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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +27 -0
- data/COPYING +5 -0
- data/GPLv2 +339 -0
- data/GPLv3 +674 -0
- data/LICENSE +58 -0
- data/README.md +89 -0
- data/lib/prawn/accessibility/document.rb +153 -0
- data/lib/prawn/accessibility/marked_content.rb +60 -0
- data/lib/prawn/accessibility/pdf_core.rb +79 -0
- data/lib/prawn/accessibility/structure_tree.rb +275 -0
- data/lib/prawn/accessibility/table.rb +144 -0
- data/lib/prawn/accessibility/version.rb +8 -0
- data/lib/prawn/accessibility.rb +14 -0
- data/lib/prawn-accessibility.rb +4 -0
- metadata +97 -0
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
|
+
[](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)
|