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.
data/LICENSE ADDED
@@ -0,0 +1,58 @@
1
+ prawn-accessibility is copyrighted free software produced by Mes Amis along with
2
+ community contributions. It derives from and is distributed under the same terms
3
+ as Prawn (copyright Gregory Brown along with community contributions). See git
4
+ log for authorship information.
5
+
6
+ Licensing terms follow:
7
+
8
+ You can redistribute prawn-accessibility and/or modify it under either the terms
9
+ of the GPLv2 or GPLv3 (see GPLv2 and GPLv3 files), or the conditions below:
10
+
11
+ 1. You may make and give away verbatim copies of the source form of the
12
+ software without restriction, provided that you duplicate all of the
13
+ original copyright notices and associated disclaimers.
14
+
15
+ 2. You may modify your copy of the software in any way, provided that
16
+ you do at least ONE of the following:
17
+
18
+ a) place your modifications in the Public Domain or otherwise
19
+ make them Freely Available, such as by posting said
20
+ modifications to Usenet or an equivalent medium, or by allowing
21
+ the author to include your modifications in the software.
22
+
23
+ b) use the modified software only within your corporation or
24
+ organization.
25
+
26
+ c) rename any non-standard executables so the names do not conflict
27
+ with standard executables, which must also be provided.
28
+
29
+ d) make other distribution arrangements with the author.
30
+
31
+ 3. You may distribute the software in object code or executable
32
+ form, provided that you do at least ONE of the following:
33
+
34
+ a) distribute the executables and library files of the software,
35
+ together with instructions (in the manual page or equivalent)
36
+ on where to get the original distribution.
37
+
38
+ b) accompany the distribution with the machine-readable source of
39
+ the software.
40
+
41
+ c) give non-standard executables non-standard names, with
42
+ instructions on where to get the original software distribution.
43
+
44
+ d) make other distribution arrangements with the author.
45
+
46
+ 4. You may modify and include the part of the software into any other
47
+ software (possibly commercial).
48
+
49
+ 5. The scripts and library files supplied as input to or produced as
50
+ output from the software do not automatically fall under the
51
+ copyright of the software, but belong to whomever generated them,
52
+ and may be sold commercially, and may be aggregated with this
53
+ software.
54
+
55
+ 6. THIS SOFTWARE IS PROVIDED "AS IS" AND WITHOUT ANY EXPRESS OR
56
+ IMPLIED WARRANTIES, INCLUDING, WITHOUT LIMITATION, THE IMPLIED
57
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
58
+ PURPOSE.
data/README.md ADDED
@@ -0,0 +1,89 @@
1
+ # prawn-accessibility
2
+
3
+ [![CI](https://github.com/mes-amis/prawn-accessibility/actions/workflows/ci.yml/badge.svg)](https://github.com/mes-amis/prawn-accessibility/actions/workflows/ci.yml)
4
+
5
+ Tagged-PDF (GSA **Section 508** / **WCAG**) accessibility for [Prawn](https://github.com/prawnpdf/prawn).
6
+
7
+ `prawn-accessibility` adds a high-level API for marking document structure —
8
+ headings, paragraphs, figures, tables, and decorative artifacts — so Prawn can
9
+ produce accessible, tagged PDFs that screen readers can navigate.
10
+
11
+ It layers on top of the **published** `prawn`, `pdf-core`, and (optionally)
12
+ `prawn-table` gems using `prepend` and additive re-opens — it does **not** fork
13
+ them. When you don't opt in (`marked: true`), output is byte-for-byte identical
14
+ to stock Prawn.
15
+
16
+ ## Installation
17
+
18
+ ```ruby
19
+ # Gemfile
20
+ gem 'prawn-accessibility'
21
+ ```
22
+
23
+ `prawn` and `pdf-core` are pulled in automatically. Table tagging activates
24
+ automatically **if `prawn-table` is also in your bundle** — it is not a required
25
+ dependency:
26
+
27
+ ```ruby
28
+ gem 'prawn-table' # optional; enables <Table>/<TR>/<TH>/<TD> tagging
29
+ ```
30
+
31
+ ## Usage
32
+
33
+ ```ruby
34
+ require 'prawn-accessibility'
35
+
36
+ pdf = Prawn::Document.new(marked: true, language: 'en-US')
37
+
38
+ pdf.heading(1, 'Annual Report') # <H1>
39
+ pdf.paragraph('Body text that a screen reader will read aloud.') # <P>
40
+
41
+ pdf.structure(:Span, ActualText: 'required') { pdf.text('*') } # replacement text
42
+ pdf.figure(alt_text: 'Company logo') { pdf.image('logo.png') } # <Figure> with /Alt
43
+
44
+ pdf.artifact(type: :Pagination) { pdf.text('Page 1') } # decorative, not read
45
+
46
+ # Tables auto-tag when the document is tagged:
47
+ pdf.table([['Name', 'Age'], ['Alice', '30']], header: true) # <Table>/<TR>/<TH>/<TD>
48
+
49
+ pdf.render_file('report.pdf')
50
+ ```
51
+
52
+ ### API
53
+
54
+ | Method | Emits |
55
+ |---|---|
56
+ | `pdf.tagged?` | whether the document is in tagged mode |
57
+ | `pdf.structure(tag, attrs) { … }` | a structure element wrapping marked content |
58
+ | `pdf.structure_container(tag, attrs) { … }` | a container whose children mark themselves |
59
+ | `pdf.artifact(type:) { … }` | decorative content (ignored by screen readers) |
60
+ | `pdf.heading(level, text, opts)` | `<H1>`–`<H6>` |
61
+ | `pdf.paragraph(text, opts)` / `{ … }` | `<P>` |
62
+ | `pdf.figure(alt_text:) { … }` | `<Figure>` with `/Alt` |
63
+
64
+ Supported structure attributes: `:Alt`, `:ActualText`, `:Lang`, `:Scope`.
65
+
66
+ ## Compatibility
67
+
68
+ Semantic-versioned. The `~>` dependency bounds are the compatibility contract:
69
+
70
+ | prawn-accessibility | prawn | pdf-core | prawn-table (optional) |
71
+ |---|---|---|---|
72
+ | 1.x | `~> 2.5` | `~> 0.10` | `~> 0.2` |
73
+
74
+ Tested on Ruby 3.1–3.4.
75
+
76
+ ## Development
77
+
78
+ ```bash
79
+ bundle install
80
+ bundle exec rspec # specs, including a regression suite proving untagged
81
+ # output is unaffected
82
+ bundle exec rubocop
83
+ ```
84
+
85
+ ## License
86
+
87
+ Tri-licensed under the same terms as Prawn: Matz's Ruby license (see
88
+ [LICENSE](LICENSE)) or [GPLv2](GPLv2) or [GPLv3](GPLv3). See [COPYING](COPYING)
89
+ for a summary.
@@ -0,0 +1,153 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'prawn'
4
+ require_relative 'pdf_core'
5
+
6
+ module Prawn
7
+ module Accessibility
8
+ # Instance methods mixed into {Prawn::Document} to provide the high-level
9
+ # tagged-PDF (accessibility) API. When a document is created with
10
+ # <tt>marked: true</tt>, content can be wrapped in structure elements that
11
+ # screen readers and assistive technologies use to navigate the document.
12
+ #
13
+ # @example
14
+ # pdf = Prawn::Document.new(marked: true, language: 'en-US')
15
+ #
16
+ # pdf.structure(:H1) do
17
+ # pdf.text 'Document Title'
18
+ # end
19
+ #
20
+ # pdf.structure(:P) do
21
+ # pdf.text 'Body paragraph text.'
22
+ # end
23
+ #
24
+ # pdf.artifact do
25
+ # pdf.text 'Page 1' # not read by screen readers
26
+ # end
27
+ module DocumentExtensions
28
+ # Whether this document is tagged for accessibility.
29
+ #
30
+ # @return [Boolean]
31
+ def tagged?
32
+ renderer.marked?
33
+ end
34
+
35
+ # Wrap content in a structure element. The block's content will be
36
+ # associated with the given tag in the document's structure tree.
37
+ #
38
+ # Can be nested — inner structure calls become children of the outer.
39
+ #
40
+ # @param tag [Symbol] PDF structure type (:Document, :Part, :Sect,
41
+ # :H1-:H6, :P, :L, :LI, :Lbl, :LBody, :Table, :TR, :TH, :TD,
42
+ # :Figure, :Formula, :Form, :Span, :Link, :Note, :BlockQuote,
43
+ # :Caption, :TOC, :TOCI, :Reference)
44
+ # @param attributes [Hash] optional attributes
45
+ # @option attributes [String] :Alt alternative text (for Figure, Formula)
46
+ # @option attributes [String] :ActualText replacement text for screen
47
+ # readers (e.g., "required" for "*", "selected" for "X")
48
+ # @option attributes [String] :Lang language override for this element
49
+ # @option attributes [Symbol] :Scope table header scope (:Column, :Row, :Both)
50
+ # @yield content to render inside this structure element
51
+ # @return [void]
52
+ def structure(tag, attributes = {}, &block)
53
+ return yield if !tagged? || !block
54
+
55
+ tree = renderer.structure_tree
56
+ tree.begin_element(tag, attributes)
57
+ tree.mark_content(tag, &block)
58
+ tree.end_element
59
+ end
60
+
61
+ # Wrap content in a structure element without marking the content
62
+ # directly. Use this for container elements (Table, TR, L, LI) where
63
+ # the children will each have their own marked content.
64
+ #
65
+ # @param tag [Symbol] PDF structure type
66
+ # @param attributes [Hash] optional attributes
67
+ # @yield content to render inside this structure element
68
+ # @return [void]
69
+ def structure_container(tag, attributes = {}, &block)
70
+ return yield if !tagged? || !block
71
+
72
+ tree = renderer.structure_tree
73
+ tree.begin_element(tag, attributes)
74
+ yield
75
+ tree.end_element
76
+ end
77
+
78
+ # Mark content as an artifact (decorative, not read by screen readers).
79
+ # Use for page numbers, decorative borders, backgrounds, watermarks.
80
+ #
81
+ # @param type [Symbol, nil] artifact type (:Pagination, :Layout,
82
+ # :Page, :Background)
83
+ # @yield content to render as artifact
84
+ # @return [void]
85
+ def artifact(type: nil, &block)
86
+ return yield if !tagged? || !block
87
+
88
+ renderer.structure_tree.mark_artifact(artifact_type: type, &block)
89
+ end
90
+
91
+ # Render a heading at the specified level.
92
+ #
93
+ # @param level [Integer] heading level 1-6
94
+ # @param content [String] heading text
95
+ # @param options [Hash] options passed to `text()`
96
+ # @return [void]
97
+ def heading(level, content, options = {})
98
+ tag = :"H#{level}"
99
+ if tagged?
100
+ structure(tag) { text(content, options) }
101
+ else
102
+ text(content, options)
103
+ end
104
+ end
105
+
106
+ # Render text wrapped in a paragraph structure element.
107
+ #
108
+ # @param content [String, nil] text to render. If nil, yields a block.
109
+ # @param options [Hash] options passed to `text()`
110
+ # @yield optional block for complex paragraph content
111
+ # @return [void]
112
+ def paragraph(content = nil, options = {}, &block)
113
+ if tagged?
114
+ if block
115
+ structure(:P, &block)
116
+ else
117
+ structure(:P) { text(content, options) }
118
+ end
119
+ elsif block
120
+ yield
121
+ else
122
+ text(content, options)
123
+ end
124
+ end
125
+
126
+ # Render an image wrapped in a Figure structure element with alt text.
127
+ #
128
+ # @param alt_text [String] alternative text for the image
129
+ # @yield block that calls `image()` or other drawing methods
130
+ # @return [void]
131
+ def figure(alt_text:, &block)
132
+ if tagged?
133
+ structure(:Figure, Alt: alt_text, &block)
134
+ else
135
+ yield
136
+ end
137
+ end
138
+ end
139
+ end
140
+ end
141
+
142
+ # --- Patches applied to Prawn::Document -------------------------------------
143
+
144
+ # Accept the accessibility options in Prawn::Document.new. VALID_OPTIONS is a
145
+ # frozen constant; redefine it (remove + set avoids the redefinition warning).
146
+ original_valid_options = Prawn::Document::VALID_OPTIONS
147
+ Prawn::Document.send(:remove_const, :VALID_OPTIONS)
148
+ Prawn::Document.const_set(
149
+ :VALID_OPTIONS,
150
+ (original_valid_options + %i[marked language]).freeze,
151
+ )
152
+
153
+ Prawn::Document.include(Prawn::Accessibility::DocumentExtensions)
@@ -0,0 +1,60 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PDF
4
+ module Core
5
+ # Provides methods for emitting marked content operators (BMC/BDC/EMC)
6
+ # in PDF content streams. These operators associate content with structure
7
+ # elements for accessibility (tagged PDF).
8
+ #
9
+ # @api private
10
+ module MarkedContent
11
+ # Begin a marked content sequence with no properties.
12
+ #
13
+ # @param tag [Symbol] structure type tag (e.g., :P, :Span, :Artifact)
14
+ # @return [void]
15
+ def begin_marked_content(tag)
16
+ add_content("/#{tag} BMC")
17
+ end
18
+
19
+ # Begin a marked content sequence with properties (BDC operator).
20
+ #
21
+ # @param tag [Symbol] structure type tag
22
+ # @param properties [Hash] properties dict (typically includes :MCID)
23
+ # @return [void]
24
+ def begin_marked_content_with_properties(tag, properties = {})
25
+ props = PDF::Core.pdf_object(properties, true)
26
+ add_content("/#{tag} #{props} BDC")
27
+ end
28
+
29
+ # End a marked content sequence.
30
+ #
31
+ # @return [void]
32
+ def end_marked_content
33
+ add_content('EMC')
34
+ end
35
+
36
+ # Wrap a block in a marked content sequence (BMC/EMC).
37
+ #
38
+ # @param tag [Symbol] structure type tag
39
+ # @yield content to wrap
40
+ # @return [void]
41
+ def marked_content_sequence(tag)
42
+ begin_marked_content(tag)
43
+ yield if block_given?
44
+ end_marked_content
45
+ end
46
+
47
+ # Wrap a block in a marked content sequence with properties (BDC/EMC).
48
+ #
49
+ # @param tag [Symbol] structure type tag
50
+ # @param properties [Hash] properties dict
51
+ # @yield content to wrap
52
+ # @return [void]
53
+ def marked_content_sequence_with_properties(tag, properties = {})
54
+ begin_marked_content_with_properties(tag, properties)
55
+ yield if block_given?
56
+ end_marked_content
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'pdf/core'
4
+ require_relative 'marked_content'
5
+ require_relative 'structure_tree'
6
+
7
+ module Prawn
8
+ module Accessibility
9
+ # Patches applied to the published +pdf-core+ gem to add tagged-PDF
10
+ # support. All patches are additive or +prepend+-based: the original
11
+ # (untagged) behavior is preserved and only diverges when a document is
12
+ # created with <tt>marked: true</tt>.
13
+ #
14
+ # @api private
15
+ module PDFCore
16
+ # Prepended onto {PDF::Core::DocumentState#initialize}. Threads the
17
+ # +:marked+ and +:language+ options through to the object store's
18
+ # catalog after the original initializer has built the store.
19
+ #
20
+ # @api private
21
+ module DocumentStatePatch
22
+ def initialize(options)
23
+ super
24
+
25
+ @store.root.data[:MarkInfo] = { Marked: true } if options[:marked]
26
+ @store.root.data[:Lang] = options[:language] if options[:language]
27
+ end
28
+ end
29
+
30
+ # Prepended onto {PDF::Core::Renderer#initialize}. When the document is
31
+ # marked, wires up a {PDF::Core::StructureTree}, schedules its
32
+ # finalization before render, and bumps the PDF version to 1.7.
33
+ #
34
+ # @api private
35
+ module RendererPatch
36
+ def initialize(state)
37
+ super
38
+
39
+ return unless state.store.marked?
40
+
41
+ @structure_tree = PDF::Core::StructureTree.new(self)
42
+ before_render { |_doc_state| @structure_tree.finalize! }
43
+ min_version(1.7)
44
+ end
45
+
46
+ # The structure tree for this document, or nil if not tagged.
47
+ #
48
+ # @return [PDF::Core::StructureTree, nil]
49
+ attr_reader :structure_tree
50
+
51
+ # Whether this document is marked (tagged for accessibility).
52
+ #
53
+ # @return [Boolean]
54
+ def marked?
55
+ state.store.marked?
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
61
+
62
+ # --- Additive re-opens ------------------------------------------------------
63
+
64
+ module PDF
65
+ module Core
66
+ class ObjectStore
67
+ # Whether this document is marked (tagged for accessibility).
68
+ #
69
+ # @return [Boolean]
70
+ def marked?
71
+ root.data.key?(:MarkInfo) && root.data[:MarkInfo][:Marked] == true
72
+ end
73
+ end
74
+ end
75
+ end
76
+
77
+ PDF::Core::Renderer.include(PDF::Core::MarkedContent)
78
+ PDF::Core::DocumentState.prepend(Prawn::Accessibility::PDFCore::DocumentStatePatch)
79
+ PDF::Core::Renderer.prepend(Prawn::Accessibility::PDFCore::RendererPatch)