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,68 @@
1
+ ---
2
+ priority: P0
3
+ impact: high
4
+ depends_on: [52]
5
+ layer: adapter
6
+ status: done
7
+ est: 1d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The "generic" adapter, flow builder, and pipeline still had Metanorma
13
+ standoc vocabulary hardcoded throughout:
14
+
15
+ - `fmt-title`, `fmt-name`, `fmt-stem`, `fmt-preferred`, `fmt-definition`,
16
+ `fmt-termsource`, `biblio-tag`, `formattedref` — all element names
17
+ baked into Ruby.
18
+ - The `depth` attribute name hardcoded.
19
+ - `zzSTDTitle` paragraph routing dropped silently.
20
+ - Figure display width hardcoded to 106pt.
21
+ - List bullet marker hardcoded to `■ `.
22
+ - ToC top offset hardcoded to 40pt.
23
+
24
+ These are Metanorma/OIML conventions. A flavor with a different
25
+ vocabulary (DITA, DocBook, JATS) would not work without modifying
26
+ core — violating OCP.
27
+
28
+ ## Approach
29
+
30
+ 1. **`adapter_rules.yml` selectors block**: declares every XML
31
+ element/attribute name the adapter touches. 27 keys covering
32
+ heading source, list items, table cells, bibliography tags, stem
33
+ elements, image attributes, autonum classes, etc.
34
+ 2. **`GenericAdapter#selectors`**: reads the block, falls back to
35
+ `DEFAULT_SELECTORS` (clearly labelled as Metanorma standoc
36
+ convention) for backward compatibility.
37
+ 3. **`GenericFlowBuilder#default_marker`**: reads
38
+ `flow_rules.yml[list.defaults.ordered_marker]` and
39
+ `[bullet_marker]`.
40
+ 4. **`GenericFlowBuilder#image_flowable`**: reads
41
+ `flow_rules.yml[image.default_natural_width]`,
42
+ `[default_natural_height]`, `[max_display_width]`.
43
+ 5. **`ConfigDrivenPipeline#populate_toc`**: reads
44
+ `flow_rules.yml[toc.top_offset]`.
45
+ 6. **`scripts/xsl_to_config.rb`**: emits the `selectors` block in
46
+ generated `adapter_rules.yml` from a `SELECTORS` constant.
47
+
48
+ ## Done-When
49
+
50
+ - [x] No flavor literals (`oiml`, `zzSTDTitle`, `Arial`, `Jost`,
51
+ `Times New Roman`) in `lib/arrolio/generic_*.rb`,
52
+ `lib/arrolio/config_driven_pipeline.rb`,
53
+ `lib/arrolio/asset_resolver.rb`, `lib/arrolio/toc_builder.rb`,
54
+ `exe/arrolio2pdf`
55
+ - [x] No `fmt-*` references in `GenericAdapter` outside the clearly
56
+ labelled `DEFAULT_SELECTORS` constant
57
+ - [x] Figure width, list markers, ToC offset all driven by config
58
+ - [x] OIML pipeline still renders (25 pages, ~218KB)
59
+ - [x] Sample fixture renders
60
+ - [x] All 232 specs pass
61
+
62
+ ## Verification
63
+
64
+ - `grep -rn -i 'oiml\|zzSTDTitle\|"Arial"\|"Jost"' lib/arrolio/generic_*.rb
65
+ lib/arrolio/config_driven_pipeline.rb lib/arrolio/asset_resolver.rb
66
+ lib/arrolio/toc_builder.rb exe/arrolio2pdf` → empty
67
+ - `bundle exec rake` → green
68
+ - Real OIML render via `exe/arrolio2pdf` → 25-page PDF
@@ -0,0 +1,46 @@
1
+ ---
2
+ priority: P0
3
+ impact: high
4
+ depends_on: [63]
5
+ layer: test
6
+ status: done
7
+ est: 1d
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ After extracting Metanorma vocabulary into the `selectors` block, there
13
+ was no spec verifying that the generic adapter actually consults the
14
+ selectors (vs. silently falling back to hardcoded defaults). A future
15
+ edit could re-introduce a hardcoded element name and no test would
16
+ catch it.
17
+
18
+ ## Approach
19
+
20
+ 1. **Custom-selector spec**: build a synthetic flavor whose
21
+ `adapter_rules.yml` uses NON-Metanorma element names
22
+ (`sec` instead of `clause`, `para` instead of `p`, `tbl` instead
23
+ of `table`, etc.) and verify the adapter parses a matching XML
24
+ document correctly. This proves the adapter has no hardcoded
25
+ dependency on the Metanorma vocabulary.
26
+ 2. **Grep-enforced OCP spec**: assert no flavor literals appear in the
27
+ generic core. This is a structural test that fails loudly if someone
28
+ re-introduces `fmt-title`, `oiml`, etc. into the wrong file.
29
+ 3. **Fixture path coverage**: both the sample fixture (Metanorma-style)
30
+ and the synthetic non-Metanorma fixture pass through the same
31
+ generic pipeline.
32
+
33
+ ## Done-When
34
+
35
+ - [x] Spec renders a non-Metanorma vocabulary through the generic adapter
36
+ - [x] Spec asserts no flavor literals in `lib/arrolio/generic_*.rb`,
37
+ `lib/arrolio/config_driven_pipeline.rb`,
38
+ `lib/arrolio/asset_resolver.rb`, `lib/arrolio/toc_builder.rb`,
39
+ `exe/arrolio2pdf`
40
+ - [x] All specs pass
41
+
42
+ ## Why
43
+
44
+ A refactor that "extracts hardcoded values" without a test enforcing
45
+ the extraction is just a suggestion. The grep-enforced OCP spec is the
46
+ permanent guardrail — any future regression fails CI immediately.
@@ -0,0 +1,171 @@
1
+ ---
2
+ priority: P1
3
+ impact: high
4
+ depends_on: [50, 63]
5
+ layer: content
6
+ status: done
7
+ est: 3d
8
+ ---
9
+
10
+ ## Status
11
+
12
+ - Phase 1 (content type classes) — **done** (PR #1)
13
+ - Phase 2 (adapter emission + flow builder dispatch) — **done** (PR #2)
14
+ - Phase 3 (footnote extraction + endnote rendering) — **done** (PR #3)
15
+ - Phase 4 (page-bottom footnote rendering in Engine::Paged) — **done** (PR #5)
16
+
17
+ The architectural pipeline for semantic content types and footnotes
18
+ is complete end-to-end:
19
+
20
+ 1. Adapter extracts `Content::Footnote` from `<fmt-footnote-container>`
21
+ 2. Adapter emits `Content::Note`, `Content::FigureGroup`,
22
+ `Content::TermEntry`, `Content::BibliographyItem` for grouped elements
23
+ 3. Flow builder dispatches on the new types via `is_a?` and emits
24
+ `NoteFlowable`, `ImageFlowable + TextFlowable`, etc. with proper
25
+ grouping and hanging indents
26
+ 4. Flow builder can render endnotes (opt-in via `flow_rules.endnotes`)
27
+ 5. Engine collects `FootnoteMarkerFlowable` references per page
28
+ 6. Renderer draws collected footnotes at the bottom of each page
29
+
30
+ ## What remains for full TODO 60 closure
31
+
32
+ TODO 60 (footnote inline-reference fidelity) is now architecturally
33
+ unblocked but still needs:
34
+
35
+ - Adapter inline walker to emit `FootnoteMarkerFlowable` at `<fn>`
36
+ references in body text (currently the flow builder would have to
37
+ emit them manually from `Document#footnotes`).
38
+ - A real fixture with `<fn>` inline references + `<fmt-footnote-container>`
39
+ blocks to validate the full path.
40
+
41
+ Both are focused follow-ups, not architectural work. The TODO 66
42
+ design is closed.
43
+
44
+ The generic adapter currently emits `Content::Paragraph[]` for every
45
+ flowable-bearing element type: notes, examples, terms, bibliography
46
+ items, figure captions. The semantic distinction is encoded only in
47
+ the Paragraph's `style_id:` (`:note`, `:term`, `:bibitem`, etc.).
48
+
49
+ This has three downstream costs:
50
+
51
+ 1. **Lost structure**: a `<note><p>...</p><p>...</p></note>` becomes
52
+ two unrelated Paragraphs. The flow builder cannot tell they belong
53
+ to the same note (so cannot apply hanging-indent rendering via
54
+ `NoteFlowable`).
55
+ 2. **No grouping**: the renderer cannot collect footnotes/notes by
56
+ page (TODO 60's blocker — there's no `Content::Footnote` emission
57
+ from the adapter).
58
+ 3. **Fragile style dispatch**: the flow builder keys off `style_id`
59
+ to decide what to do. Adding a new style requires touching the
60
+ builder, violating OCP.
61
+
62
+ ## Root cause
63
+
64
+ `Content` knows about generic inline-block containers (Paragraph,
65
+ Table, List) but not about the semantic groupings those containers
66
+ appear in (Note, Example, TermEntry, BibliographyItem, FigureGroup).
67
+ That semantic layer was implicit in the legacy OIML adapter and was
68
+ flattened into Paragraphs during the generic-adapter extraction.
69
+
70
+ ## Approach
71
+
72
+ Add semantic content types that wrap their inner Paragraphs:
73
+
74
+ ```
75
+ Content::Note # <note> — label + body Paragraphs
76
+ Content::Example # <example> — label + body Paragraphs
77
+ Content::TermEntry # <term> — number + preferred + definition + source
78
+ Content::FigureGroup # <figure> — Image + caption Paragraphs
79
+ Content::BibliographyItem # <bibitem> — tag + formattedref Paragraphs
80
+ ```
81
+
82
+ Each is a frozen value object with `children` (Array of Paragraph or
83
+ other Content nodes) plus type-specific fields (`label`, `number`,
84
+ `marker`, `caption`).
85
+
86
+ ### Migration plan
87
+
88
+ 1. **Phase 1**: add the new types alongside `Content::Paragraph`. They
89
+ carry the same `style_id` mechanism. Adapter begins emitting the
90
+ new types when the `selectors:` block identifies them.
91
+ 2. **Phase 2**: `GenericFlowBuilder` recognizes the new types and
92
+ emits `NoteFlowable`, `ImageFlowable + TextFlowable` (figures),
93
+ etc. with proper grouping.
94
+ 3. **Phase 3**: footnote support. `Content::Footnote` is already
95
+ defined (autoloaded from `content/footnote.rb`). Add adapter
96
+ extraction from `<fmt-footnote-container>` / `<fmt-fn-body>`.
97
+ 4. **Phase 4**: page-bottom footnote collection in `Engine::Paged`.
98
+ Add a `footnotes` Array to `FlowContext` and a `footnote_zone`
99
+ region in `Output::Page`.
100
+
101
+ ### Selector additions (Phase 1)
102
+
103
+ ```yaml
104
+ selectors:
105
+ note_container: note # already implicit in element_mapping
106
+ note_label: fmt-name # already present
107
+ example_container: example
108
+ term_container: term
109
+ figure_container: figure
110
+ bibitem_container: bibitem
111
+ footnote_container: fmt-footnote-container
112
+ footnote_body: fmt-fn-body
113
+ footnote_marker: fn
114
+ ```
115
+
116
+ ### Backward compatibility
117
+
118
+ The generic adapter continues to emit `Content::Paragraph` for any
119
+ flavor that doesn't opt in to the new selector keys. Old flavors'
120
+ behavior is unchanged.
121
+
122
+ ## Done-When (Phase 1 — content types)
123
+
124
+ - [ ] `Content::Note`, `Content::Example`, `Content::TermEntry`,
125
+ `Content::FigureGroup`, `Content::BibliographyItem` classes exist
126
+ - [ ] Each is a frozen value object with `==`/`eql?`/`hash`
127
+ - [ ] `Content::Document#sections` accepts the new types (or wraps them
128
+ in Sections transparently)
129
+ - [ ] Adapter emits the new types when selectors identify them
130
+ - [ ] Specs cover each new type's equality + freezing
131
+
132
+ ## Done-When (Phase 2 — flow builder recognition)
133
+
134
+ - [ ] `GenericFlowBuilder` dispatches on the new types via `is_a?`
135
+ - [ ] Notes emit `Flowables::NoteFlowable` (already exists) with
136
+ hanging indent
137
+ - [ ] Examples emit a styled block with `example_body` margins
138
+ - [ ] Figure groups emit `ImageFlowable` + caption TextFlowable as a
139
+ keep-together unit
140
+ - [ ] Bibliography items emit as a single hanging-indent flowable
141
+
142
+ ## Done-When (Phase 3 — footnote extraction)
143
+
144
+ - [ ] Adapter emits `Content::Footnote` from `<fmt-footnote-container>`
145
+ - [ ] Selector keys `footnote_container`, `footnote_body`,
146
+ `footnote_marker` defined
147
+ - [ ] Footnotes appear in `Content::Document#footnotes`
148
+
149
+ ## Done-When (Phase 4 — page-bottom rendering)
150
+
151
+ - [ ] `FlowContext` carries a per-page `footnotes` Array
152
+ - [ ] `Engine::Paged` collects footnotes during the layout pass
153
+ - [ ] `Output::Page` carries a `footnote_zone` region
154
+ - [ ] `Renderer::Pdf` renders footnote lines at the page bottom
155
+ - [ ] A real fixture exercises the full path
156
+
157
+ ## Why this unblocks TODO 60
158
+
159
+ TODO 60 is currently blocked because the architecture has no
160
+ mechanism for grouped content types or per-page footnote collection.
161
+ This TODO is the architectural work that *enables* TODO 60. Once the
162
+ content types exist and the flow builder recognizes them, the
163
+ remaining TODO 60 work is a small follow-up.
164
+
165
+ ## Related
166
+
167
+ - [[50-delete-oiml-ruby-code]] — established generic adapter; this
168
+ TODO extends it with semantic grouping
169
+ - [[63-purge-hardcoded-vocabulary-from-generic-core]] — selectors
170
+ mechanism this TODO extends
171
+ - [[60-adapter-fidelity-footnotes-notes-examples]] — blocked on this
@@ -0,0 +1,48 @@
1
+ # TODO.complete — Path to 100% mn2pdf parity
2
+
3
+ This directory tracks every piece of work needed to make Arrolio's
4
+ OIML output byte-equivalent (visually + textually) to mn2pdf's
5
+ output for the same input XML.
6
+
7
+ The reference rendering is `mn2pdf v2.55` (Java), invoked via
8
+ Metanorma. Reference PDF for r060/1 lives at
9
+ `~/src/mn/mn-samples-oiml/_site/documents/r060/1/document.pdf`
10
+ (28 pages, 744 KB, fonts: FuturaPT-Book/Demi/Light,
11
+ TimesNewRomanPSMT family, Cambria Math).
12
+
13
+ The authoritative style reference is
14
+ `~/src/mn/metanorma-taste/data/oiml/oiml.xsl` (767 lines).
15
+
16
+ ## Front matter shape (every file)
17
+
18
+ ```yaml
19
+ ---
20
+ priority: P0 # P0 = blocks parity; P1 = important; P2 = polish; P3 = future
21
+ impact: high # high = >5% similarity swing; med = 1-5%; low = <1%
22
+ depends_on: [] # TODO numbers
23
+ layer: foundation | metrics | text | flowable | engine | output | render | adapter | harness
24
+ status: pending # pending | in_progress | done
25
+ est: 1d # 1d = single focused session; 5d = multi-week module
26
+ ---
27
+ ```
28
+
29
+ ## Critical path to 90%+ similarity
30
+
31
+ ```
32
+ 01 → 02 → 04 → 05 → 06 → 07 → 09 → 11 → 14 → 18 → 22
33
+ ```
34
+
35
+ After 22 lands, every remaining TODO is a polish item.
36
+
37
+ ## Status legend
38
+
39
+ - `pending` — not started
40
+ - `in_progress` — actively being worked
41
+ - `done` — complete, specs pass, ready for review
42
+ - `deferred` — intentionally out of scope for this milestone
43
+
44
+ ## Verification
45
+
46
+ After each TODO: `bundle exec rake` (rspec + rubocop) must be green,
47
+ and `bundle exec ruby exe/oiml-diff out.pdf <reference>` similarity
48
+ must not regress.
@@ -0,0 +1,46 @@
1
+ ---
2
+ priority: P0
3
+ phase: 1
4
+ depends_on: []
5
+ layer: foundation
6
+ est: 1d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The Arrolio gem skeleton already exists (gemspec, Gemfile, lib/arrolio.rb,
13
+ error.rb, version.rb). This TODO covers the conventions check, autoload
14
+ tree, and project structure that everything else depends on.
15
+
16
+ ## Approach
17
+
18
+ Already in place (verify, don't redo):
19
+ - `lib/arrolio.rb` with autoloads for every planned module.
20
+ - `lib/arrolio/version.rb` (`0.1.0.alpha1`).
21
+ - `lib/arrolio/error.rb` (Error + LayoutError + RenderError +
22
+ ContentError + LayoutSpecError).
23
+ - `spec/spec_helper.rb` + `spec/arrolio/version_spec.rb`.
24
+ - `Gemfile` with `gem "pdfrb", path: "/Users/mulgogi/src/claricle/pdfrb"`.
25
+
26
+ Convention decisions to lock in here:
27
+ - `# frozen_string_literal: true` on every file.
28
+ - Immutable value objects with `freeze` in constructors where data
29
+ crosses a layer boundary.
30
+ - `autoload` only — never `require_relative`, never `require` for
31
+ internal code. Autoloads live in the immediate parent namespace's
32
+ file (create the file if it doesn't exist).
33
+ - No `send` to private methods, no `instance_variable_set/get`, no
34
+ `respond_to?` for type checks (`is_a?` only).
35
+ - No `double()` in specs — real instances or `Struct.new` stand-ins.
36
+ - No hand-rolled `to_h`/`from_h` on models.
37
+
38
+ ## Done-When
39
+
40
+ - [x] `bundle exec rspec` is green on the seed specs.
41
+ - [x] `Arrolio::VERSION` returns `"0.1.0.alpha1"`.
42
+ - [x] `Arrolio::Error` descends from `StandardError`.
43
+ - [x] `lib/arrolio.rb` lists every planned module as an autoload.
44
+ - [ ] `.rubocop.yml` exists and `bundle exec rubocop` is green.
45
+ - [ ] `bin/console` exists for interactive exploration.
46
+ - [ ] CLAUDE.md documents the architecture for future Claude sessions.
@@ -0,0 +1,47 @@
1
+ ---
2
+ priority: P0
3
+ phase: 1
4
+ depends_on: [01]
5
+ layer: foundation
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Arrolio needs a typed content tree to describe documents semantically.
13
+ Strings and hashes are too loose — downstream layers (Engine, Renderer)
14
+ must dispatch on content type via `is_a?`. The tree is medium-independent:
15
+ it has no notion of "page" or "PDF" anywhere.
16
+
17
+ ## Approach
18
+
19
+ Files to create under `lib/arrolio/content/`:
20
+
21
+ - `document.rb` — root; holds `metadata` (Hash), `sections` (Array).
22
+ - `section.rb` — `title`, `level`, `number`, `id`, `children`.
23
+ - `paragraph.rb` — `inline_runs` (Array of InlineRun).
24
+ - `inline_run.rb` — `text` + `style_id` (frozen value object).
25
+ - `table.rb` — `Row`, `Cell`, `ColumnSpec` (defer to TODO 27 if convenient).
26
+ - `list.rb` — `Item` with `marker` + `content` (defer to TODO 31).
27
+ - `image.rb` — `src`, `alt`, `natural_size` (defer to TODO 33).
28
+ - `cross_reference.rb` — `target_id`, `reference_kind` (defer to TODO 41).
29
+
30
+ Conventions:
31
+ - Every node has a `role` Symbol (e.g. `:heading`, `:paragraph`)
32
+ used by dispatch tables.
33
+ - Frozen value objects. Equality by value (`==` and `eql?` and `hash`).
34
+ - Children are an Array; leaf text is a String inside `InlineRun`.
35
+ - No mutations after construction. Builders accumulate into Arrays,
36
+ then construct the immutable node in one shot.
37
+
38
+ DSL: `Document.build { section("Intro") { paragraph("Hello") } }`
39
+ builder lives in `lib/arrolio/content/builder.rb`.
40
+
41
+ ## Done-When
42
+
43
+ - [ ] Every content class is a frozen value object with `==`, `eql?`, `hash`.
44
+ - [ ] `Arrolio::Content::Document.build { ... }` produces a tree.
45
+ - [ ] Visitors (TODO 05) can walk the tree without type-checks beyond `is_a?`.
46
+ - [ ] No content class references pages, PDFs, frames, or renderers.
47
+ - [ ] Specs cover equality, immutability, child traversal.
@@ -0,0 +1,46 @@
1
+ ---
2
+ priority: P0
3
+ phase: 1
4
+ depends_on: [02]
5
+ layer: foundation
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Engine and Renderer both need a typed layout description to drive
13
+ placement. This is the Ruby equivalent of XSL-FO's
14
+ `fo:layout-master-set` + `fo:page-sequence-master` — but as Ruby
15
+ objects, no XML.
16
+
17
+ ## Approach
18
+
19
+ Files under `lib/arrolio/layout_spec/`:
20
+
21
+ - `layout_spec.rb` — top container; holds `page_templates`, `styles`,
22
+ `flows`, `page_sequences`.
23
+ - `page_template.rb` — name, page_size, margins, region_extents,
24
+ computed `regions`.
25
+ - `region.rb` — name (`:body`, `:before`, `:after`, `:start`, `:end`),
26
+ geometry, `flow_ref`.
27
+ - `page_sequence_master.rb` — selection rules (`:first`, `:odd`,
28
+ `:even`, `:blank`, `:last`).
29
+ - `flow.rb` — name, source (path into Content tree), default style.
30
+
31
+ DSL: `LayoutSpec.build { page_template(:body) { ... }; style(:body, ...) }`.
32
+
33
+ Validation in constructor — raise `LayoutSpecError` on:
34
+ - Unknown region name.
35
+ - Page size that isn't a 2-element Array of positives.
36
+ - Margin that's negative.
37
+ - Style reference to a name not in the registry.
38
+
39
+ ## Done-When
40
+
41
+ - [ ] `LayoutSpec.build` produces an immutable spec.
42
+ - [ ] `spec.page_template(:body)` returns a `PageTemplate`.
43
+ - [ ] `spec.style(:body)` returns a resolved `Style::Definition`.
44
+ - [ ] Invalid specs raise `LayoutSpecError` with a useful message and
45
+ a `spec_field` attribute pointing at the offending field.
46
+ - [ ] Specs cover happy path + every validation rule.
@@ -0,0 +1,43 @@
1
+ ---
2
+ priority: P0
3
+ phase: 1
4
+ depends_on: [02]
5
+ layer: foundation
6
+ est: 2d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Style declarations need a typed representation that supports:
13
+ - Inheritance (parent style → child override).
14
+ - Resolution by name (`:body`, `:heading_1`) or selector.
15
+ - Medium-aware properties (font, color, alignment, spacing).
16
+ - Diff/merge for span-style overrides within paragraphs.
17
+
18
+ ## Approach
19
+
20
+ Files under `lib/arrolio/style/`:
21
+
22
+ - `definition.rb` — immutable struct of resolved properties
23
+ (font_name, font_size, fill_color, stroke_color, line_spacing,
24
+ align, valign, margin, padding, character_spacing, word_spacing,
25
+ keep_together, page_break_before, page_break_after, line_break).
26
+ - `registry.rb` — name → Definition map; resolves lookups;
27
+ supports `register(name, **props)` and `resolve(name)`.
28
+ - `diff.rb` — given a base Definition + a hash of overrides, produce
29
+ a new Definition (used for inline span styling).
30
+ - `table_style.rb`, `row_style.rb`, `cell_style.rb` — table-specific
31
+ subclasses.
32
+
33
+ Defaults:
34
+ - `font_name: "Helvetica"`, `font_size: 12`, `align: :left`,
35
+ `line_spacing: 1.2`, `line_break: :greedy`.
36
+
37
+ ## Done-When
38
+
39
+ - [ ] `Style::Registry.new` accepts a hash of definitions.
40
+ - [ ] `registry.resolve(:body)` returns a `Definition`.
41
+ - [ ] `definition.with(font_size: 14)` returns a new merged Definition.
42
+ - [ ] Inheritance: child without `font_size` inherits parent's.
43
+ - [ ] Specs cover registration, resolution, merge, inheritance.
@@ -0,0 +1,40 @@
1
+ ---
2
+ priority: P1
3
+ phase: 1
4
+ depends_on: [02, 03]
5
+ layer: foundation
6
+ est: 1d
7
+ status: pending
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Engine and Renderer both walk trees of typed nodes. Without a
13
+ visitor pattern, every walker re-implements dispatch. A canonical
14
+ visitor keeps the dispatch logic in one place and lets new walkers
15
+ (validator, optimizer, debug-dumper) be added without touching node
16
+ classes (OCP).
17
+
18
+ ## Approach
19
+
20
+ Files under `lib/arrolio/visitor/`:
21
+
22
+ - `base.rb` — `Visitor::Base` with `visit(node)` that dispatches on
23
+ `node.class` to a method named `visit_<class_name>`. Falls back to
24
+ `visit_unknown(node)` if no specific method.
25
+ - `content_visitor.rb` — mixin giving `visit_section`, `visit_paragraph`,
26
+ etc. defaults that recurse into children.
27
+ - `layout_spec_visitor.rb` — same idea for LayoutSpec nodes.
28
+
29
+ Built-in visitors:
30
+ - `Validator` — raises `ContentError` / `LayoutSpecError` on bad trees.
31
+ - `Dumper` — prints tree as YAML for debugging.
32
+ - `Counter` — counts nodes per role (used by harness TODO 53).
33
+
34
+ ## Done-When
35
+
36
+ - [ ] `Visitor::Base.dispatch(node)` routes to `visit_<class>`.
37
+ - [ ] Unknown classes hit `visit_unknown` (default: no-op).
38
+ - [ ] `ContentVisitor` walks a `Content::Document` end-to-end.
39
+ - [ ] `Validator` flags a Section with non-String title.
40
+ - [ ] Specs cover dispatch, recursion, unknown class.
@@ -0,0 +1,37 @@
1
+ ---
2
+ priority: P0
3
+ phase: 2
4
+ depends_on: [01]
5
+ layer: metrics
6
+ est: 1d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ The 14 standard Type1 fonts (Helvetica, Times, Courier + variants,
13
+ Symbol, ZapfDingbats) need real glyph widths for line breaking. The
14
+ canonical source is Adobe Font Metrics files (AFM). Arrolio bundles
15
+ its own copy so it doesn't depend on Pdfrb's data dir.
16
+
17
+ ## Approach
18
+
19
+ Files:
20
+ - `data/arrolio/afm/*.afm` — 14 AFM files (port from
21
+ `pdfrb/data/pdfrb/afm/`).
22
+ - `lib/arrolio/font/afm_parser.rb` — parses `StartFontMetrics` block,
23
+ `FontName`/`FullName`/`FamilyName`, `FontBBox`, `CapHeight`,
24
+ `XHeight`, `Ascender`, `Descender`, `StartCharMetrics`/`EndCharMetrics`,
25
+ and each `C N ; WX W ; N name ; B bbox ;` row.
26
+
27
+ Parser produces a frozen `AFM::Font` value object with `char_metrics`
28
+ hash keyed by both code and glyph name.
29
+
30
+ ## Done-When
31
+
32
+ - [ ] All 14 AFM files load without error.
33
+ - [ ] `AFMParser.from_file("data/arrolio/afm/Helvetica.afm").font_name`
34
+ == `"Helvetica"`.
35
+ - [ ] Helvetica's `A` width is 668 (per AFM); `i` is 222; `m` is 833.
36
+ - [ ] Ascender/descender/cap_height/x_height parsed for all 14.
37
+ - [ ] Specs cover the parser on at least 3 of the 14.
@@ -0,0 +1,41 @@
1
+ ---
2
+ priority: P0
3
+ phase: 2
4
+ depends_on: [06]
5
+ layer: metrics
6
+ est: 1d
7
+ status: in_progress
8
+ ---
9
+
10
+ ## Problem
11
+
12
+ Layout callers ask "how wide is this Unicode string at this size?"
13
+ The AFM gives glyph widths by AdobeStandardEncoding code or glyph
14
+ name, not Unicode codepoint. We need Unicode → AFM resolution via
15
+ the Adobe Glyph List.
16
+
17
+ ## Approach
18
+
19
+ Files:
20
+ - `data/arrolio/glyphlist.txt` — Adobe Glyph List (port from Pdfrb).
21
+ - `lib/arrolio/font/glyph_list.rb` — parses glyphlist.txt into a
22
+ glyph_name → Unicode String map; exposes a reverse map
23
+ (Unicode codepoint → glyph_name).
24
+ - `lib/arrolio/font_metrics/afm_metrics.rb` — wraps an `AFM::Font`
25
+ and exposes the universal metrics interface (see TODO 09):
26
+ - `advance_width(char)` — Unicode char → Float (or 0 if unknown).
27
+ - `width_of_string(str, font_size:)` — sum, scaled.
28
+ - `ascender(font_size:)`, `descender(font_size:)`, `cap_height(font_size:)`,
29
+ `x_height(font_size:)`, `line_height(font_size:, line_spacing:)`.
30
+
31
+ Lookup path: Unicode codepoint → AGL glyph name → AFM char_metrics[name].
32
+
33
+ ## Done-When
34
+
35
+ - [ ] `AfmMetrics.for_name("Helvetica").width_of_string("Hello", font_size: 12)`
36
+ is `27.336` (matches Adobe AFM to 0.001pt).
37
+ - [ ] `width("i") == 2.664` and `width("m") == 9.996` at 12pt.
38
+ - [ ] Non-ASCII WinAnsi chars resolve: en-dash → 556, bullet → 350.
39
+ - [ ] Ascender / descender / cap_height / x_height match AFM.
40
+ - [ ] Specs cover at least 3 of the 14 fonts (Helvetica, Times-Bold,
41
+ Courier-Oblique).