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,265 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module TextLayout
5
+ module KnuthPlass
6
+ # Optimal line breaking via dynamic programming (Knuth & Plass,
7
+ # 1981). Finds the set of break points that minimizes the total
8
+ # "badness" — the sum of squared adjustment ratios across all
9
+ # lines, plus penalties for flagged breaks.
10
+ #
11
+ # The algorithm runs in O(n²) worst case (n = item count) but
12
+ # converges in O(n) for typical text because most candidate
13
+ # breaks are pruned early.
14
+ #
15
+ # Produces TextLayout::Line[] output — compatible with the
16
+ # existing Greedy breaker so the engine can swap freely.
17
+ class Breaker
18
+ Infinity = Float::INFINITY
19
+
20
+ # Tolerance for adjustment ratio — lines with ratio above
21
+ # this are considered "underfull" and penalized.
22
+ TOLERANCE = 100.0
23
+
24
+ # Extra penalty for consecutive flagged breaks (hyphens).
25
+ FLAGGED_PENALTY = 3000.0
26
+
27
+ # Extra penalty for the last line being underfull.
28
+ DEMERITS_LAST_LINE = 50.0
29
+
30
+ attr_reader :items, :line_widths, :runs, :measurer, :align
31
+
32
+ # +items:+ Array of KnuthPlass::Item (Box, Glue, Penalty).
33
+ # +line_widths:+ Array of Float — width available per line.
34
+ # If shorter than the number of lines, the last width repeats.
35
+ # +runs:+ Array of InlineRun — for building placed runs.
36
+ # +measurer:+ GlyphMeasurer for width lookups.
37
+ # +align:+ :left, :right, :center, :justify.
38
+ def initialize(items:, line_widths:, runs:, measurer:, align: :left)
39
+ @items = items
40
+ @line_widths = line_widths
41
+ @runs = runs
42
+ @measurer = measurer
43
+ @align = align
44
+ end
45
+
46
+ def layout
47
+ nodes = active_nodes
48
+ return [] if nodes.empty?
49
+
50
+ # Only consider nodes that reached the end of the items
51
+ # (the FINISHED penalty at the last position).
52
+ final_nodes = nodes.select { |n| n.position == @items.length - 1 }
53
+ return [] if final_nodes.empty?
54
+
55
+ # Backtrack from the best final node.
56
+ breaks = reconstruct_breaks(best_final_node(final_nodes))
57
+
58
+ # Build Line objects from the break points.
59
+ lines = []
60
+ prev_item = 0
61
+ breaks.each_with_index do |br, line_idx|
62
+ width = line_width_for(line_idx)
63
+ placed = build_placed_runs(prev_item, br, width)
64
+ used = compute_line_width(prev_item, br)
65
+ lines << Line.new(placed, width: used, max_width: width, align: @align)
66
+ prev_item = br
67
+ end
68
+ lines
69
+ end
70
+
71
+ # Internal: a node in the dynamic programming graph.
72
+ Node = Struct.new(:position, :line, :fitness, :total_demerits,
73
+ :previous, keyword_init: true)
74
+
75
+ private
76
+
77
+ # Active-node dynamic programming. Each active node represents
78
+ # a feasible break position. We iterate through items and
79
+ # try breaking at each feasible position.
80
+ def active_nodes
81
+ @active = [Node.new(position: -1, line: 0, fitness: 1,
82
+ total_demerits: 0, previous: nil)]
83
+ @items.each_with_index do |_item, i|
84
+ next unless feasible_break?(i)
85
+
86
+ @active = @active.reject { |a| prune?(a, i) }
87
+ candidates = @active.filter_map { |a| try_break(a, i) }
88
+ best = best_candidate(candidates)
89
+ @active << best if best
90
+ end
91
+ @active
92
+ end
93
+
94
+ def feasible_break?(i)
95
+ item = @items[i]
96
+ (item.penalty? && !item.no_break?) || (item.glue? && i.positive? && @items[i - 1].box?)
97
+ end
98
+
99
+ def prune?(node, _current)
100
+ node.line >= @line_widths.length + 10
101
+ end
102
+
103
+ def try_break(node, break_pos)
104
+ item = @items[break_pos]
105
+ width = line_width_for(node.line)
106
+ line_w = compute_line_width(node.position + 1, break_pos)
107
+
108
+ is_forced = item.penalty? && item.forced_break?
109
+ ratio = adjustment_ratio(line_w, width, break_pos)
110
+
111
+ # Forced breaks are always accepted but still penalized
112
+ # for bad ratios so the algorithm prefers shorter lines.
113
+ unless is_forced
114
+ return nil if ratio.abs > TOLERANCE
115
+ end
116
+
117
+ ratio = 0.0 if ratio == Infinity || ratio == -Infinity
118
+ demerits = demerits_for(ratio, break_pos)
119
+ total = node.total_demerits + demerits
120
+ fitness = fitness_class(ratio)
121
+
122
+ Node.new(
123
+ position: break_pos,
124
+ line: node.line + 1,
125
+ fitness: fitness,
126
+ total_demerits: total,
127
+ previous: node
128
+ )
129
+ end
130
+
131
+ def best_candidate(candidates)
132
+ candidates.min_by(&:total_demerits)
133
+ end
134
+
135
+ def best_final_node(nodes)
136
+ nodes.min_by(&:total_demerits)
137
+ end
138
+
139
+ # Adjustment ratio: how stretched (positive) or shrunk
140
+ # (negative) a line is. 0 = perfect fit.
141
+ def adjustment_ratio(natural_width, target_width, break_pos)
142
+ glue_stretch = total_stretch(break_pos)
143
+ glue_shrink = total_shrink(break_pos)
144
+
145
+ if natural_width < target_width
146
+ glue_stretch.positive? ? (target_width - natural_width) / glue_stretch : Infinity
147
+ elsif natural_width > target_width
148
+ glue_shrink.positive? ? (target_width - natural_width) / glue_shrink : -Infinity
149
+ else
150
+ 0.0
151
+ end
152
+ end
153
+
154
+ def demerits_for(ratio, break_pos)
155
+ penalty = @items[break_pos].penalty? ? @items[break_pos].penalty : 0
156
+ badness = if ratio.negative?
157
+ (100 * ((-ratio) ** 3)).to_i
158
+ else
159
+ 0
160
+ end
161
+ # Standard Knuth-Plass demerits. Forced breaks (penalty =
162
+ # -Infinity) use d = (1 + badness)^2 — the penalty term
163
+ # drops out because there's no choice about breaking.
164
+ d = if penalty.infinite?
165
+ (1 + badness) ** 2
166
+ elsif penalty >= 0
167
+ (1 + badness + penalty) ** 2
168
+ else
169
+ ((1 + badness) ** 2) - (penalty ** 2)
170
+ end
171
+ flagged = @items[break_pos].penalty? && @items[break_pos].flagged ? FLAGGED_PENALTY : 0
172
+ d + flagged
173
+ end
174
+
175
+ def fitness_class(ratio)
176
+ case ratio
177
+ when -Infinity..-0.5 then 0 # tight
178
+ when -0.5..0.5 then 1 # normal
179
+ when 0.5..1.0 then 2 # loose
180
+ else 3 # very loose
181
+ end
182
+ end
183
+
184
+ # Sum of widths from item +start+ to item +stop+ (exclusive).
185
+ # Discards trailing glue at the break point.
186
+ def compute_line_width(start, stop)
187
+ return 0.0 if start >= stop
188
+
189
+ sum = 0.0
190
+ (start...stop).each do |i|
191
+ item = @items[i]
192
+ next if item.nil?
193
+
194
+ # Trailing glue after last box is discarded at break.
195
+ last_box = last_box_index_in(start, stop)
196
+ sum += item.width if i <= last_box
197
+ end
198
+ sum
199
+ end
200
+
201
+ def last_box_index_in(start, stop)
202
+ idx = start - 1
203
+ (start...stop).each do |i|
204
+ idx = i if @items[i]&.box?
205
+ end
206
+ idx
207
+ end
208
+
209
+ def total_stretch(break_pos)
210
+ sum = 0.0
211
+ @items[0..break_pos].each { |item| sum += item.stretch if item.glue? }
212
+ sum
213
+ end
214
+
215
+ def total_shrink(break_pos)
216
+ sum = 0.0
217
+ @items[0..break_pos].each { |item| sum += item.shrink if item.glue? }
218
+ sum
219
+ end
220
+
221
+ def line_width_for(line_idx)
222
+ @line_widths[[line_idx, @line_widths.length - 1].min]
223
+ end
224
+
225
+ def reconstruct_breaks(final_node)
226
+ breaks = []
227
+ node = final_node
228
+ while node&.previous
229
+ breaks << node.position
230
+ node = node.previous
231
+ end
232
+ breaks.reverse
233
+ end
234
+
235
+ def build_placed_runs(start_item, stop_item, _width)
236
+ placed = []
237
+ x_offset = 0.0
238
+ (start_item...stop_item).each do |i|
239
+ item = @items[i]
240
+ next unless item&.box?
241
+
242
+ run = @runs[item.run_index]
243
+ next unless run
244
+
245
+ slice = run.text[item.char_offset, item.char_length]
246
+ next if slice.nil? || slice.empty?
247
+
248
+ sub = InlineRun.new(slice, style: run.style,
249
+ baseline_shift: run.baseline_shift,
250
+ font_size_scale: run.font_size_scale,
251
+ href: run.href)
252
+ placed << Line::PlacedRun.new(run: sub, x_offset: x_offset)
253
+ x_offset += item.width
254
+ end
255
+ placed
256
+ end
257
+
258
+ def finalize_node(position, line, fitness, total_demerits, previous)
259
+ Node.new(position: position, line: line, fitness: fitness,
260
+ total_demerits: total_demerits, previous: previous)
261
+ end
262
+ end
263
+ end
264
+ end
265
+ end
@@ -0,0 +1,84 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module TextLayout
5
+ module KnuthPlass
6
+ # Base class for items in the Knuth-Plass line-breaking model.
7
+ # Every item has a width. Box and Glue are "content"; Penalty
8
+ # is a potential break point.
9
+ class Item
10
+ attr_reader :width
11
+
12
+ def initialize(width:)
13
+ @width = width.to_f
14
+ end
15
+
16
+ def box? = false
17
+ def glue? = false
18
+ def penalty? = false
19
+ end
20
+
21
+ # A box is a unit of content that cannot be broken across
22
+ # lines (a word, a glyph cluster). Has a fixed width.
23
+ class Box < Item
24
+ attr_reader :run_index, :char_offset, :char_length
25
+
26
+ def initialize(width:, run_index:, char_offset:, char_length:)
27
+ super(width: width)
28
+ @run_index = run_index
29
+ @char_offset = char_offset
30
+ @char_length = char_length
31
+ end
32
+
33
+ def box? = true
34
+ end
35
+
36
+ # Glue represents inter-word space. Has a natural width plus
37
+ # stretchability (how much it can grow) and shrinkability
38
+ # (how much it can shrink). At a break point, trailing glue
39
+ # is discarded (doesn't contribute to line width).
40
+ class Glue < Item
41
+ attr_reader :stretch, :shrink, :run_index, :char_offset
42
+
43
+ def initialize(width:, stretch:, shrink:, run_index: nil, char_offset: nil)
44
+ super(width: width)
45
+ @stretch = stretch.to_f
46
+ @shrink = shrink.to_f
47
+ @run_index = run_index
48
+ @char_offset = char_offset
49
+ end
50
+
51
+ def glue? = true
52
+ end
53
+
54
+ # A penalty represents a potential break point. Width is the
55
+ # width of the hyphen (or 0 if no hyphen). Flagged penalties
56
+ # (e.g., hyphenation) incur extra cost. A penalty of +Infinity+
57
+ # means "never break here"; -Infinity means "always break".
58
+ class Penalty < Item
59
+ attr_reader :penalty, :flagged, :run_index, :char_offset
60
+
61
+ def initialize(penalty:, width: 0, flagged: false, run_index: nil, char_offset: nil)
62
+ super(width: width)
63
+ @penalty = penalty.to_f
64
+ @flagged = flagged
65
+ @run_index = run_index
66
+ @char_offset = char_offset
67
+ end
68
+
69
+ def penalty? = true
70
+
71
+ def forced_break?
72
+ @penalty == -Float::INFINITY
73
+ end
74
+
75
+ def no_break?
76
+ @penalty == Float::INFINITY
77
+ end
78
+ end
79
+
80
+ # A forced break at the end of a paragraph.
81
+ FINISHED = Penalty.new(penalty: -Float::INFINITY).freeze
82
+ end
83
+ end
84
+ end
@@ -0,0 +1,103 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module TextLayout
5
+ module KnuthPlass
6
+ # Converts a list of InlineRun values into KnuthPlass::Item
7
+ # sequences (Box, Glue, Penalty). Each word becomes a Box;
8
+ # spaces become Glue; potential break points become Penalty
9
+ # items.
10
+ class ItemBuilder
11
+ HYPHEN_PENALTY = 50.0
12
+
13
+ attr_reader :runs, :measurer
14
+
15
+ def initialize(runs, measurer:)
16
+ @runs = runs
17
+ @measurer = measurer
18
+ end
19
+
20
+ def build
21
+ items = []
22
+ @runs.each_with_index do |run, run_idx|
23
+ build_items_for_run(run, run_idx, items)
24
+ end
25
+ items << FINISHED
26
+ items
27
+ end
28
+
29
+ private
30
+
31
+ def build_items_for_run(run, run_idx, items)
32
+ text = run.text
33
+ return if text.nil? || text.empty?
34
+
35
+ # Handle forced breaks first.
36
+ if text.include?("\n")
37
+ build_newline_items(run, run_idx, text, items)
38
+ return
39
+ end
40
+
41
+ words = text.split(/(\s+)/)
42
+ offset = 0
43
+ words.each do |word|
44
+ if word.match?(/\s+/)
45
+ width = measure(word, run)
46
+ stretch = measure(' ', run) * 0.5
47
+ shrink = measure(' ', run) * 0.33
48
+ items << Glue.new(width: width, stretch: stretch, shrink: shrink,
49
+ run_index: run_idx, char_offset: offset)
50
+ elsif word.include?('-')
51
+ build_hyphenated_word(word, run, run_idx, offset, items)
52
+ elsif !word.empty?
53
+ width = measure(word, run)
54
+ items << Box.new(width: width, run_index: run_idx,
55
+ char_offset: offset, char_length: word.length)
56
+ end
57
+ offset += word.length
58
+ end
59
+ end
60
+
61
+ def build_newline_items(run, run_idx, text, items)
62
+ segments = text.split("\n")
63
+ segments.each_with_index do |seg, i|
64
+ unless seg.empty?
65
+ width = measure(seg, run)
66
+ items << Box.new(width: width, run_index: run_idx,
67
+ char_offset: text.index(seg) || 0,
68
+ char_length: seg.length)
69
+ end
70
+ items << Penalty.new(penalty: -Float::INFINITY, run_index: run_idx) if i < segments.length - 1
71
+ end
72
+ end
73
+
74
+ def build_hyphenated_word(word, run, run_idx, offset, items)
75
+ parts = word.split(/(-)/)
76
+ part_offset = offset
77
+ parts.each do |part|
78
+ next if part.empty?
79
+
80
+ width = measure(part, run)
81
+ items << Box.new(width: width, run_index: run_idx,
82
+ char_offset: part_offset, char_length: part.length)
83
+ if part == '-'
84
+ items << Penalty.new(width: 0, penalty: HYPHEN_PENALTY,
85
+ flagged: true, run_index: run_idx,
86
+ char_offset: part_offset)
87
+ end
88
+ part_offset += part.length
89
+ end
90
+ end
91
+
92
+ def measure(text, run)
93
+ @measurer.width_of_run(
94
+ text,
95
+ font_size: run.style.font_size * run.font_size_scale,
96
+ character_spacing: run.style.character_spacing,
97
+ word_spacing: 0
98
+ )
99
+ end
100
+ end
101
+ end
102
+ end
103
+ end
@@ -0,0 +1,12 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Arrolio::TextLayout::KnuthPlass — optimal line breaking.
4
+ module Arrolio
5
+ module TextLayout
6
+ module KnuthPlass
7
+ autoload :Item, 'arrolio/text_layout/knuth_plass/item'
8
+ autoload :Breaker, 'arrolio/text_layout/knuth_plass/breaker'
9
+ autoload :ItemBuilder, 'arrolio/text_layout/knuth_plass/item_builder'
10
+ end
11
+ end
12
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module TextLayout
5
+ # One laid-out line: a list of PlacedRun tuples plus the
6
+ # line's content width, target width, and alignment.
7
+ class Line
8
+ PlacedRun = Struct.new(:run, :x_offset, keyword_init: true) do
9
+ def freeze
10
+ run.freeze
11
+ super
12
+ end
13
+ end
14
+
15
+ attr_reader :placed_runs, :width, :max_width, :align
16
+
17
+ def initialize(placed_runs, width:, max_width:, align: :left)
18
+ @placed_runs = placed_runs.freeze
19
+ @width = width.to_f
20
+ @max_width = max_width.to_f
21
+ @align = align
22
+ freeze
23
+ end
24
+
25
+ def empty?
26
+ @placed_runs.empty?
27
+ end
28
+
29
+ def x_offset
30
+ case @align
31
+ when :center then ((@max_width - @width) / 2.0)
32
+ when :right then (@max_width - @width)
33
+ else 0.0
34
+ end
35
+ end
36
+
37
+ def justified?
38
+ @align == :justify
39
+ end
40
+
41
+ def justify_stretch
42
+ return 0.0 unless justified?
43
+ return 0.0 if @width >= @max_width
44
+
45
+ gaps = word_gap_count
46
+ return 0.0 if gaps.zero?
47
+
48
+ (@max_width - @width) / gaps.to_f
49
+ end
50
+
51
+ def ==(other)
52
+ other.is_a?(self.class) &&
53
+ placed_runs == other.placed_runs &&
54
+ width == other.width &&
55
+ max_width == other.max_width &&
56
+ align == other.align
57
+ end
58
+
59
+ alias eql? ==
60
+
61
+ def hash
62
+ [self.class, placed_runs, width, max_width, align].hash
63
+ end
64
+
65
+ private
66
+
67
+ def word_gap_count
68
+ @placed_runs.sum { |pr| pr.run.text.count(' ') }
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Arrolio::TextLayout — paragraph line-breaking. Pure + stateless.
4
+ module Arrolio
5
+ module TextLayout
6
+ autoload :Greedy, 'arrolio/text_layout/greedy'
7
+ autoload :Line, 'arrolio/text_layout/line'
8
+ autoload :BreakOpportunity, 'arrolio/text_layout/break_opportunity'
9
+ autoload :KnuthPlass, 'arrolio/text_layout/knuth_plass'
10
+ end
11
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module TocBuilder
5
+ module_function
6
+
7
+ def build_flowables(context, layout_spec, rules: {})
8
+ styles = rules.fetch('styles', {})
9
+ entries = context.heading_entries
10
+ entries.map do |entry|
11
+ number = entry[:number]
12
+ title = entry[:title].to_s
13
+ label = number ? "#{number} #{title}" : title
14
+ style_id = style_id_for(entry[:level], styles)
15
+ style = layout_spec.resolve_style(style_id)
16
+ Flowables::TocLineFlowable.new(
17
+ label,
18
+ entry[:page_number],
19
+ level: entry[:level] || 1,
20
+ style: style
21
+ )
22
+ end
23
+ end
24
+
25
+ def style_id_for(level, styles)
26
+ key = level.to_i >= 2 ? 'sub' : 'entry'
27
+ default_style = key == 'sub' ? 'toc_entry_sub' : 'toc_entry'
28
+ (styles[key] || default_style).to_sym
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ VERSION = '0.1.0'
5
+ end
@@ -0,0 +1,75 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ # Writing mode enumeration for vertical/RTL layout. Controls
5
+ # the inline and block progression directions used by the
6
+ # layout engine. The values map to XSL-FO writing-mode
7
+ # property values.
8
+ #
9
+ # +LR_TB+ — left-to-right inline, top-to-bottom block (Latin, CJK).
10
+ # +RL_TB+ — right-to-left inline, top-to-bottom block (Arabic, Hebrew).
11
+ # +TB_RL+ — top-to-bottom inline, right-to-left block (vertical CJK).
12
+ class WritingMode
13
+ LR_TB = :lr_tb
14
+ RL_TB = :rl_tb
15
+ TB_RL = :tb_rl
16
+ DEFAULT = LR_TB
17
+
18
+ ALL = [LR_TB, RL_TB, TB_RL].freeze
19
+
20
+ attr_reader :mode
21
+
22
+ def initialize(mode = DEFAULT)
23
+ @mode = mode.to_sym
24
+ freeze
25
+ end
26
+
27
+ def vertical?
28
+ @mode == TB_RL
29
+ end
30
+
31
+ def rtl?
32
+ @mode == RL_TB
33
+ end
34
+
35
+ def ltr?
36
+ @mode == LR_TB
37
+ end
38
+
39
+ # Returns the inline progression direction as a 2D vector.
40
+ # +[1, 0]+ = rightward, +[-1, 0]+ = leftward, +[0, 1]+ = downward.
41
+ def inline_direction
42
+ case @mode
43
+ when LR_TB then [1, 0]
44
+ when RL_TB then [-1, 0]
45
+ when TB_RL then [0, 1]
46
+ end
47
+ end
48
+
49
+ # Returns the block progression direction as a 2D vector.
50
+ def block_direction
51
+ case @mode
52
+ when LR_TB, RL_TB then [0, -1]
53
+ when TB_RL then [-1, 0]
54
+ end
55
+ end
56
+
57
+ def ==(other)
58
+ other.is_a?(self.class) && mode == other.mode
59
+ end
60
+ alias eql? ==
61
+
62
+ def hash
63
+ [self.class, mode].hash
64
+ end
65
+
66
+ def self.parse(value)
67
+ case value.to_s.downcase.tr('-', '_')
68
+ when 'lr_tb', 'lrtb' then new(LR_TB)
69
+ when 'rl_tb', 'rltb' then new(RL_TB)
70
+ when 'tb_rl', 'tbrl' then new(TB_RL)
71
+ else new(DEFAULT)
72
+ end
73
+ end
74
+ end
75
+ end