prawn-accessibility 1.0.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.
@@ -0,0 +1,275 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PDF
4
+ module Core
5
+ # Manages the PDF structure tree for tagged/accessible PDFs.
6
+ #
7
+ # The structure tree provides the logical structure of a document,
8
+ # mapping marked content sequences in page content streams to
9
+ # structure elements (headings, paragraphs, tables, etc.).
10
+ #
11
+ # PDF spec references: Section 14.7 (Logical Structure)
12
+ #
13
+ # @api private
14
+ class StructureTree
15
+ # @return [PDF::Core::Renderer] owning renderer
16
+ attr_reader :renderer
17
+
18
+ # @return [PDF::Core::Reference] StructTreeRoot indirect object
19
+ attr_reader :root_ref
20
+
21
+ # @return [PDF::Core::Reference] Document-level structure element
22
+ attr_reader :document_elem_ref
23
+
24
+ # @return [Array<Hash>] all structure elements created
25
+ attr_reader :elements
26
+
27
+ # @return [Hash{Integer => Array}] page StructParents index => array of
28
+ # structure element refs for marked content on that page
29
+ attr_reader :parent_tree_map
30
+
31
+ # @return [Integer] next available MCID for the current page
32
+ attr_reader :next_mcid
33
+
34
+ # @return [Array<PDF::Core::Reference>] stack of open structure elements
35
+ attr_reader :element_stack
36
+
37
+ # @param renderer [PDF::Core::Renderer]
38
+ def initialize(renderer)
39
+ @renderer = renderer
40
+ @elements = []
41
+ @parent_tree_map = {}
42
+ @next_mcid = 0
43
+ @element_stack = []
44
+ @page_mcid_map = {} # page_ref_id => next mcid for that page
45
+ @root_ref = nil
46
+ @document_elem_ref = nil
47
+ end
48
+
49
+ # Allocate the next MCID for the current page and track it.
50
+ #
51
+ # @return [Integer] the allocated MCID
52
+ def allocate_mcid
53
+ page = renderer.state.page
54
+ page_id = page.dictionary.identifier
55
+
56
+ @page_mcid_map[page_id] ||= 0
57
+ mcid = @page_mcid_map[page_id]
58
+ @page_mcid_map[page_id] += 1
59
+
60
+ mcid
61
+ end
62
+
63
+ # Add a structure element as a child of the current open element
64
+ # (or the document element if none is open).
65
+ #
66
+ # @param tag [Symbol] structure type (e.g., :P, :H1, :Table, :TD)
67
+ # @param attributes [Hash] additional attributes for the structure element
68
+ # @option attributes [String] :Alt alternative text (for Figure, Formula)
69
+ # @option attributes [String] :ActualText replacement text for screen
70
+ # readers (e.g., "required" instead of reading "*")
71
+ # @option attributes [String] :Lang language tag
72
+ # @option attributes [Symbol] :Scope TH scope (:Column, :Row, :Both)
73
+ # @return [PDF::Core::Reference] the structure element reference
74
+ def add_element(tag, attributes = {})
75
+ parent_ref = current_element || document_element
76
+
77
+ elem_data = {
78
+ Type: :StructElem,
79
+ S: tag,
80
+ P: parent_ref,
81
+ K: [],
82
+ }
83
+
84
+ elem_data[:Alt] = attributes[:Alt] if attributes[:Alt]
85
+ elem_data[:ActualText] = attributes[:ActualText] if attributes[:ActualText]
86
+ elem_data[:Lang] = attributes[:Lang] if attributes[:Lang]
87
+
88
+ if attributes[:Scope]
89
+ elem_data[:A] = {
90
+ O: :Table,
91
+ Scope: attributes[:Scope],
92
+ }
93
+ end
94
+
95
+ elem_ref = renderer.ref!(elem_data)
96
+ @elements << elem_ref
97
+
98
+ # Add as child of parent
99
+ parent_data = renderer.deref(parent_ref)
100
+ parent_data[:K] << elem_ref
101
+
102
+ elem_ref
103
+ end
104
+
105
+ # Begin a structure element scope. Content rendered inside will be
106
+ # children of this element.
107
+ #
108
+ # @param tag [Symbol] structure type
109
+ # @param attributes [Hash] additional attributes
110
+ # @return [PDF::Core::Reference] the opened structure element
111
+ def begin_element(tag, attributes = {})
112
+ elem_ref = add_element(tag, attributes)
113
+ @element_stack.push(elem_ref)
114
+ elem_ref
115
+ end
116
+
117
+ # End the current structure element scope.
118
+ #
119
+ # @return [PDF::Core::Reference] the closed structure element
120
+ def end_element
121
+ @element_stack.pop
122
+ end
123
+
124
+ # Add marked content to the current structure element.
125
+ # This allocates an MCID, records the mapping, and emits BDC/EMC
126
+ # operators around the yielded block.
127
+ #
128
+ # @param tag [Symbol] marked content tag (e.g., :P, :Span)
129
+ # @param struct_elem_ref [PDF::Core::Reference, nil] the structure element
130
+ # this content belongs to. If nil, uses current_element.
131
+ # @yield content to render inside the marked content sequence
132
+ # @return [void]
133
+ def mark_content(tag, struct_elem_ref: nil)
134
+ elem_ref = struct_elem_ref || current_element || document_element
135
+ mcid = allocate_mcid
136
+ page = renderer.state.page
137
+ page_ref = page.dictionary
138
+
139
+ # Record in parent tree map
140
+ page_struct_parents = page_struct_parents_index(page_ref)
141
+ @parent_tree_map[page_struct_parents] ||= []
142
+
143
+ # Add marked content reference to the structure element's K array
144
+ mcr = { Type: :MCR, MCID: mcid, Pg: page_ref }
145
+ elem_data = renderer.deref(elem_ref)
146
+ elem_data[:K] << mcr
147
+
148
+ # Track which struct element owns this MCID on this page
149
+ @parent_tree_map[page_struct_parents][mcid] = elem_ref
150
+
151
+ # Emit BDC/EMC in content stream
152
+ renderer.begin_marked_content_with_properties(tag, { MCID: mcid })
153
+ yield if block_given?
154
+ renderer.end_marked_content
155
+ end
156
+
157
+ # Mark content as an artifact (decorative, not read by screen readers).
158
+ #
159
+ # @param artifact_type [Symbol, nil] optional artifact type
160
+ # (:Pagination, :Layout, :Page, :Background)
161
+ # @yield content to render as artifact
162
+ # @return [void]
163
+ def mark_artifact(artifact_type: nil)
164
+ if artifact_type
165
+ renderer.begin_marked_content_with_properties(
166
+ :Artifact, { Type: artifact_type },
167
+ )
168
+ else
169
+ renderer.begin_marked_content(:Artifact)
170
+ end
171
+ yield if block_given?
172
+ renderer.end_marked_content
173
+ end
174
+
175
+ # Finalize the structure tree before rendering. Called via
176
+ # before_render callback.
177
+ #
178
+ # Builds the StructTreeRoot, ParentTree, and wires everything
179
+ # into the Catalog.
180
+ #
181
+ # @return [void]
182
+ def finalize!
183
+ return if @elements.empty? && @parent_tree_map.empty?
184
+
185
+ build_root
186
+ build_parent_tree
187
+ assign_struct_parents_to_pages
188
+ attach_to_catalog
189
+ end
190
+
191
+ private
192
+
193
+ # The current open structure element, or nil if none.
194
+ #
195
+ # @return [PDF::Core::Reference, nil]
196
+ def current_element
197
+ @element_stack.last
198
+ end
199
+
200
+ # Get or create the Document-level structure element.
201
+ #
202
+ # @return [PDF::Core::Reference]
203
+ def document_element
204
+ return @document_elem_ref if @document_elem_ref
205
+
206
+ @document_elem_ref = renderer.ref!(
207
+ Type: :StructElem,
208
+ S: :Document,
209
+ P: nil, # will be set to StructTreeRoot in finalize!
210
+ K: [],
211
+ )
212
+ @elements << @document_elem_ref
213
+ @document_elem_ref
214
+ end
215
+
216
+ # Get or assign a StructParents index for a page.
217
+ #
218
+ # @param page_ref [PDF::Core::Reference] page dictionary reference
219
+ # @return [Integer] the StructParents index
220
+ def page_struct_parents_index(page_ref)
221
+ page_ref.data[:StructParents] ||= @parent_tree_map.size
222
+ page_ref.data[:StructParents]
223
+ end
224
+
225
+ # Build the StructTreeRoot object.
226
+ #
227
+ # @return [void]
228
+ def build_root
229
+ @root_ref = renderer.ref!(
230
+ Type: :StructTreeRoot,
231
+ K: document_element,
232
+ ParentTree: nil, # set in build_parent_tree
233
+ )
234
+
235
+ # Point Document element's parent to the root
236
+ doc_data = renderer.deref(@document_elem_ref)
237
+ doc_data[:P] = @root_ref
238
+ end
239
+
240
+ # Build the ParentTree (a number tree mapping StructParents indices
241
+ # to arrays of structure element references).
242
+ #
243
+ # @return [void]
244
+ def build_parent_tree
245
+ # ParentTree is a number tree. For simplicity, use a flat Nums array
246
+ # since most documents won't have enough pages to need a balanced tree.
247
+ nums = []
248
+ @parent_tree_map.sort_by { |k, _| k }.each do |index, elem_array|
249
+ nums << index
250
+ nums << renderer.ref!(elem_array)
251
+ end
252
+
253
+ parent_tree_ref = renderer.ref!(Type: :ParentTree, Nums: nums)
254
+ root_data = renderer.deref(@root_ref)
255
+ root_data[:ParentTree] = parent_tree_ref
256
+ end
257
+
258
+ # Ensure each page that has marked content has a StructParents entry.
259
+ # (Already handled lazily in page_struct_parents_index, but verify.)
260
+ #
261
+ # @return [void]
262
+ def assign_struct_parents_to_pages
263
+ # Already assigned lazily when mark_content is called.
264
+ # This method exists as a hook for any additional finalization.
265
+ end
266
+
267
+ # Wire the StructTreeRoot into the document Catalog.
268
+ #
269
+ # @return [void]
270
+ def attach_to_catalog
271
+ renderer.state.store.root.data[:StructTreeRoot] = @root_ref
272
+ end
273
+ end
274
+ end
275
+ end
@@ -0,0 +1,144 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Table accessibility patches. This file is only loaded when Prawn::Table is
4
+ # already defined (see prawn/accessibility.rb), so requiring prawn-accessibility
5
+ # without prawn-table installed is safe.
6
+
7
+ unless defined?(Prawn::Table)
8
+ raise 'prawn/accessibility/table requires prawn-table to be loaded first'
9
+ end
10
+
11
+ module Prawn
12
+ module Accessibility
13
+ # Patches applied to the published +prawn-table+ gem so that tables emit
14
+ # +<Table>+/+<TR>+/+<TH>+/+<TD>+ structure elements when the owning document
15
+ # is tagged. Untagged tables render exactly as upstream (via +super+).
16
+ #
17
+ # @api private
18
+ module TablePatch
19
+ # Prepended onto {Prawn::Table#initialize} to flag header cells after
20
+ # the table has been built and positioned.
21
+ #
22
+ # @api private
23
+ module TableInstancePatch
24
+ def initialize(*args, &block)
25
+ super
26
+ mark_header_cells
27
+ end
28
+
29
+ # Draws the table, wrapping it in a +<Table>+ structure element when
30
+ # the document is tagged.
31
+ def draw
32
+ if @pdf.respond_to?(:tagged?) && @pdf.tagged?
33
+ @pdf.structure_container(:Table) { super() }
34
+ else
35
+ super
36
+ end
37
+ end
38
+
39
+ # Marks cells in header rows as header cells for accessibility.
40
+ #
41
+ # @return [void]
42
+ def mark_header_cells
43
+ n = number_of_header_rows
44
+ return if n.zero?
45
+
46
+ @cells.each do |cell|
47
+ cell.is_header_cell = true if cell.row < n
48
+ end
49
+ end
50
+ end
51
+
52
+ # Prepended onto the {Prawn::Table::Cell} singleton so
53
+ # <tt>Cell.draw_cells</tt> emits row/cell structure tags in tagged mode.
54
+ #
55
+ # @api private
56
+ module CellClassPatch
57
+ def draw_cells(cells)
58
+ return super if cells.empty?
59
+
60
+ first_entry = cells.first
61
+ first_cell = first_entry.is_a?(Array) ? first_entry[0] : first_entry
62
+ pdf = first_cell.instance_variable_get(:@pdf)
63
+ tagged = pdf.respond_to?(:tagged?) && pdf.tagged?
64
+
65
+ return super unless tagged
66
+
67
+ # Phase 1: backgrounds (decorative — artifact in tagged mode)
68
+ pdf.artifact(type: :Layout) do
69
+ cells.each do |cell, pt|
70
+ cell.set_width_constraints
71
+ cell.draw_background(pt)
72
+ end
73
+ end
74
+
75
+ # Phase 2: borders and content, wrapped in TR/TH/TD structure
76
+ draw_cells_tagged(cells, pdf)
77
+ end
78
+
79
+ # Draw cells with accessibility structure tags (TR, TH, TD).
80
+ #
81
+ # @api private
82
+ def draw_cells_tagged(cells, pdf)
83
+ # Group cells by row for TR wrapping
84
+ rows = cells.group_by { |cell, _pt| cell.row }
85
+
86
+ rows.sort_by { |row_num, _| row_num }.each do |_row_num, row_cells|
87
+ pdf.structure_container(:TR) do
88
+ row_cells.each do |cell, pt|
89
+ # Skip span dummy cells — the master cell handles drawing
90
+ next if cell.is_a?(Prawn::Table::Cell::SpanDummy)
91
+
92
+ # Borders are decorative
93
+ pdf.artifact(type: :Layout) { cell.draw_borders(pt) }
94
+
95
+ # Content gets TH or TD tag
96
+ tag = cell.header? ? :TH : :TD
97
+ attrs = {}
98
+ attrs[:Scope] = :Column if tag == :TH
99
+
100
+ pdf.structure(tag, attrs) do
101
+ cell.draw_content_only(pt)
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
110
+ end
111
+
112
+ # --- Additive re-open of Prawn::Table::Cell ---------------------------------
113
+
114
+ module Prawn
115
+ class Table
116
+ class Cell
117
+ # Whether this cell is a header cell (TH) for accessibility. Set
118
+ # automatically for cells in header rows when the table has
119
+ # <tt>header: true</tt>.
120
+ attr_accessor :is_header_cell
121
+
122
+ # Whether this cell is a header cell.
123
+ #
124
+ # @return [Boolean]
125
+ def header?
126
+ !!@is_header_cell
127
+ end
128
+
129
+ # Draws only the cell content (no borders or background). Used by the
130
+ # tagged PDF rendering path where borders are drawn separately as
131
+ # artifacts.
132
+ #
133
+ # @api private
134
+ def draw_content_only(pt)
135
+ draw_bounded_content(pt)
136
+ end
137
+ end
138
+ end
139
+ end
140
+
141
+ Prawn::Table.prepend(Prawn::Accessibility::TablePatch::TableInstancePatch)
142
+ Prawn::Table::Cell.singleton_class.prepend(
143
+ Prawn::Accessibility::TablePatch::CellClassPatch,
144
+ )
@@ -0,0 +1,8 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Prawn
4
+ module Accessibility
5
+ # Gem version. Follows Semantic Versioning (https://semver.org).
6
+ VERSION = '1.0.0'
7
+ end
8
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'accessibility/version'
4
+ require_relative 'accessibility/document'
5
+
6
+ # prawn-table support is optional. If the gem is installed, load it and apply
7
+ # the table tagging patches; otherwise carry on without table support.
8
+ begin
9
+ require 'prawn/table'
10
+ rescue LoadError
11
+ # prawn-table is not available; table tagging is disabled.
12
+ end
13
+
14
+ require_relative 'accessibility/table' if defined?(Prawn::Table)
@@ -0,0 +1,4 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Conventional entry point matching the gem name (`require 'prawn-accessibility'`).
4
+ require_relative 'prawn/accessibility'
metadata ADDED
@@ -0,0 +1,97 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: prawn-accessibility
3
+ version: !ruby/object:Gem::Version
4
+ version: 1.0.0
5
+ platform: ruby
6
+ authors:
7
+ - Mes Amis
8
+ bindir: bin
9
+ cert_chain: []
10
+ date: 1980-01-02 00:00:00.000000000 Z
11
+ dependencies:
12
+ - !ruby/object:Gem::Dependency
13
+ name: pdf-core
14
+ requirement: !ruby/object:Gem::Requirement
15
+ requirements:
16
+ - - "~>"
17
+ - !ruby/object:Gem::Version
18
+ version: '0.10'
19
+ type: :runtime
20
+ prerelease: false
21
+ version_requirements: !ruby/object:Gem::Requirement
22
+ requirements:
23
+ - - "~>"
24
+ - !ruby/object:Gem::Version
25
+ version: '0.10'
26
+ - !ruby/object:Gem::Dependency
27
+ name: prawn
28
+ requirement: !ruby/object:Gem::Requirement
29
+ requirements:
30
+ - - "~>"
31
+ - !ruby/object:Gem::Version
32
+ version: '2.5'
33
+ type: :runtime
34
+ prerelease: false
35
+ version_requirements: !ruby/object:Gem::Requirement
36
+ requirements:
37
+ - - "~>"
38
+ - !ruby/object:Gem::Version
39
+ version: '2.5'
40
+ description: |
41
+ prawn-accessibility layers tagged-PDF support onto the published prawn,
42
+ pdf-core, and (optionally) prawn-table gems. It adds a high-level API for
43
+ marking document structure — headings, paragraphs, figures, tables, and
44
+ artifacts — so Prawn can produce Section 508 / WCAG accessible PDFs.
45
+
46
+ The behavior originates from the stalled upstream pull requests
47
+ prawnpdf/pdf-core#67, prawnpdf/prawn#1391, and prawnpdf/prawn-table#164,
48
+ repackaged here as a standalone gem that patches the released libraries via
49
+ `prepend`/additive re-opens rather than forking them.
50
+ email:
51
+ - craig@monami.io
52
+ executables: []
53
+ extensions: []
54
+ extra_rdoc_files: []
55
+ files:
56
+ - CHANGELOG.md
57
+ - COPYING
58
+ - GPLv2
59
+ - GPLv3
60
+ - LICENSE
61
+ - README.md
62
+ - lib/prawn-accessibility.rb
63
+ - lib/prawn/accessibility.rb
64
+ - lib/prawn/accessibility/document.rb
65
+ - lib/prawn/accessibility/marked_content.rb
66
+ - lib/prawn/accessibility/pdf_core.rb
67
+ - lib/prawn/accessibility/structure_tree.rb
68
+ - lib/prawn/accessibility/table.rb
69
+ - lib/prawn/accessibility/version.rb
70
+ homepage: https://github.com/mes-amis/prawn-accessibility
71
+ licenses:
72
+ - Nonstandard
73
+ - GPL-2.0-only
74
+ - GPL-3.0-only
75
+ metadata:
76
+ homepage_uri: https://github.com/mes-amis/prawn-accessibility
77
+ source_code_uri: https://github.com/mes-amis/prawn-accessibility
78
+ changelog_uri: https://github.com/mes-amis/prawn-accessibility/blob/main/CHANGELOG.md
79
+ rubygems_mfa_required: 'true'
80
+ rdoc_options: []
81
+ require_paths:
82
+ - lib
83
+ required_ruby_version: !ruby/object:Gem::Requirement
84
+ requirements:
85
+ - - ">="
86
+ - !ruby/object:Gem::Version
87
+ version: '3.0'
88
+ required_rubygems_version: !ruby/object:Gem::Requirement
89
+ requirements:
90
+ - - ">="
91
+ - !ruby/object:Gem::Version
92
+ version: '0'
93
+ requirements: []
94
+ rubygems_version: 3.6.9
95
+ specification_version: 4
96
+ summary: Tagged PDF (Section 508 / WCAG) accessibility for Prawn.
97
+ test_files: []