diff --git a/lib/prawn.rb b/lib/prawn.rb index df1a255fb..08fd561d2 100644 --- a/lib/prawn.rb +++ b/lib/prawn.rb @@ -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' diff --git a/lib/prawn/accessibility.rb b/lib/prawn/accessibility.rb new file mode 100644 index 000000000..528bc8cd8 --- /dev/null +++ b/lib/prawn/accessibility.rb @@ -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 diff --git a/lib/prawn/document.rb b/lib/prawn/document.rb index 92e263697..621bd91a4 100644 --- a/lib/prawn/document.rb +++ b/lib/prawn/document.rb @@ -58,6 +58,7 @@ class Document include Prawn::Stamp include Prawn::SoftMask include Prawn::TransformationStack + include Prawn::Accessibility alias inspect to_s @@ -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 @@ -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 diff --git a/spec/prawn/accessibility_spec.rb b/spec/prawn/accessibility_spec.rb new file mode 100644 index 000000000..6af7516d3 --- /dev/null +++ b/spec/prawn/accessibility_spec.rb @@ -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