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,493 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'stringio'
4
+ require 'digest/md5'
5
+ require 'tmpdir'
6
+
7
+ module Arrolio
8
+ module Renderer
9
+ # Walks Output::Page[] and emits PDF bytes via Pdfrb. Supports
10
+ # font embedding: pass +font_paths:+ as a Hash mapping the
11
+ # Style#font_name string to a TTF path, and the renderer will
12
+ # subset + embed each font for the codepoints actually used
13
+ # in the document.
14
+ class Pdf
15
+ MM_TO_PT = 2.83464567
16
+
17
+ attr_reader :document, :fonts
18
+
19
+ def initialize
20
+ @document = Pdfrb::Document.new
21
+ @fonts = FontRegistry.new(@document)
22
+ @images = {}
23
+ @font_paths = {}
24
+ @font_embedders = {}
25
+ @font_encoders = {}
26
+ @font_refs = {}
27
+ @link_annotator = LinkAnnotator.new(@document)
28
+ end
29
+
30
+ def render(pages, io:, logo_path: nil, metadata: {},
31
+ font_paths: {}, context: nil, layout_spec: nil)
32
+ @font_paths = font_paths.transform_keys(&:to_s)
33
+ @layout_spec = layout_spec
34
+ apply_metadata(metadata)
35
+ attach_xmp_metadata(metadata)
36
+ @logo_ref = register_logo(logo_path)
37
+ prepare_embedded_fonts(pages)
38
+ pages.each { |page| render_page(page) }
39
+ build_outline(context, pages) if context
40
+ @document.write(io.is_a?(String) ? io : nil,
41
+ io: io.is_a?(String) ? nil : io)
42
+ end
43
+
44
+ def apply_metadata(metadata)
45
+ info = @document.catalog.value[:Info]
46
+ info ||= @document.add({})
47
+ @document.catalog.value[:Info] = info
48
+ info.value[:Title] = metadata[:title] if metadata[:title]
49
+ info.value[:Author] = metadata[:author] if metadata[:author]
50
+ info.value[:Creator] = 'Arrolio (Ruby)'
51
+ info.value[:Producer] = 'Arrolio + Pdfrb'
52
+ end
53
+
54
+ # Emit an XMP metadata packet (RDF/XML) as a stream on the
55
+ # catalog's /Metadata entry. PDF readers use this for Dublin
56
+ # Core properties alongside the legacy /Info dict.
57
+ def attach_xmp_metadata(metadata)
58
+ xmp = XmpBuilder.new(metadata).build
59
+ stream = @document.add(
60
+ { Type: :Metadata, Subtype: :XML, Length: xmp.bytesize },
61
+ type: Pdfrb::Model::Cos::Stream
62
+ )
63
+ stream.stream = xmp
64
+ @document.catalog.value[:Metadata] =
65
+ Pdfrb::Model::Reference.new(stream.oid, stream.gen)
66
+ rescue StandardError => e
67
+ Arrolio::Logger.warn "XMP attach failed: #{e.class}: #{e.message[0, 80]}"
68
+ end
69
+
70
+ def register_logo(path)
71
+ return nil unless path
72
+
73
+ @document.images.add(path)
74
+ rescue StandardError => e
75
+ Arrolio::Logger.warn "logo load failed: #{e.class}: #{e.message[0, 80]}"
76
+ nil
77
+ end
78
+
79
+ def register_image(path)
80
+ return @images[path] if @images.key?(path)
81
+
82
+ resolved = resolve_image_for_pdfrb(path)
83
+ return nil unless resolved
84
+
85
+ @images[path] = @document.images.add(resolved)
86
+ rescue StandardError => e
87
+ Arrolio::Logger.warn "register_image failed for #{path}: #{e.class}: #{e.message[0, 80]}"
88
+ nil
89
+ end
90
+
91
+ # Pdfrb supports JPEG, PNG, and PDF — not SVG. If +path+ is an
92
+ # SVG file, rasterize it to PNG via rsvg-convert and cache the
93
+ # result. Returns the path to a pdfrb-compatible image file.
94
+ def resolve_image_for_pdfrb(path)
95
+ return path unless path.to_s.end_with?('.svg', '.SVG')
96
+ return path unless File.exist?(path)
97
+
98
+ png_path = svg_to_png_cache_path(path)
99
+ return png_path if File.exist?(png_path)
100
+
101
+ rasterize_svg(path, png_path)
102
+ File.exist?(png_path) ? png_path : path
103
+ end
104
+
105
+ def svg_to_png_cache_path(svg_path)
106
+ digest = Digest::MD5.file(svg_path).hexdigest
107
+ File.join(Dir.tmpdir, 'arrolio-svg-' + digest + '.png')
108
+ end
109
+
110
+ def rasterize_svg(svg_path, png_path)
111
+ require 'open3'
112
+ result = Open3.capture3('rsvg-convert', '-d', '96', '-p', '96',
113
+ '-o', png_path, svg_path)
114
+ return if result[2].success?
115
+
116
+ Arrolio::Logger.warn "rsvg-convert failed: #{result[1]}"
117
+ rescue StandardError => e
118
+ Arrolio::Logger.warn "rasterize_svg failed: #{e.class}: #{e.message[0, 80]}"
119
+ end
120
+
121
+ def header_footer_style
122
+ return @header_footer_style if @header_footer_style
123
+
124
+ config = @layout_spec&.header_footer_config || {}
125
+ @header_footer_style = {
126
+ margin_top: (config['margin_top'] || 26.5) * MM_TO_PT,
127
+ margin_bottom: (config['margin_bottom'] || 26.5) * MM_TO_PT,
128
+ margin_lr: (config['margin_lr'] || 25.5) * MM_TO_PT,
129
+ header_offset: (config['header_offset'] || 4) * MM_TO_PT,
130
+ footer_offset: (config['footer_offset'] || 4) * MM_TO_PT,
131
+ font_name: config['font_name'] || 'Helvetica',
132
+ font_size: config['font_size'] || 9.0,
133
+ rule_width: config['rule_width'] || 0.5
134
+ }.freeze
135
+ end
136
+
137
+ def cover_logo_style
138
+ return @cover_logo_style if @cover_logo_style
139
+
140
+ config = @layout_spec&.cover_logo_config || {}
141
+ width_mm = config['width_mm'] || 35.0
142
+ ratio = config['aspect_ratio'] || (1459.0 / 1667.0)
143
+ margin_mm = config['margin_mm'] || 25.5
144
+ @cover_logo_style = {
145
+ width: width_mm * MM_TO_PT,
146
+ height: width_mm * ratio * MM_TO_PT,
147
+ margin: margin_mm * MM_TO_PT
148
+ }.freeze
149
+ end
150
+
151
+ private
152
+
153
+ # Pre-scan all pages, collect codepoints per font family,
154
+ # then call Font::Embedder for each font_path.
155
+ def prepare_embedded_fonts(pages)
156
+ return if @font_paths.empty?
157
+
158
+ codepoints = Hash.new { |h, k| h[k] = [] }
159
+ pages.each { |page| collect_codepoints(page, codepoints) }
160
+ Arrolio::Logger.debug "collected codepoints for fonts: #{codepoints.keys.inspect}"
161
+ @font_paths.each do |font_name, path|
162
+ exists = File.exist?(path)
163
+ has_cp = codepoints.key?(font_name)
164
+ cp_count = codepoints[font_name]&.length || 0
165
+ Arrolio::Logger.debug "font #{font_name}: exists=#{exists} cp=#{cp_count}"
166
+ next unless exists
167
+ next unless has_cp
168
+
169
+ cps = codepoints[font_name].uniq
170
+ embedder = Font::Embedder.new(@document, path,
171
+ base_font_name: font_name)
172
+ ref = embedder.embed(cps)
173
+ attach_to_resources(font_name, ref)
174
+ @font_embedders[font_name] = embedder
175
+ @font_encoders[font_name] = Font::TextEncoder.new(embedder)
176
+ @font_refs[font_name] = ref
177
+ rescue StandardError => e
178
+ Arrolio::Logger.warn "embed failed for #{font_name}: #{e.class}: #{e.message[0,100]}"
179
+ Arrolio::Logger.debug e.backtrace.first(5).join("\n")
180
+ end
181
+ end
182
+
183
+ def attach_to_resources(font_name, ref)
184
+ catalog = @document.catalog
185
+ res = catalog.value[:Resources]
186
+ Arrolio::Logger.debug "attach_to_resources: Resources=#{res.class}"
187
+ unless res.is_a?(Hash) && res[:Font].is_a?(Hash)
188
+ catalog.value[:Resources] = { Font: {} }
189
+ end
190
+ font_hash = catalog.value[:Resources][:Font]
191
+ key = (format('EF%d', (font_hash.length + 1))).to_sym
192
+ font_hash[key] = ref
193
+ @embedded_resource_keys ||= {}
194
+ @embedded_resource_keys[font_name] = key
195
+ end
196
+
197
+ def collect_codepoints(page, by_font)
198
+ (page.static_regions.values + page.regions.values).each do |region|
199
+ region.placed_boxes.each do |box|
200
+ next unless box.kind == :text
201
+ next unless box.data.is_a?(Hash) && box.data[:lines]
202
+
203
+ box.data[:lines].each do |line|
204
+ next unless line.is_a?(TextLayout::Line)
205
+ line.placed_runs.each do |pr|
206
+ next unless pr.run.is_a?(InlineRun)
207
+ # Collect under the RUN'S font name, not the
208
+ # paragraph's. Italic/bold runs have different
209
+ # font_names and need their own subsets.
210
+ run_font = pr.run.style.font_name
211
+ next unless @font_paths.key?(run_font)
212
+ pr.run.text.each_codepoint { |cp| by_font[run_font] << cp }
213
+ end
214
+ end
215
+ end
216
+ end
217
+ end
218
+
219
+ def build_outline(context, _pages)
220
+ entries = context.heading_entries
221
+ return unless entries&.any?
222
+
223
+ pdfrb_pages = @document.pages.to_a
224
+ ob = OutlineBuilder.new(document: @document,
225
+ entries: entries,
226
+ pdfrb_pages: pdfrb_pages)
227
+ result = ob.build
228
+ Arrolio::Logger.debug "outline build returned: #{result.class}"
229
+ rescue StandardError => e
230
+ Arrolio::Logger.warn "outline build failed: #{e.class}: #{e.message[0,150]}"
231
+ Arrolio::Logger.debug e.backtrace.first(5).join("\n")
232
+ end
233
+
234
+ def render_page(output_page)
235
+ pdfrb_page = @document.pages.add(media_box: [0, 0,
236
+ output_page.width,
237
+ output_page.height])
238
+ catalog = @document.catalog
239
+ catalog.value[:Resources] ||= { Font: {} }
240
+ pdfrb_page.value[:Resources] = catalog.value[:Resources]
241
+ canvas = pdfrb_page.canvas
242
+ render_cover_logo(canvas, output_page) if output_page.template_role == :cover && @logo_ref
243
+ render_header_footer(canvas, output_page) unless output_page.template_role == :cover
244
+ render_page_footnotes(canvas, output_page) unless output_page.template_role == :cover
245
+ (output_page.static_regions.values + output_page.regions.values).each do |region|
246
+ region.placed_boxes.each { |box| render_box(canvas, box) }
247
+ end
248
+ @link_annotator.flush(pdfrb_page)
249
+ end
250
+
251
+ def render_page_footnotes(canvas, page)
252
+ return unless page.footnotes && !page.footnotes.empty?
253
+
254
+ hf = header_footer_style
255
+ y = hf[:margin_bottom] + 20
256
+ page.footnotes.each do |footnote|
257
+ marker = footnote.marker.to_s
258
+ body_text = footnote.body.map do |node|
259
+ node.is_a?(Arrolio::Content::Paragraph) ? node.text : node.to_s
260
+ end.join(' ')
261
+ line = "#{marker} #{body_text}".strip
262
+ next if line.empty?
263
+
264
+ payload = encoded_or_text(hf[:font_name], line)
265
+ ref = embedded_or_standard(hf[:font_name])
266
+ canvas.text(payload, at: [hf[:margin_lr], y], font: ref, size: 7.0)
267
+ y -= 9.0
268
+ end
269
+ rescue StandardError => e
270
+ Arrolio::Logger.warn "footnote render failed: #{e.class}: #{e.message[0, 80]}"
271
+ end
272
+
273
+ def render_cover_logo(canvas, page)
274
+ logo_style = cover_logo_style
275
+ w = logo_style[:width]
276
+ h = logo_style[:height]
277
+ margin = logo_style[:margin]
278
+ x = page.width - margin - w
279
+ y = page.height - margin - h
280
+ invoke = invoke_xobject_op
281
+ return unless invoke
282
+
283
+ canvas.save_graphics_state do
284
+ canvas.concat(w, 0, 0, h, x, y)
285
+ canvas.emit_op(invoke, @logo_ref)
286
+ end
287
+ rescue StandardError => e
288
+ Arrolio::Logger.warn "logo render failed: #{e.class}: #{e.message[0, 80]}"
289
+ end
290
+
291
+ def invoke_xobject_op
292
+ return Pdfrb::Content::Operator::InvokeXObject if defined?(Pdfrb::Content::Operator::InvokeXObject)
293
+
294
+ @invoke_xobject_class ||= Class.new(Pdfrb::Content::Operator::Base) do
295
+ def self.name = 'Do'
296
+ end
297
+ end
298
+
299
+ def render_header_footer(canvas, page)
300
+ hf_style = header_footer_style
301
+ margin_top = hf_style[:margin_top]
302
+ margin_bottom = hf_style[:margin_bottom]
303
+ margin_lr = hf_style[:margin_lr]
304
+ header_offset = hf_style[:header_offset]
305
+ footer_offset = hf_style[:footer_offset]
306
+ page_w = page.width
307
+ page_h = page.height
308
+ before_y = page_h - margin_top + header_offset
309
+ after_y = margin_bottom - footer_offset
310
+
311
+ if page.header_text
312
+ emit_header_or_footer(canvas, page.header_text, before_y,
313
+ page.header_align, page_w, margin_lr, true, hf_style)
314
+ end
315
+ return unless page.footer_text
316
+
317
+ emit_header_or_footer(canvas, page.footer_text, after_y,
318
+ page.footer_align, page_w, margin_lr, false, hf_style)
319
+ end
320
+
321
+ def emit_header_or_footer(canvas, text, y, align, page_w, margin_lr, top, hf_style)
322
+ font_name = hf_style[:font_name]
323
+ size = hf_style[:font_size]
324
+ measurer = GlyphMeasurer.new(font_name: font_name)
325
+ tw = measurer.width_of_string(text, font_size: size)
326
+ x = case align
327
+ when :center then (page_w - tw) / 2.0
328
+ when :right then page_w - margin_lr - tw
329
+ else margin_lr
330
+ end
331
+ ref = embedded_or_standard(font_name)
332
+ payload = encoded_or_text(font_name, text)
333
+ canvas.text(payload, at: [x, y], font: ref, size: size)
334
+ canvas.line_width = hf_style[:rule_width]
335
+ canvas.stroke_color(0.0)
336
+ rule_y = top ? y - 2 : y + size + 2
337
+ canvas.line(margin_lr, rule_y, page_w - margin_lr, rule_y)
338
+ canvas.stroke
339
+ end
340
+
341
+ def render_box(canvas, box)
342
+ case box.kind
343
+ when :text then render_text(canvas, box)
344
+ when :rect then render_rect(canvas, box)
345
+ when :line then render_line(canvas, box)
346
+ when :image then render_image(canvas, box)
347
+ end
348
+ end
349
+
350
+ def render_text(canvas, box)
351
+ lines = box.data[:lines]
352
+ line_height = box.data[:line_height]
353
+ style = box.data[:style]
354
+ measurer = GlyphMeasurer.new(font_name: style.font_name)
355
+ ascender = measurer.ascender(font_size: style.font_size)
356
+ x_origin = box.x
357
+ y_top = box.y + box.height
358
+
359
+ lines.each_with_index do |line, idx|
360
+ baseline_y = y_top - (idx * line_height) - ascender
361
+ line_x = x_origin + line.x_offset
362
+ render_line_runs(canvas, line, line_x, baseline_y, style)
363
+ end
364
+ end
365
+
366
+ def render_line_runs(canvas, line, line_x, baseline_y, _paragraph_style)
367
+ justify = line.justified? ? line.justify_stretch : 0.0
368
+ extra_offset = 0.0
369
+ line.placed_runs.each do |pr|
370
+ run = pr.run
371
+ x = line_x + pr.x_offset + extra_offset
372
+ font_name = run.style.font_name
373
+ ref = embedded_or_standard(font_name)
374
+ payload = encoded_or_text(font_name, run.text)
375
+ y, size = baseline_position_for(run, baseline_y)
376
+ opts = { at: [x, y], font: ref, size: size }
377
+ if run.style.character_spacing && !run.style.character_spacing.zero?
378
+ opts[:char_spacing] = run.style.character_spacing
379
+ end
380
+ if run.style.word_spacing && !run.style.word_spacing.zero?
381
+ opts[:word_spacing] = run.style.word_spacing
382
+ end
383
+ record_link_if_needed(run, x, y, opts[:size])
384
+ canvas.text(payload, **opts)
385
+ extra_offset += (run.text.count(' ') * justify) if justify.positive?
386
+ end
387
+ end
388
+
389
+ # Returns [y, font_size] for a run, applying baseline shift and
390
+ # font size scaling for subscript/superscript runs.
391
+ # Subscript: shift down 0.2em, scale 0.7x.
392
+ # Superscript: shift up 0.4em, scale 0.7x.
393
+ def baseline_position_for(run, baseline_y)
394
+ base_size = run.style.font_size
395
+ return [baseline_y, base_size] unless run.is_a?(InlineRun)
396
+
397
+ case run.baseline_shift
398
+ when Content::InlineRun::BASELINE_SUB
399
+ [baseline_y - (base_size * 0.2), base_size * run.font_size_scale]
400
+ when Content::InlineRun::BASELINE_SUP
401
+ [baseline_y + (base_size * 0.4), base_size * run.font_size_scale]
402
+ else
403
+ [baseline_y, base_size]
404
+ end
405
+ end
406
+
407
+ # Returns the PDF resource ref for a font name. Embedded
408
+ # fonts use the Type0 ref registered during prepare_embedded_fonts;
409
+ # everything else falls back to the standard 14 path via
410
+ # FontRegistry (Helvetica, Times-Roman, etc.).
411
+ def embedded_or_standard(font_name)
412
+ key = @embedded_resource_keys&.[](font_name)
413
+ return key if key
414
+
415
+ @fonts.resolve(font_name)
416
+ end
417
+
418
+ # Returns the text payload to emit: encoded 2-byte GID
419
+ # sequence for embedded fonts, or the raw Unicode string for
420
+ # standard 14 fonts (whose /Encoding is implicit WinAnsi).
421
+ def encoded_or_text(font_name, text)
422
+ encoder = @font_encoders[font_name]
423
+ return text unless encoder
424
+
425
+ encoder.encode(text)
426
+ end
427
+
428
+ def render_rect(canvas, box)
429
+ stroke = box.data[:stroke_width]
430
+ stroke_color = box.data[:stroke_color]
431
+ fill_color = box.data[:fill_color]
432
+ canvas.rectangle(box.x, box.y, box.width, box.height)
433
+ if fill_color
434
+ canvas.fill_color(parse_color(fill_color))
435
+ canvas.fill
436
+ end
437
+ return unless stroke&.to_f&.positive?
438
+
439
+ canvas.line_width = stroke.to_f
440
+ canvas.stroke_color(parse_color(stroke_color)) if stroke_color
441
+ canvas.stroke
442
+ end
443
+
444
+ def render_line(canvas, box)
445
+ d = box.data
446
+ canvas.line(d[:x1], d[:y1], d[:x2], d[:y2])
447
+ canvas.line_width = d[:stroke_width].to_f
448
+ canvas.stroke_color(parse_color(d[:stroke_color])) if d[:stroke_color]
449
+ canvas.stroke
450
+ end
451
+
452
+ def render_image(canvas, box)
453
+ ref = resolve_image_ref(box.data[:image_ref])
454
+ return unless ref
455
+
456
+ invoke = invoke_xobject_op
457
+ return unless invoke
458
+
459
+ canvas.save_graphics_state do
460
+ canvas.concat(box.width, 0, 0, box.height, box.x, box.y)
461
+ canvas.emit_op(invoke, ref)
462
+ end
463
+ end
464
+
465
+ def resolve_image_ref(image_ref)
466
+ return image_ref if image_ref.is_a?(Symbol)
467
+
468
+ register_image(image_ref.to_s)
469
+ end
470
+
471
+ def record_link_if_needed(run, x, baseline_y, font_size)
472
+ return unless run.is_a?(InlineRun) && run.hyperlink?
473
+
474
+ approx_width = run.text.length * font_size * 0.5
475
+ @link_annotator.record_link(
476
+ x: x, y: baseline_y - 2,
477
+ width: approx_width, height: font_size + 2,
478
+ uri: run.href
479
+ )
480
+ end
481
+
482
+ def parse_color(spec)
483
+ return spec if spec.is_a?(Array) || spec.is_a?(Numeric)
484
+ return 0.0 if spec.nil? || spec.to_s.empty?
485
+
486
+ color = Color.parse(spec)
487
+ return 0.0 unless color
488
+
489
+ color.grayscale? ? color.to_grayscale_float : color.to_render
490
+ end
491
+ end
492
+ end
493
+ end
@@ -0,0 +1,59 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Renderer
5
+ # Configuration for PDF digital signatures. Carries the keystore
6
+ # path, password, and certification level. The renderer applies
7
+ # the signature as a post-processing step using an external
8
+ # signing tool (or a future Pdfrb signing API).
9
+ #
10
+ # Certification levels (PDF 1.7 §12.8.2.2):
11
+ # +:no_changes+ — no changes permitted after signing.
12
+ # +:form_fill+ — form filling and signing permitted.
13
+ # +:annotate+ — annotations + form fill + signing permitted.
14
+ class SignatureConfig
15
+ CERT_LEVELS = {
16
+ no_changes: 1,
17
+ form_fill: 2,
18
+ annotate: 3
19
+ }.freeze
20
+
21
+ attr_reader :keystore_path, :keystore_password, :cert_level,
22
+ :reason, :location, :name
23
+
24
+ def initialize(keystore_path:, keystore_password:,
25
+ cert_level: :form_fill, reason: nil, location: nil,
26
+ name: nil)
27
+ @keystore_path = keystore_path.to_s
28
+ @keystore_password = keystore_password.to_s
29
+ @cert_level = cert_level.to_sym
30
+ @reason = reason&.to_s
31
+ @location = location&.to_s
32
+ @name = name&.to_s
33
+ freeze
34
+ end
35
+
36
+ def cert_level_code
37
+ CERT_LEVELS.fetch(@cert_level, 2)
38
+ end
39
+
40
+ def valid?
41
+ !@keystore_path.empty? && !@keystore_password.empty?
42
+ end
43
+
44
+ def ==(other)
45
+ other.is_a?(self.class) &&
46
+ keystore_path == other.keystore_path &&
47
+ cert_level == other.cert_level &&
48
+ reason == other.reason &&
49
+ location == other.location &&
50
+ name == other.name
51
+ end
52
+ alias eql? ==
53
+
54
+ def hash
55
+ [self.class, keystore_path, cert_level, reason, location, name].hash
56
+ end
57
+ end
58
+ end
59
+ end
@@ -0,0 +1,117 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Arrolio
4
+ module Renderer
5
+ # Builds a PDF structure tree (/StructTreeRoot) for PDF/UA
6
+ # compliance. Walks the Output::Page[] tree and creates
7
+ # /StructElem entries for each content block (paragraphs,
8
+ # headings, tables, figures). The structure tree maps marked
9
+ # content sequences to semantic roles so assistive technology
10
+ # can navigate the document.
11
+ #
12
+ # The builder is a foundation — full PDF/UA compliance also
13
+ # requires /Alt text (AccessibilityTagger), reading order,
14
+ # and /ActualText for non-text content. This builder creates
15
+ # the tree skeleton; the renderer fills in marked content refs.
16
+ class StructureTreeBuilder
17
+ # Standard PDF structure types from PDF 1.7 Table 10.20.
18
+ STRUCT_TYPES = {
19
+ document: :Document,
20
+ part: :Part,
21
+ section: :Sect,
22
+ heading: :H,
23
+ heading1: :H1,
24
+ heading2: :H2,
25
+ heading3: :H3,
26
+ paragraph: :P,
27
+ table: :Table,
28
+ table_row: :TR,
29
+ table_header: :TH,
30
+ table_data: :TD,
31
+ list: :L,
32
+ list_item: :LI,
33
+ figure: :Figure,
34
+ caption: :Caption,
35
+ footnote: :Footnote,
36
+ link: :Link,
37
+ quote: :BlockQuote,
38
+ code: :Code
39
+ }.freeze
40
+
41
+ attr_reader :document, :entries
42
+
43
+ def initialize(document)
44
+ @document = document
45
+ @entries = []
46
+ @next_mcid = 0
47
+ end
48
+
49
+ # Records a structure element for a content block.
50
+ # +type:+ one of STRUCT_TYPES keys.
51
+ # +page_number:+ the page where the content appears.
52
+ # +text:+ optional text content for the /Alt entry.
53
+ # +alt:+ optional alt text for non-text content.
54
+ def record(type:, page_number:, text: nil, alt: nil)
55
+ entry = StructEntry.new(
56
+ type: struct_type_for(type),
57
+ page_number: page_number,
58
+ mcid: @next_mcid,
59
+ text: text,
60
+ alt: alt
61
+ )
62
+ @entries << entry
63
+ @next_mcid += 1
64
+ entry
65
+ end
66
+
67
+ # Builds and attaches the /StructTreeRoot to the document
68
+ # catalog. Returns the root reference or nil if no entries.
69
+ def build
70
+ return nil if @entries.empty?
71
+
72
+ struct_elements = @entries.map { |e| build_struct_elem(e) }
73
+ root = @document.add(
74
+ { Type: :StructTreeRoot,
75
+ K: struct_elements.map { |ref| ref_for(ref) } },
76
+ type: Pdfrb::Model::Cos::Dictionary
77
+ )
78
+ @document.catalog.value[:StructTreeRoot] =
79
+ Pdfrb::Model::Reference.new(root.oid, root.gen)
80
+ root
81
+ end
82
+
83
+ def count
84
+ @entries.length
85
+ end
86
+
87
+ def empty?
88
+ @entries.empty?
89
+ end
90
+
91
+ # Internal structure entry record.
92
+ StructEntry = Struct.new(:type, :page_number, :mcid, :text, :alt,
93
+ keyword_init: true)
94
+
95
+ private
96
+
97
+ def struct_type_for(type)
98
+ STRUCT_TYPES.fetch(type, :P)
99
+ end
100
+
101
+ def build_struct_elem(entry)
102
+ dict = {
103
+ Type: :StructElem,
104
+ S: entry.type,
105
+ Pg: nil,
106
+ MCID: entry.mcid
107
+ }
108
+ dict[:Alt] = entry.alt if entry.alt && !entry.alt.empty?
109
+ @document.add(dict, type: Pdfrb::Model::Cos::Dictionary)
110
+ end
111
+
112
+ def ref_for(obj)
113
+ Pdfrb::Model::Reference.new(obj.oid, obj.gen)
114
+ end
115
+ end
116
+ end
117
+ end