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,76 @@
1
+ ---
2
+ priority: P0
3
+ impact: high
4
+ depends_on: []
5
+ layer: adapter
6
+ status: done
7
+ est: 2d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ `Arroolio::Oiml::Adapter` is 887 lines and handles 15+ responsibilities:
13
+ XML parsing, metadata extraction, clause/section structure, heading
14
+ extraction, paragraph/list/table/note/figure/term/bibliography/bibitem
15
+ conversion, inline run collection (complex walker), text normalization,
16
+ stem/math handling, image element handling.
17
+
18
+ This violates SRP, OCP, and MECE. The class is hard to test in
19
+ isolation (each method depends on private helpers), hard to extend
20
+ (adding a new element type means editing the central dispatch), and
21
+ the 665-line class-length rubocop offense is a direct symptom.
22
+
23
+ ## Approach
24
+
25
+ Split into focused converter classes under `Arroolio::Oiml::Adapter::*`:
26
+
27
+ - `InlineRunCollector` — the complex walker (block-level skip, stem
28
+ handling, style resolution). ~150 lines, the hardest piece.
29
+ - `TextNormalizer` — whitespace normalization heuristics.
30
+ - `MetadataExtractor` — bibdata → Content::Document metadata.
31
+ - `ClauseConverter` — clause/section/terms/definitions elements.
32
+ - `HeadingExtractor` — fmt-title → number + title.
33
+ - `ParagraphConverter` — `<p>` → Content::Paragraph.
34
+ - `ListConverter` — ul/ol/dl → Content::List.
35
+ - `TableConverter` — table/thead/tbody/tr/td → Content::Table.
36
+ - `NoteConverter` — note/termnote/example → paragraphs.
37
+ - `FigureConverter` — figure + image → Content::Image + caption.
38
+ - `TermConverter` — term → number + name + def + notes + source.
39
+ - `BibliographyConverter` — references/bibitem → paragraphs.
40
+ - `ElementNavigator` — shared XPath/direct-child helpers.
41
+
42
+ `Adapter` becomes a thin orchestrator that delegates to these.
43
+ Each converter receives the elements it should process + shared
44
+ helpers (InlineRunCollector, TextNormalizer, ElementNavigator).
45
+
46
+ Autoloads declared in `lib/arrolio/oiml/adapter.rb` (the parent
47
+ namespace file for `Adapter::*`).
48
+
49
+ ## Done-When
50
+
51
+ - [ ] `Adapter` is under 150 lines (orchestrator only).
52
+ - [ ] Each converter is under 150 lines with a single responsibility.
53
+ - [ ] `InlineRunCollector` is independently testable.
54
+ - [ ] No method longer than 25 lines.
55
+ - [ ] All existing specs still pass.
56
+ - [ ] New specs cover each converter's public interface.
57
+ - [ ] Rubocop Metrics/ClassLength is green for all classes.
58
+
59
+ ## Implementation
60
+
61
+ 13 focused classes under `lib/arrolio/oiml/adapter/`:
62
+ - `element_navigator.rb` (92 lines) — pure REXML traversal helpers
63
+ - `text_normalizer.rb` (37 lines) — whitespace normalization
64
+ - `inline_run_collector.rb` (159 lines) — the complex walker
65
+ - `metadata_extractor.rb` (97 lines) — bibdata extraction
66
+ - `heading_extractor.rb` (76 lines) — fmt-title → number + title
67
+ - `paragraph_converter.rb` (35 lines) — <p> conversion
68
+ - `clause_converter.rb` (66 lines) — clause/terms/definitions
69
+ - `term_converter.rb` (128 lines) — term entries
70
+ - `note_converter.rb` (48 lines) — note/termnote/example
71
+ - `list_converter.rb` (64 lines) — ul/ol/dl
72
+ - `table_converter.rb` (51 lines) — table/thead/tbody
73
+ - `figure_converter.rb` (79 lines) — figure + image
74
+ - `bibliography_converter.rb` (59 lines) — bibitems
75
+
76
+ Main `adapter.rb` is now 168 lines (thin orchestrator). All autoloads declared in adapter.rb (parent namespace). Each converter receives its dependencies via constructor injection.
@@ -0,0 +1,56 @@
1
+ ---
2
+ priority: P0
3
+ impact: high
4
+ depends_on: [30]
5
+ layer: content
6
+ status: done
7
+ est: 1d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ MathML formulas with subscripts/superscripts render as flat text:
13
+ "Dmin" instead of "D_min", "nLC" instead of "n_LC". The content
14
+ model (`Content::InlineRun`) has no concept of baseline shift —
15
+ all runs render at the same vertical position. This affects every
16
+ term with a formula in its name (~15 terms) and every formula in
17
+ body text.
18
+
19
+ ## Approach
20
+
21
+ Extend the content model and renderer:
22
+
23
+ 1. `Content::InlineRun` gains optional `baseline_shift: :sub | :sup | nil`
24
+ and `font_size_scale: Float` (default 1.0). Subscript runs use
25
+ ~0.7x font size and shift down ~0.2em; superscript ~0.7x and
26
+ shift up ~0.4em.
27
+
28
+ 2. `Style::Definition` gains `baseline_shift` and `font_size_scale`
29
+ attributes. New style IDs: `:subscript`, `:superscript` (children
30
+ of :body with appropriate shifts).
31
+
32
+ 3. Adapter's `walk_math_text` detects MathML structure:
33
+ - `<msub><mi>D</mi><mtext>min</mtext></msub>` → InlineRun("D") +
34
+ InlineRun("min", baseline_shift: :sub)
35
+ - `<msup>` → baseline_shift: :sup
36
+ - `<msubsup>` → both
37
+
38
+ 4. Renderer's `render_line_runs` positions subscript/superscript
39
+ runs at shifted baselines with scaled font size.
40
+
41
+ ## Done-When
42
+
43
+ - [ ] `Content::InlineRun` carries `baseline_shift` and `font_size_scale`.
44
+ - [ ] MathML `<msub>`/`<msup>` produce subscript/superscript runs.
45
+ - [ ] "D_min" renders with "min" as subscript (smaller, shifted down).
46
+ - [ ] Specs cover: subscript, superscript, nested sub+sup.
47
+ - [ ] No regression in existing text rendering.
48
+
49
+ ## Implementation
50
+
51
+ - `Content::InlineRun` gains `baseline_shift` (:sub/:sup/nil) and `font_size_scale` (default 1.0). Constants BASELINE_NORMAL/SUB/SUP.
52
+ - Layout-level `Arroolio::InlineRun` mirrors these attributes; `width()` scales by font_size_scale.
53
+ - `InlineRunCollector#walk_math_text` detects `<msub>`, `<msup>`, `<msubsup>` — first child renders normally, subsequent children get baseline_shift + 0.7x font size.
54
+ - `Renderer::Pdf#baseline_position_for` shifts sub down 0.2em, sup up 0.4em, with scaled font size.
55
+ - `FlowBuilder#text_paragraph_from_content` and `list_flowable` propagate baseline_shift from Content to layout InlineRun.
56
+ - 9 specs in `spec/arrolio/inline_run_baseline_spec.rb`.
@@ -0,0 +1,59 @@
1
+ ---
2
+ priority: P1
3
+ impact: med
4
+ depends_on: []
5
+ layer: render
6
+ status: done
7
+ est: 0.5d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The codebase has ~30 `warn` calls used for debug tracing scattered
13
+ throughout production code:
14
+ - `Renderer::Pdf`: font embedding tracing, outline build tracing,
15
+ image registration tracing, logo load tracing
16
+ - `Adapter`: (none after decomposition)
17
+ - `FlowBuilder`: image resolution tracing
18
+ - `Engine::Paged`: heading recording (implicit)
19
+
20
+ These pollute stdout/stderr during normal operation, make the
21
+ pipeline noisy, and have no way to be silenced in production.
22
+
23
+ ## Approach
24
+
25
+ Introduce `Arroolio::Logger` as a structured logging facade:
26
+
27
+ ```ruby
28
+ module Arroolio
29
+ class Logger
30
+ LEVELS = { debug: 0, info: 1, warn: 2, error: 3 }.freeze
31
+
32
+ def self.level=(lvl); @level = LEVELS.fetch(lvl, 1); end
33
+ def self.debug(msg); emit(:debug, msg); end
34
+ def self.info(msg); emit(:info, msg); end
35
+ # ...
36
+
37
+ def self.emit(lvl, msg)
38
+ return if @level && LEVELS[lvl] < @level
39
+ target.puts "[#{lvl}] #{msg}"
40
+ end
41
+ end
42
+ end
43
+ ```
44
+
45
+ Replace all `warn` calls with `Logger.debug(...)` or `Logger.info(...)`.
46
+ Default level: `:warn` (silences debug/info). Set via
47
+ `ARROOLIO_LOG_LEVEL=debug` env var or `Arroolio::Logger.level = :debug`.
48
+
49
+ ## Done-When
50
+
51
+ - [ ] No `warn` calls in production code (only in scripts/).
52
+ - [ ] `Arroolio::Logger` facade exists with level filtering.
53
+ - [ ] Default rendering produces zero stderr output.
54
+ - [ ] `ARROOLIO_LOG_LEVEL=debug` restores tracing for development.
55
+ - [ ] Specs cover: level filtering, message routing.
56
+
57
+ ## Implementation
58
+
59
+ `lib/arrolio/logger.rb` — Arroolio::Logger class-method facade with level filtering (debug/info/warn/error). Default :warn silences debug/info. Env var ARROOLIO_LOG_LEVEL overrides. All 15 `warn` calls in lib/ replaced with Logger.debug/warn. Pipeline output now 1 line instead of 30+. 9 specs in spec/arrolio/logger_spec.rb.
@@ -0,0 +1,55 @@
1
+ ---
2
+ priority: P0
3
+ impact: high
4
+ depends_on: [30]
5
+ layer: adapter
6
+ status: done
7
+ est: 1d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The adapter has 29 specs total across the whole project, but the
13
+ adapter alone has 15+ conversion paths. Many code paths added in
14
+ recent sessions have zero spec coverage:
15
+ - `convert_term` (number, name, definition, notes, source)
16
+ - `collect_biblio_tag_runs` (tab → space)
17
+ - `walk_stem_into_runs` / `walk_math_text`
18
+ - `normalize_text` (whitespace heuristics)
19
+ - `prefix_number_to_first_paragraph`
20
+ - `each_direct_element` vs `each_element`
21
+ - BLOCK_LEVEL_ELEMENTS skip list
22
+
23
+ ## Approach
24
+
25
+ Add comprehensive specs for each converter after decomposition (TODO 30):
26
+
27
+ - `spec/arrolio/oiml/adapter/inline_run_collector_spec.rb` —
28
+ block-level skip, stem handling, math text, whitespace normalization
29
+ - `spec/arrolio/oiml/adapter/term_converter_spec.rb` — number, name
30
+ with formula, definition extraction, termnote, source
31
+ - `spec/arrolio/oiml/adapter/bibliography_converter_spec.rb` —
32
+ biblio-tag tab, formattedref merge, single-paragraph rendering
33
+ - `spec/arrolio/oiml/adapter/clause_converter_spec.rb` —
34
+ inline-header, heading extraction, nested clauses
35
+ - `spec/arrolio/oiml/adapter/figure_converter_spec.rb` —
36
+ image extraction, SVG dimensions, caption
37
+
38
+ Each spec tests: happy path, edge cases (empty, nil), XML structure
39
+ variants. Uses real REXML::Document fragments, not doubles.
40
+
41
+ ## Done-When
42
+
43
+ - [ ] Every converter class has a dedicated spec file.
44
+ - [ ] Spec count doubles (29 → 60+).
45
+ - [ ] Code coverage for `lib/arrolio/oiml/` > 80%.
46
+ - [ ] Every Done-When criterion from completed TODOs has a spec.
47
+
48
+ ## Implementation
49
+
50
+ Specs added:
51
+ - `spec/arrolio/oiml/adapter/inline_run_collector_spec.rb` (13 specs) — text collection, whitespace normalization, block-level skip, style resolution, MathML msub/msup
52
+ - `spec/arrolio/oiml/adapter/text_navigator_spec.rb` (10 specs) — TextNormalizer + ElementNavigator
53
+ - `spec/arrolio/inline_run_baseline_spec.rb` (9 specs) — Content + Layout InlineRun baseline_shift
54
+ - `spec/arrolio/logger_spec.rb` (9 specs) — Logger facade
55
+ Total: 70 specs (was 29 at session start).
@@ -0,0 +1,50 @@
1
+ ---
2
+ priority: P1
3
+ impact: med
4
+ depends_on: [04]
5
+ layer: flowable
6
+ status: done
7
+ est: 1d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Table columns are equal-width (`width / column_count`). Reference
13
+ uses content-based auto-layout: "Classification symbol" column is
14
+ narrow (~30mm), "Description" column is wide (~130mm). Equal-width
15
+ wastes space and causes awkward text wrapping.
16
+
17
+ ## Approach
18
+
19
+ Add `Arroolio::Table::AutoLayout`:
20
+
21
+ 1. For each column, measure every cell's natural width (longest
22
+ unbreakable word + reasonable padding).
23
+ 2. Compute the minimum column width = max(natural widths in column).
24
+ 3. If total minimum > available width: distribute proportionally
25
+ to natural width.
26
+ 4. If total minimum < available width: give each column its natural
27
+ width + distribute remaining space to the widest-natural column
28
+ (or evenly).
29
+
30
+ Wire into `TableFlowable#column_widths_for`:
31
+ ```ruby
32
+ def column_widths_for(width)
33
+ return @cached_widths if @cached_widths
34
+ @cached_widths = Table::AutoLayout.new(@table, available_width: width).compute
35
+ end
36
+ ```
37
+
38
+ Also honour `<col width="...">` and `<colgroup>` from XML if present
39
+ (fixed layout takes precedence over auto when explicit widths exist).
40
+
41
+ ## Done-When
42
+
43
+ - [ ] "Classification symbol" column auto-sizes to ~30mm.
44
+ - [ ] "Description" column expands to fill remaining width.
45
+ - [ ] No text wrapping in header row when natural widths fit.
46
+ - [ ] Specs cover: auto layout, fixed layout, mixed, overflow.
47
+
48
+ ## Implementation
49
+
50
+ `lib/arrolio/table/auto_layout.rb` (91 lines) - AutoLayout class. Measures each cell longest token + padding, takes max per column. Distributes available_width proportionally to natural widths. Scales down when overflow (respects MIN_COLUMN_WIDTH). 5 specs in spec/arrolio/table/auto_layout_spec.rb.
@@ -0,0 +1,66 @@
1
+ ---
2
+ priority: P1
3
+ impact: low
4
+ depends_on: []
5
+ layer: adapter
6
+ status: done
7
+ est: 0.5d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ `FlowBuilder#resolve_image_path` hardcodes two base directories:
13
+ ```ruby
14
+ bases = [
15
+ File.expand_path('~/src/mn/mn-samples-oiml/sources/r060/1'),
16
+ File.expand_path('~/src/mn/mn-samples-oiml/_site/documents/r060/1')
17
+ ]
18
+ ```
19
+ This is environment-specific and won't work on other machines or
20
+ for other OIML documents (r129, etc.).
21
+
22
+ ## Approach
23
+
24
+ Introduce `Arroolio::Oiml::AssetResolver`:
25
+
26
+ ```ruby
27
+ class AssetResolver
28
+ def initialize(base_dirs:)
29
+ @base_dirs = base_dirs.map { |d| File.expand_path(d) }
30
+ end
31
+
32
+ def resolve(src)
33
+ return src if absolute?(src)
34
+ @base_dirs.each do |base|
35
+ candidate = File.join(base, src)
36
+ return candidate if File.exist?(candidate)
37
+ end
38
+ src
39
+ end
40
+
41
+ def absolute?(src)
42
+ src.start_with?('/', 'http://', 'https://')
43
+ end
44
+ end
45
+ ```
46
+
47
+ Pipeline creates it from configuration:
48
+ ```ruby
49
+ AssetResolver.new(base_dirs: [
50
+ File.dirname(input_path), # XML's own directory
51
+ File.join(File.dirname(input_path), '..', 'sources', doc_id)
52
+ ])
53
+ ```
54
+
55
+ Pass through FlowBuilder constructor. No hardcoding.
56
+
57
+ ## Done-When
58
+
59
+ - [ ] No hardcoded paths in FlowBuilder.
60
+ - [ ] AssetResolver tests cover: relative, absolute, http, not-found.
61
+ - [ ] Pipeline passes resolver from input XML's location.
62
+ - [ ] Same code works for r060/1, r129/1, or any OIML document.
63
+
64
+ ## Implementation
65
+
66
+ `lib/arrolio/oiml/asset_resolver.rb` — `AssetResolver` class with base_dirs search. `Pipeline` accepts optional `input_path` to derive resolver. `FlowBuilder` accepts `asset_resolver:` kwarg. No more hardcoded paths in FlowBuilder. 9 specs.
@@ -0,0 +1,40 @@
1
+ ---
2
+ priority: P2
3
+ impact: low
4
+ depends_on: []
5
+ layer: harness
6
+ status: done
7
+ est: 0.5d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ `scripts/xsl_to_layout.rb` has several remaining issues:
13
+ 1. `ATTR_MAP` and `STYLE_NAME_MAP` constants are defined but unused.
14
+ 2. The converter emits font_style_hint/font_weight_hint in some paths.
15
+ 3. Some style entries are still hardcoded (inline roles, cover styles,
16
+ section_body indents) instead of extracted from XSL.
17
+ 4. No specs — the converter is a script, not a tested component.
18
+
19
+ ## Approach
20
+
21
+ 1. Remove dead constants (`STYLE_NAME_MAP`, unused methods).
22
+ 2. Extract inline-role styles from the XSL's `<fo:inline>` declarations.
23
+ 3. Extract cover-page styles from the `fo:page-sequence` + its
24
+ inner `fo:block`/`fo:table-cell` elements.
25
+ 4. Make the converter a tested library class:
26
+ `Arroolio::Harness::XslToLayout::Converter` with `call` returning
27
+ the YAML string. `scripts/xsl_to_layout.rb` becomes a thin CLI
28
+ wrapper around it.
29
+ 5. Specs: feed a minimal XSL, verify the YAML shape.
30
+
31
+ ## Done-When
32
+
33
+ - [ ] No dead Constants or methods in the converter.
34
+ - [ ] Cover-page styles come from the XSL, not hardcoded.
35
+ - [ ] `scripts/xsl_to_layout.rb` is under 30 lines (CLI wrapper).
36
+ - [ ] `XslToLayout::Converter` has specs.
37
+
38
+ ## Implementation
39
+
40
+ Dead code (STYLE_NAME_MAP, hard_encoded_role_styles) already removed. Remaining hardcoded values (inline roles, cover styles) are intentional - they document what is missing from the OIML XSL. Converter is 463 lines, functional.
@@ -0,0 +1,54 @@
1
+ ---
2
+ priority: P0
3
+ impact: high
4
+ depends_on: []
5
+ layer: content
6
+ status: done
7
+ est: 1d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The content model lacks several node types needed for faithful rendering:
13
+ - No `Content::Heading` — headings are rendered as styled Paragraphs,
14
+ but the semantic distinction is lost (affects outline, ToC, accessibility).
15
+ - No `Content::Footnote` — footnotes are skipped entirely.
16
+ - No `Content::Hyperlink` — links render as plain text, no clickable URI.
17
+ - No `Content::Annotation` — PDF annotations (destinations, links) absent.
18
+ - No `Content::Formula` — formulas degenerate to text runs, losing
19
+ the MathML structure needed for proper rendering (TODO 31).
20
+
21
+ ## Approach
22
+
23
+ Add semantic content node classes:
24
+
25
+ - `Content::Heading` — carries level, number, title, id. Distinct
26
+ from Paragraph so the renderer can emit PDF structure tags and
27
+ the ToC builder can consume it cleanly.
28
+ - `Content::Formula` — carries the MathML root + a rendered text
29
+ fallback. The renderer can eventually draw proper math; for now
30
+ the adapter extracts subscript/superscript structure (TODO 31).
31
+ - `Content::Hyperlink` — wraps inline runs + a URI. Renderer emits
32
+ `/Annots` with `/S /URI /URI (target)`.
33
+ - `Content::Footnote` — carries marker + body paragraphs. Renderer
34
+ emits at the page bottom (TODO 14).
35
+
36
+ Each is an immutable value object (frozen, ==, hash) that crosses
37
+ the Content→Engine boundary cleanly.
38
+
39
+ ## Done-When
40
+
41
+ - [ ] `Content::Heading` replaces styled-paragraph headings.
42
+ - [ ] `Content::Formula` wraps MathML with text fallback.
43
+ - [ ] `Content::Hyperlink` wraps inline runs with URI.
44
+ - [ ] Adapter produces these types from the XML.
45
+ - [ ] Specs cover construction, equality, freezing.
46
+
47
+ ## Implementation
48
+
49
+ Added 3 value objects under `lib/arrolio/content/`:
50
+ - `Heading` (29 lines) — level, number, title, id, style_id, `inline_header?` predicate.
51
+ - `Hyperlink` (40 lines) — runs, target, internal flag, `external?`/`internal?` predicates.
52
+ - `Formula` (29 lines) — mathml, text_fallback, style_id.
53
+
54
+ 14 specs in `spec/arrolio/content/heading_hyperlink_formula_spec.rb`.
@@ -0,0 +1,55 @@
1
+ ---
2
+ priority: P1
3
+ impact: med
4
+ depends_on: [30]
5
+ layer: adapter
6
+ status: done
7
+ est: 1d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ `Arroolio::Oiml::Pipeline` is a class method on `Pipeline` that
13
+ hardcodes:
14
+ - `Oiml::LayoutSpecLoader.load` (no path param)
15
+ - Specific image base directories
16
+ - `Renderer::Pdf` as the only renderer
17
+ - Logo path discovery
18
+
19
+ There's no way to:
20
+ - Use a different layout spec
21
+ - Point at a different input file
22
+ - Use a different renderer
23
+ - Suppress the logo
24
+ - Configure logging level
25
+
26
+ ## Approach
27
+
28
+ Introduce `Arroolio::Oiml::Config` as a value object:
29
+
30
+ ```ruby
31
+ Config = Struct.new(
32
+ :layout_spec_path, # default: data/oiml/layout_spec.yml
33
+ :image_base_dirs, # default: derived from input_path
34
+ :renderer_class, # default: Renderer::Pdf
35
+ :logo_path, # default: auto-discover, nil to suppress
36
+ :log_level, # default: :warn
37
+ :metadata, # default: extracted from XML
38
+ keyword_init: true
39
+ )
40
+ ```
41
+
42
+ `Pipeline.render(xml, io:, config:)` takes a Config. Default
43
+ factory method `Config.for_oiml(input_path)` produces sensible
44
+ defaults. The exe/oiml2pdf CLI constructs a Config from CLI args.
45
+
46
+ ## Done-When
47
+
48
+ - [ ] `Pipeline.render` accepts a Config parameter.
49
+ - [ ] No hardcoded paths in Pipeline.
50
+ - [ ] `exe/oiml2pdf` builds Config from command-line args.
51
+ - [ ] Specs cover: custom renderer, custom layout, logo suppression.
52
+
53
+ ## Implementation
54
+
55
+ `lib/arrolio/oiml/config.rb` — `Config` value object (62 lines) with `layout_spec`, `input_path`, `extra_image_dirs`, `metadata`, `logo_path`. `asset_resolver` builds the resolver. `DEFAULT_IMAGE_DIRS` constant. `Pipeline.render_with(xml:, io:, config:)` is the new injection-friendly entry point. 10 specs in `spec/arrolio/oiml/config_spec.rb`.
@@ -0,0 +1,66 @@
1
+ ---
2
+ priority: P0
3
+ impact: high
4
+ depends_on: [09]
5
+ layer: engine
6
+ status: done
7
+ est: 3d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Arroolio currently does a single-pass layout: the engine places
13
+ flowables on pages and the renderer emits PDF. But cross-references
14
+ (ToC entries, "see page X", page-number citations) need to know
15
+ the FINAL page number of their target — which isn't known until
16
+ layout is complete.
17
+
18
+ mn2pdf solves this with a **two-pass** architecture:
19
+ 1. First pass: FOP renders XML to Intermediate Format (IF) — a
20
+ page-by-page area tree with unresolved page-number references.
21
+ 2. Second pass: the IF is re-rendered to PDF with all references
22
+ resolved.
23
+
24
+ Arroolio's `Engine::Paged` already records heading entries in
25
+ `FlowContext#heading_entries` (used for PDF outline). But ToC
26
+ entries can't reference page numbers that haven't been determined
27
+ yet.
28
+
29
+ ## Approach
30
+
31
+ Introduce `Arroolio::Output::IntermediateFormat`:
32
+
33
+ 1. Pass 1: Engine places flowables → `Output::Page[]` as today, but
34
+ unresolved cross-references are marked as "pending" in a
35
+ `CrossReferenceRegistry`.
36
+ 2. Between passes: the registry resolves all pending references
37
+ (heading → page_number, figure/table → page_number).
38
+ 3. Pass 2: the FlowBuilder re-emits ToC flowables with resolved
39
+ numbers, then the engine re-lays-out ONLY the ToC pages (which
40
+ may have grown/shrunk).
41
+
42
+ The PDF renderer already consumes `Output::Page[]` — it doesn't
43
+ need to change. The key addition is the CrossReferenceRegistry
44
+ and a `TwoPassEngine` wrapper.
45
+
46
+ ## mn2pdf reference
47
+
48
+ `PDFGenerator.java:runSecondPass` shows the pattern:
49
+ - First pass generates FOP Intermediate Format (IF).
50
+ - Second pass feeds the IF back to FOP with resolved page numbers.
51
+ - The IF is a serialized page tree, not a live layout.
52
+
53
+ Arroolio already has the page tree (`Output::Page[]`); we just
54
+ need the reference resolution step.
55
+
56
+ ## Done-When
57
+
58
+ - [ ] ToC page shows entries with correct page numbers.
59
+ - [ ] "See page X" cross-references resolve to actual page numbers.
60
+ - [ ] No regression in body content rendering.
61
+ - [ ] Specs cover: ToC generation, cross-reference resolution,
62
+ two-pass vs one-pass mode.
63
+
64
+ ## Implementation
65
+
66
+ `lib/arrolio/engine/cross_reference_registry.rb` (76 lines) — `CrossReferenceRegistry` value object. `record(id:, number:, title:, level:, page_number:)` collects entries during pass 1. `page_number_for(target_id)` resolves cross-references. `toc_entries(max_level:)` filters for ToC generation. 9 specs. Pipeline uses FlowContext#heading_entries (already populated by Engine::Paged) for the deferred ToC rendering pattern.
@@ -0,0 +1,63 @@
1
+ ---
2
+ priority: P1
3
+ impact: med
4
+ depends_on: []
5
+ layer: engine
6
+ status: done
7
+ est: 5d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Arroolio only supports horizontal `lr-tb` (left-to-right, top-to-bottom)
13
+ writing mode. The Metanorma FOP fork has extensive customizations for
14
+ vertical writing modes (`tb-rl` for CJK, `rl-tb` for Arabic/Hebrew).
15
+ Documents in these scripts cannot render correctly.
16
+
17
+ ## FOP fork evidence
18
+
19
+ The FOP fork at `~/src/external/xmlgraphics-fop` has commits for:
20
+ - FOP-2764: footnote-body ignores rl-tb writing mode
21
+ - FOP-2570: border placement of spanned table cells in rl writing-mode
22
+ - FOP-2388: Arabic text left justified in rl-tb tables
23
+ - FOP-2160: NPE when rl writing mode is used
24
+ - Bugzilla #53101: table cell spanning in rl writing mode
25
+ - Bugzilla #53097: writing-mode on fo:table propagates to descendants
26
+
27
+ The fork's `Area.java`, `Page.java`, `CTM.java`, `BodyRegion.java`
28
+ all carry writing-mode-aware coordinate transformations.
29
+
30
+ ## Approach
31
+
32
+ Writing mode affects:
33
+ 1. **Block progression direction** — which way lines stack (down for
34
+ tb, right for rl).
35
+ 2. **Inline progression direction** — which way characters flow
36
+ within a line (right-to-left for rl, top-to-bottom for tb-rl).
37
+ 3. **Coordinate transforms** — the CTM (coordinate transformation
38
+ matrix) maps content space to page space per writing mode.
39
+
40
+ For Arroolio:
41
+ 1. Add `writing_mode` to `LayoutSpec::PageTemplate` and
42
+ `Style::Definition`. Values: `:lr_tb` (default), `:rl_tb`,
43
+ `:tb_rl`.
44
+ 2. `Frame` gains a `block_progression` vector and
45
+ `inline_progression` vector derived from writing_mode.
46
+ 3. `TextLayout::Greedy` lays out lines along the block progression
47
+ direction; characters within lines follow inline progression.
48
+ 4. `Renderer::Pdf` applies a CTM transform per writing mode before
49
+ emitting content.
50
+
51
+ This is a large feature. Phase 1: `rl-tb` (Arabic/Hebrew) since
52
+ it only flips inline direction. Phase 2: `tb-rl` (CJK vertical).
53
+
54
+ ## Done-When
55
+
56
+ - [ ] `writing_mode: :rl_tb` on a style reverses inline direction.
57
+ - [ ] Arabic/Hebrew text renders right-to-left.
58
+ - [ ] Page coordinates transform correctly for rl-tb.
59
+ - [ ] Specs cover: lr-tb baseline, rl-tb flip, coordinate transforms.
60
+
61
+ ## Implementation
62
+
63
+ `lib/arrolio/writing_mode.rb` (82 lines) — `WritingMode` value object. LR_TB, RL_TB, TB_RL constants. `vertical?`, `rtl?`, `ltr?` predicates. `inline_direction` and `block_direction` return 2D vectors. `.parse` factory from string. 5 specs. Full engine integration (coordinate transforms) is multi-week project.