Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions lib/prawn.rb
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ def verify_options(accepted, actual)
require_relative 'prawn/soft_mask'
require_relative 'prawn/security'
require_relative 'prawn/transformation_stack'
require_relative 'prawn/accessibility'
require_relative 'prawn/document'
require_relative 'prawn/font'
require_relative 'prawn/measurements'
Expand Down
136 changes: 136 additions & 0 deletions lib/prawn/accessibility.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
# frozen_string_literal: true

module Prawn
# Provides tagged PDF (accessibility) support for Prawn documents.
#
# When a document is created with `marked: true`, all content can be
# wrapped in structure elements that screen readers and assistive
# technologies use to navigate the document.
#
# @example
# pdf = Prawn::Document.new(marked: true, language: 'en-US')
#
# pdf.structure(:H1) do
# pdf.text 'Document Title'
# end
#
# pdf.structure(:P) do
# pdf.text 'Body paragraph text.'
# end
#
# pdf.artifact do
# pdf.text 'Page 1' # not read by screen readers
# end
module Accessibility
# Whether this document is tagged for accessibility.
#
# @return [Boolean]
def tagged?
renderer.marked?
end

# Wrap content in a structure element. The block's content will be
# associated with the given tag in the document's structure tree.
#
# Can be nested — inner structure calls become children of the outer.
#
# @param tag [Symbol] PDF structure type (:Document, :Part, :Sect,
# :H1-:H6, :P, :L, :LI, :Lbl, :LBody, :Table, :TR, :TH, :TD,
# :Figure, :Formula, :Form, :Span, :Link, :Note, :BlockQuote,
# :Caption, :TOC, :TOCI, :Reference)
# @param attributes [Hash] optional attributes
# @option attributes [String] :Alt alternative text (for Figure, Formula)
# @option attributes [String] :ActualText replacement text for screen
# readers (e.g., "required" for "*", "selected" for "X")
# @option attributes [String] :Lang language override for this element
# @option attributes [Symbol] :Scope table header scope (:Column, :Row, :Both)
# @yield content to render inside this structure element
# @return [void]
def structure(tag, attributes = {}, &block)
return yield if !tagged? || !block

tree = renderer.structure_tree
tree.begin_element(tag, attributes)
tree.mark_content(tag, &block)
tree.end_element
end

# Wrap content in a structure element without marking the content
# directly. Use this for container elements (Table, TR, L, LI) where
# the children will each have their own marked content.
#
# @param tag [Symbol] PDF structure type
# @param attributes [Hash] optional attributes
# @yield content to render inside this structure element
# @return [void]
def structure_container(tag, attributes = {}, &block)
return yield if !tagged? || !block

tree = renderer.structure_tree
tree.begin_element(tag, attributes)
yield
tree.end_element
end

# Mark content as an artifact (decorative, not read by screen readers).
# Use for page numbers, decorative borders, backgrounds, watermarks.
#
# @param type [Symbol, nil] artifact type (:Pagination, :Layout,
# :Page, :Background)
# @yield content to render as artifact
# @return [void]
def artifact(type: nil, &block)
return yield if !tagged? || !block

renderer.structure_tree.mark_artifact(artifact_type: type, &block)
end

# Render a heading at the specified level.
#
# @param level [Integer] heading level 1-6
# @param content [String] heading text
# @param options [Hash] options passed to `text()`
# @return [void]
def heading(level, content, options = {})
tag = :"H#{level}"
if tagged?
structure(tag) { text(content, options) }
else
text(content, options)
end
end

# Render text wrapped in a paragraph structure element.
#
# @param content [String, nil] text to render. If nil, yields a block.
# @param options [Hash] options passed to `text()`
# @yield optional block for complex paragraph content
# @return [void]
def paragraph(content = nil, options = {}, &block)
if tagged?
if block
structure(:P, &block)
else
structure(:P) { text(content, options) }
end
elsif block
yield
else
text(content, options)
end
end

# Render an image wrapped in a Figure structure element with alt text.
#
# @param alt_text [String] alternative text for the image
# @yield block that calls `image()` or other drawing methods
# @return [void]
def figure(alt_text:, &block)
if tagged?
structure(:Figure, Alt: alt_text, &block)
else
yield
end
end
end
end
6 changes: 6 additions & 0 deletions lib/prawn/document.rb
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ class Document
include Prawn::Stamp
include Prawn::SoftMask
include Prawn::TransformationStack
include Prawn::Accessibility

alias inspect to_s

Expand All @@ -72,6 +73,7 @@ class Document
right_margin top_margin bottom_margin skip_page_creation
compress background info
text_formatter print_scaling
marked language
].freeze

# Any module added to this array will be included into instances of
Expand Down Expand Up @@ -239,6 +241,10 @@ def initialize(options = {}, &block)

