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.
- checksums.yaml +7 -0
- data/.rspec +2 -0
- data/.rubocop.yml +343 -0
- data/CHANGELOG.md +18 -0
- data/CLAUDE.md +154 -0
- data/Gemfile +15 -0
- data/LICENSE +24 -0
- data/README.adoc +95 -0
- data/Rakefile +21 -0
- data/TODO.complete/01-true-type-font-metrics.md +56 -0
- data/TODO.complete/02-font-embedding.md +51 -0
- data/TODO.complete/03-knuth-plass-breaker.md +42 -0
- data/TODO.complete/04-table-flowable.md +60 -0
- data/TODO.complete/05-list-flowable.md +50 -0
- data/TODO.complete/06-notes-and-examples.md +41 -0
- data/TODO.complete/07-odd-even-templates-and-page-numbering.md +63 -0
- data/TODO.complete/08-image-flowable.md +45 -0
- data/TODO.complete/09-toc-and-leaders.md +66 -0
- data/TODO.complete/10-hyperlinks-and-destinations.md +46 -0
- data/TODO.complete/11-pdf-outline-bookmarks.md +47 -0
- data/TODO.complete/12-metadata-info-dict.md +42 -0
- data/TODO.complete/13-xmp-metadata.md +43 -0
- data/TODO.complete/14-footnotes.md +43 -0
- data/TODO.complete/15-color-management.md +33 -0
- data/TODO.complete/16-pdfua-struct-tree.md +35 -0
- data/TODO.complete/17-alt-text-and-actual-text.md +28 -0
- data/TODO.complete/18-math-rendering.md +41 -0
- data/TODO.complete/19-preformatted-and-code.md +31 -0
- data/TODO.complete/20-page-break-controls.md +46 -0
- data/TODO.complete/21-svg-renderer.md +40 -0
- data/TODO.complete/22-definition-lists.md +36 -0
- data/TODO.complete/23-multi-column.md +34 -0
- data/TODO.complete/24-cover-page-fidelity.md +40 -0
- data/TODO.complete/25-i18n-and-multi-language.md +28 -0
- data/TODO.complete/26-pixel-diff-harness.md +36 -0
- data/TODO.complete/27-pdfrb-only-text-extraction.md +37 -0
- data/TODO.complete/28-restore-nested-module-style.md +41 -0
- data/TODO.complete/29-font-embedding-wiring-debug.md +72 -0
- data/TODO.complete/30-adapter-decomposition.md +76 -0
- data/TODO.complete/31-subscript-superscript-inline-runs.md +56 -0
- data/TODO.complete/32-debug-output-cleanup.md +59 -0
- data/TODO.complete/33-comprehensive-adapter-specs.md +55 -0
- data/TODO.complete/34-table-column-auto-layout.md +50 -0
- data/TODO.complete/35-image-path-resolution.md +66 -0
- data/TODO.complete/36-xsl-converter-completeness.md +40 -0
- data/TODO.complete/37-content-model-completeness.md +54 -0
- data/TODO.complete/38-pipeline-config-externalization.md +55 -0
- data/TODO.complete/39-two-pass-cross-reference-resolution.md +66 -0
- data/TODO.complete/40-vertical-writing-modes.md +63 -0
- data/TODO.complete/41-pdf-annotations-hyperlinks.md +53 -0
- data/TODO.complete/42-font-manifest-configuration.md +75 -0
- data/TODO.complete/43-shrink-to-fit-containers.md +52 -0
- data/TODO.complete/44-back-of-book-index-generation.md +51 -0
- data/TODO.complete/45-pdf-forms-acroform.md +52 -0
- data/TODO.complete/46-pdf-digital-signatures.md +47 -0
- data/TODO.complete/47-ocp-flavor-separation.md +50 -0
- data/TODO.complete/48-oiml-as-pure-configuration.md +80 -0
- data/TODO.complete/49-oiml-flowbuilder-as-configuration.md +39 -0
- data/TODO.complete/50-delete-oiml-ruby-code.md +53 -0
- data/TODO.complete/51-xsl-as-authoritative-source.md +68 -0
- data/TODO.complete/52-flavor-packaging-separation.md +49 -0
- data/TODO.complete/53-second-flavor-spike.md +74 -0
- data/TODO.complete/54-flavor-manifest-contract.md +72 -0
- data/TODO.complete/55-header-footer-style-configuration.md +57 -0
- data/TODO.complete/56-generic-adapter-mece-decomposition.md +56 -0
- data/TODO.complete/57-strict-section-typing.md +49 -0
- data/TODO.complete/58-flavor-packaging-gem-convention.md +47 -0
- data/TODO.complete/59-xsl-converter-profiles.md +76 -0
- data/TODO.complete/60-adapter-fidelity-footnotes-notes-examples.md +62 -0
- data/TODO.complete/61-error-model-and-strict-mode.md +62 -0
- data/TODO.complete/62-font-resolution-and-strict-fonts.md +66 -0
- data/TODO.complete/63-purge-hardcoded-vocabulary-from-generic-core.md +68 -0
- data/TODO.complete/64-selector-driven-adapter-specs.md +46 -0
- data/TODO.complete/66-semantic-content-types.md +171 -0
- data/TODO.complete/README.md +48 -0
- data/TODO.implementation/01-foundation.md +46 -0
- data/TODO.implementation/02-content-contract.md +47 -0
- data/TODO.implementation/03-layoutspec-contract.md +46 -0
- data/TODO.implementation/04-style-system.md +43 -0
- data/TODO.implementation/05-visitor-pattern.md +40 -0
- data/TODO.implementation/06-afm-data-and-parser.md +37 -0
- data/TODO.implementation/07-afm-metrics.md +41 -0
- data/TODO.implementation/08-truetype-tables-and-metrics.md +48 -0
- data/TODO.implementation/09-font-registry-and-glyph-measurer.md +49 -0
- data/TODO.implementation/10-break-opportunity-and-inline-run.md +47 -0
- data/TODO.implementation/11-greedy-line-breaker.md +48 -0
- data/TODO.implementation/12-knuth-plass-line-breaker.md +46 -0
- data/TODO.implementation/13-line-and-alignment.md +45 -0
- data/TODO.implementation/14-knuth-elements-and-breaker.md +56 -0
- data/TODO.implementation/15-refactor-text-layout-on-knuth.md +47 -0
- data/TODO.implementation/16-flowable-and-frame.md +52 -0
- data/TODO.implementation/17-flow-context.md +50 -0
- data/TODO.implementation/18-text-flowable.md +52 -0
- data/TODO.implementation/19-spacer-and-page-break.md +52 -0
- data/TODO.implementation/20-engine-paged.md +63 -0
- data/TODO.implementation/21-output-tree.md +56 -0
- data/TODO.implementation/22-pdf-renderer.md +77 -0
- data/TODO.implementation/23-end-to-end-smoke.md +53 -0
- data/TODO.implementation/24-page-template-and-region.md +51 -0
- data/TODO.implementation/25-page-sequence-master.md +54 -0
- data/TODO.implementation/26-two-pass-layout.md +66 -0
- data/TODO.implementation/27-table-model.md +48 -0
- data/TODO.implementation/28-table-layouts.md +53 -0
- data/TODO.implementation/29-table-renderer.md +55 -0
- data/TODO.implementation/30-table-flowable.md +53 -0
- data/TODO.implementation/31-list-model.md +45 -0
- data/TODO.implementation/32-list-flowable.md +54 -0
- data/TODO.implementation/33-image-flowable.md +59 -0
- data/TODO.implementation/34-svg-renderer.md +58 -0
- data/TODO.implementation/35-svg-as-form-xobject.md +38 -0
- data/TODO.implementation/36-inline-builder.md +50 -0
- data/TODO.implementation/37-hyperlink.md +53 -0
- data/TODO.implementation/38-sub-superscript.md +44 -0
- data/TODO.implementation/39-leader-and-field-runs.md +60 -0
- data/TODO.implementation/40-bookmark-outline.md +45 -0
- data/TODO.implementation/41-destinations.md +52 -0
- data/TODO.implementation/42-toc-builder.md +52 -0
- data/TODO.implementation/43-footnotes.md +51 -0
- data/TODO.implementation/44-multi-column.md +45 -0
- data/TODO.implementation/45-page-numbering-and-section-tracking.md +50 -0
- data/TODO.implementation/46-color-management.md +47 -0
- data/TODO.implementation/47-metadata-and-xmp.md +41 -0
- data/TODO.implementation/48-struct-tree-and-marked-content.md +49 -0
- data/TODO.implementation/49-alt-text-and-actual-text.md +43 -0
- data/TODO.implementation/50-composer-facade.md +59 -0
- data/TODO.implementation/51-style-presets.md +44 -0
- data/TODO.implementation/52-oiml-adapter-and-e2e-pipeline.md +120 -0
- data/TODO.implementation/53-oiml-layout-spec.md +121 -0
- data/TODO.implementation/54-oiml-pipeline.md +97 -0
- data/TODO.implementation/55-pdf-comparator.md +98 -0
- data/TODO.implementation/56-diff-formatters.md +95 -0
- data/TODO.implementation/57-path-locator-and-enrichment.md +74 -0
- data/TODO.implementation/58-rspec-matchers-and-harness-api.md +124 -0
- data/TODO.implementation/59-pixel-diff-mode.md +93 -0
- data/TODO.implementation/60-yard-documentation.md +43 -0
- data/TODO.implementation/61-benchmarks-and-stress-tests.md +50 -0
- data/TODO.implementation/62-validation-framework.md +107 -0
- data/TODO.implementation/63-layout-rules-profile.md +110 -0
- data/TODO.implementation/64-pdfa-and-pdfua-conformance.md +74 -0
- data/TODO.implementation/65-feature-extractor.md +64 -0
- data/TODO.implementation/66-auto-fixer.md +75 -0
- data/TODO.implementation/README.md +169 -0
- data/data/arrolio/afm/Courier-Bold.afm +342 -0
- data/data/arrolio/afm/Courier-BoldOblique.afm +342 -0
- data/data/arrolio/afm/Courier-Oblique.afm +342 -0
- data/data/arrolio/afm/Courier.afm +342 -0
- data/data/arrolio/afm/Helvetica-Bold.afm +2827 -0
- data/data/arrolio/afm/Helvetica-BoldOblique.afm +2827 -0
- data/data/arrolio/afm/Helvetica-Oblique.afm +3051 -0
- data/data/arrolio/afm/Helvetica.afm +3051 -0
- data/data/arrolio/afm/Symbol.afm +213 -0
- data/data/arrolio/afm/Times-Bold.afm +2588 -0
- data/data/arrolio/afm/Times-BoldItalic.afm +2384 -0
- data/data/arrolio/afm/Times-Italic.afm +2667 -0
- data/data/arrolio/afm/Times-Roman.afm +2419 -0
- data/data/arrolio/afm/ZapfDingbats.afm +225 -0
- data/data/arrolio/glyphlist.txt +4306 -0
- data/exe/arrolio2pdf +28 -0
- data/lib/arrolio/asset_resolver.rb +36 -0
- data/lib/arrolio/color.rb +107 -0
- data/lib/arrolio/column_set.rb +64 -0
- data/lib/arrolio/composer.rb +10 -0
- data/lib/arrolio/config_driven_pipeline.rb +144 -0
- data/lib/arrolio/content/bibliography_item.rb +42 -0
- data/lib/arrolio/content/builder.rb +108 -0
- data/lib/arrolio/content/document.rb +60 -0
- data/lib/arrolio/content/example.rb +43 -0
- data/lib/arrolio/content/figure_group.rb +39 -0
- data/lib/arrolio/content/footnote.rb +46 -0
- data/lib/arrolio/content/form_field.rb +47 -0
- data/lib/arrolio/content/formula.rb +34 -0
- data/lib/arrolio/content/heading.rb +42 -0
- data/lib/arrolio/content/hyperlink.rb +42 -0
- data/lib/arrolio/content/image.rb +36 -0
- data/lib/arrolio/content/index_entry.rb +52 -0
- data/lib/arrolio/content/inline_run.rb +51 -0
- data/lib/arrolio/content/list.rb +60 -0
- data/lib/arrolio/content/note.rb +48 -0
- data/lib/arrolio/content/page_break.rb +28 -0
- data/lib/arrolio/content/paragraph.rb +38 -0
- data/lib/arrolio/content/preformatted.rb +47 -0
- data/lib/arrolio/content/section.rb +39 -0
- data/lib/arrolio/content/table.rb +117 -0
- data/lib/arrolio/content/term_entry.rb +43 -0
- data/lib/arrolio/content.rb +27 -0
- data/lib/arrolio/data_dir.rb +28 -0
- data/lib/arrolio/engine/cross_reference_registry.rb +69 -0
- data/lib/arrolio/engine/paged.rb +186 -0
- data/lib/arrolio/engine.rb +9 -0
- data/lib/arrolio/error.rb +70 -0
- data/lib/arrolio/flavor/manifest.rb +111 -0
- data/lib/arrolio/flavor/registry.rb +54 -0
- data/lib/arrolio/flavor.rb +23 -0
- data/lib/arrolio/flow_context.rb +37 -0
- data/lib/arrolio/flowable.rb +66 -0
- data/lib/arrolio/flowables/footnote_marker_flowable.rb +28 -0
- data/lib/arrolio/flowables/heading_flowable.rb +23 -0
- data/lib/arrolio/flowables/image_flowable.rb +53 -0
- data/lib/arrolio/flowables/list_flowable.rb +117 -0
- data/lib/arrolio/flowables/note_flowable.rb +19 -0
- data/lib/arrolio/flowables/page_break.rb +15 -0
- data/lib/arrolio/flowables/page_sequence_start.rb +28 -0
- data/lib/arrolio/flowables/spacer.rb +34 -0
- data/lib/arrolio/flowables/table_flowable.rb +147 -0
- data/lib/arrolio/flowables/text_flowable.rb +95 -0
- data/lib/arrolio/flowables/toc_line_flowable.rb +35 -0
- data/lib/arrolio/flowables/two_column_block.rb +56 -0
- data/lib/arrolio/flowables.rb +19 -0
- data/lib/arrolio/font/embedder.rb +197 -0
- data/lib/arrolio/font/manifest.rb +114 -0
- data/lib/arrolio/font/resolver.rb +161 -0
- data/lib/arrolio/font/text_encoder.rb +28 -0
- data/lib/arrolio/font.rb +10 -0
- data/lib/arrolio/font_metrics/afm_metrics.rb +91 -0
- data/lib/arrolio/font_metrics/registry.rb +55 -0
- data/lib/arrolio/font_metrics/true_type_metrics.rb +95 -0
- data/lib/arrolio/font_metrics.rb +10 -0
- data/lib/arrolio/frame.rb +36 -0
- data/lib/arrolio/generic_adapter.rb +672 -0
- data/lib/arrolio/generic_flow_builder.rb +338 -0
- data/lib/arrolio/glyph_measurer.rb +71 -0
- data/lib/arrolio/harness/pdf_diff.rb +104 -0
- data/lib/arrolio/harness/pixel_diff.rb +122 -0
- data/lib/arrolio/harness/text_diff.rb +53 -0
- data/lib/arrolio/harness/text_extractor.rb +58 -0
- data/lib/arrolio/harness.rb +10 -0
- data/lib/arrolio/inline_run.rb +59 -0
- data/lib/arrolio/layout_spec/flow.rb +17 -0
- data/lib/arrolio/layout_spec/loader.rb +117 -0
- data/lib/arrolio/layout_spec/page_template.rb +127 -0
- data/lib/arrolio/layout_spec/page_template_selector.rb +58 -0
- data/lib/arrolio/layout_spec/region.rb +38 -0
- data/lib/arrolio/layout_spec/scale_length.rb +34 -0
- data/lib/arrolio/layout_spec.rb +90 -0
- data/lib/arrolio/logger.rb +54 -0
- data/lib/arrolio/output/page.rb +69 -0
- data/lib/arrolio/output/placed_box.rb +68 -0
- data/lib/arrolio/output/region.rb +32 -0
- data/lib/arrolio/output.rb +10 -0
- data/lib/arrolio/renderer/accessibility_tagger.rb +70 -0
- data/lib/arrolio/renderer/font_registry.rb +32 -0
- data/lib/arrolio/renderer/link_annotator.rb +62 -0
- data/lib/arrolio/renderer/outline_builder.rb +102 -0
- data/lib/arrolio/renderer/pdf.rb +493 -0
- data/lib/arrolio/renderer/signature_config.rb +59 -0
- data/lib/arrolio/renderer/structure_tree_builder.rb +117 -0
- data/lib/arrolio/renderer/xmp_builder.rb +92 -0
- data/lib/arrolio/renderer.rb +17 -0
- data/lib/arrolio/style/definition.rb +98 -0
- data/lib/arrolio/style/loader.rb +25 -0
- data/lib/arrolio/style/registry.rb +53 -0
- data/lib/arrolio/style.rb +9 -0
- data/lib/arrolio/table/auto_layout.rb +91 -0
- data/lib/arrolio/table.rb +9 -0
- data/lib/arrolio/text_direction.rb +81 -0
- data/lib/arrolio/text_layout/break_opportunity.rb +103 -0
- data/lib/arrolio/text_layout/greedy.rb +110 -0
- data/lib/arrolio/text_layout/knuth_plass/breaker.rb +265 -0
- data/lib/arrolio/text_layout/knuth_plass/item.rb +84 -0
- data/lib/arrolio/text_layout/knuth_plass/item_builder.rb +103 -0
- data/lib/arrolio/text_layout/knuth_plass.rb +12 -0
- data/lib/arrolio/text_layout/line.rb +72 -0
- data/lib/arrolio/text_layout.rb +11 -0
- data/lib/arrolio/toc_builder.rb +31 -0
- data/lib/arrolio/version.rb +5 -0
- data/lib/arrolio/writing_mode.rb +75 -0
- data/lib/arrolio.rb +42 -0
- data/script/smoke.rb +32 -0
- data/scripts/write_content_extras.py +227 -0
- data/scripts/xsl_profiles/standoc.yml +218 -0
- data/scripts/xsl_to_config.rb +391 -0
- 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
|