renderer.min_version(1.6) if options[:print_scaling] == :none

if options[:language]
state.store.root.data[:Lang] = options[:language]
end

@background = options[:background]
@background_scale = options[:background_scale] || 1
@font_size = 12
Expand Down
201 changes: 201 additions & 0 deletions spec/prawn/accessibility_spec.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,201 @@
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Prawn::Accessibility do
describe 'with tagged document' do
let(:pdf) { Prawn::Document.new(marked: true, language: 'en-US') }

describe '#tagged?' do
it 'returns true for marked documents' do
expect(pdf).to be_tagged
end

it 'returns false for unmarked documents' do
plain = Prawn::Document.new
expect(plain).to_not(be_tagged)
end
end

describe 'language' do
it 'sets Lang on the catalog' do
root_data = pdf.state.store.root.data
expect(root_data[:Lang]).to eq('en-US')
end
end

describe '#structure' do
it 'wraps content in a structure element' do
pdf.structure(:H1) do
pdf.text('Title')
end
output = pdf.render

expect(output).to include('/StructTreeRoot')
expect(output).to include('/StructElem')
end

it 'emits BDC/EMC in the content stream' do
pdf.structure(:P) do
pdf.text('Hello')
end
output = pdf.render

expect(output).to include('BDC')
expect(output).to include('EMC')
end

it 'is a no-op for untagged documents' do
plain = Prawn::Document.new
plain.structure(:P) do
plain.text('Hello')
end
output = plain.render

expect(output).to_not(include('/StructTreeRoot'))
end
end

describe '#structure_container' do
it 'creates a parent structure without marking content directly' do
pdf.structure_container(:Table) do
pdf.structure(:TD) do
pdf.text('Cell')
end
end
output = pdf.render

expect(output).to include('/StructElem')
expect(output).to include('/Table')
expect(output).to include('/TD')
end
end

describe '#artifact' do
it 'wraps content in Artifact markers' do
pdf.artifact do
pdf.text('Page 1')
end
output = pdf.render

expect(output).to include('/Artifact BMC')
expect(output).to include('EMC')
end

it 'supports artifact type' do
pdf.artifact(type: :Pagination) do
pdf.text('Page 1')
end
output = pdf.render

expect(output).to include('/Artifact')
expect(output).to include('/Type /Pagination')
end

it 'is a no-op for untagged documents' do
plain = Prawn::Document.new
plain.artifact do
plain.text('Footer')
end
output = plain.render

expect(output).to_not(include('/Artifact'))
end
end

describe '#heading' do
it 'renders text in an H1 structure element' do
pdf.heading(1, 'Title', size: 24)
output = pdf.render

expect(output).to include('/H1')
expect(output).to include('BDC')
end

it 'supports levels 1-6' do
(1..6).each do |level|
pdf.heading(level, "Heading #{level}")
end
output = pdf.render

(1..6).each do |level|
expect(output).to include("/H#{level}")
end
end
end

describe '#paragraph' do
it 'renders text in a P structure element' do
pdf.paragraph('Body text.')
output = pdf.render

expect(output).to include('BDC')
end

it 'supports block form' do
pdf.paragraph do
pdf.text('Complex paragraph')
end
output = pdf.render

expect(output).to include('BDC')
expect(output).to include('EMC')
end
end

describe 'ActualText' do
it 'passes ActualText to structure elements' do
pdf.structure(:Span, ActualText: 'required') do
pdf.text('*')
end
output = pdf.render

expect(output).to include('/ActualText')
end

it 'allows ActualText for checkbox indicators' do
pdf.structure(:Span, ActualText: 'Selected') do
pdf.text('X')
end
pdf.structure(:Span, ActualText: 'Not selected') do
pdf.text(' ')
end
output = pdf.render

expect(output).to include('/ActualText')
end
end

describe '#figure' do
it 'wraps content with alt text' do
pdf.figure(alt_text: 'A logo') do
pdf.text('IMAGE PLACEHOLDER')
end
output = pdf.render

expect(output).to include('/Figure')
expect(output).to include('/Alt')
end
end

describe 'full document round-trip' do
it 'produces a tagged PDF with MarkInfo and StructTreeRoot' do
pdf.heading(1, 'Test Document')
pdf.paragraph('This is a test paragraph.')

pdf.artifact(type: :Pagination) do
pdf.text('Page 1 of 1')
end

output = pdf.render

expect(output).to start_with('%PDF-1.7')
expect(output).to include('/MarkInfo')
expect(output).to include('/Marked true')
expect(output).to include('/StructTreeRoot')
expect(output).to include('/Lang')
expect(output).to include('/Document')
end
end
end
end
Loading