From 2d0c94b39dcaa2cc03a6b75f34e804f6496d5198 Mon Sep 17 00:00:00 2001 From: Craig McNamara Date: Wed, 25 Mar 2026 15:33:22 -0700 Subject: [PATCH] Add table accessibility tagging (Table/TR/TH/TD structure elements) When rendering on a tagged Prawn::Document (marked: true), tables now automatically emit PDF structure elements: - wraps the entire table - wraps each row -
with /Scope /Column for header cells - for data cells Header cells are detected automatically from the table's header: option. Backgrounds and borders are wrapped as /Artifact (decorative content not read by screen readers). Co-Authored-By: Claude Opus 4.6 (1M context) --- Gemfile | 5 ++ lib/prawn/table.rb | 21 +++++++ lib/prawn/table/cell.rb | 85 ++++++++++++++++++++++++-- spec/table/accessibility_spec.rb | 100 +++++++++++++++++++++++++++++++ 4 files changed, 205 insertions(+), 6 deletions(-) create mode 100644 spec/table/accessibility_spec.rb diff --git a/Gemfile b/Gemfile index b4e2a20b..1147fbe7 100644 --- a/Gemfile +++ b/Gemfile @@ -1,3 +1,8 @@ source "https://rubygems.org" gemspec + +# Evaluate Gemfile.local if it exists +if File.exist?("#{__FILE__}.local") + instance_eval(File.read("#{__FILE__}.local"), "#{__FILE__}.local") +end diff --git a/lib/prawn/table.rb b/lib/prawn/table.rb index 718fe698..a50ed677 100644 --- a/lib/prawn/table.rb +++ b/lib/prawn/table.rb @@ -153,6 +153,7 @@ def initialize(data, document, options={}, &block) set_column_widths set_row_heights position_cells + mark_header_cells end # Number of rows in the table. @@ -263,6 +264,15 @@ def style(stylable, style_hash={}, &block) # Draws the table onto the document at the document's current y-position. # def draw + if @pdf.respond_to?(:tagged?) && @pdf.tagged? + @pdf.structure_container(:Table) { draw_inner } + else + draw_inner + end + end + + # @api private + def draw_inner with_position do # Reference bounds are the non-stretchy bounds used to decide when to # flow to a new column / page. @@ -511,6 +521,17 @@ def header_rows header_rows end + # Marks cells in header rows as header cells for accessibility. + # + def mark_header_cells + n = number_of_header_rows + return if n == 0 + + @cells.each do |cell| + cell.is_header_cell = true if cell.row < n + end + end + # Converts the array of cellable objects given into instances of # Prawn::Table::Cell, and sets up their in-table properties so that they # know their own position in the table. diff --git a/lib/prawn/table/cell.rb b/lib/prawn/table/cell.rb index 3dbbc844..990bef01 100644 --- a/lib/prawn/table/cell.rb +++ b/lib/prawn/table/cell.rb @@ -141,6 +141,19 @@ def max_width # attr_accessor :background_color + # Whether this cell is a header cell (TH) for accessibility. + # Set automatically for cells in header rows when the table has + # header: true. + # + attr_accessor :is_header_cell + + # Whether this cell is a header cell. + # + # @return [Boolean] + def header? + !!@is_header_cell + end + # Number of columns this cell spans. Defaults to 1. # attr_reader :colspan @@ -414,14 +427,65 @@ def draw(pt=[x, y]) # and content. # def self.draw_cells(cells) - cells.each do |cell, pt| - cell.set_width_constraints - cell.draw_background(pt) + return if cells.empty? + + first_entry = cells.first + first_cell = first_entry.is_a?(Array) ? first_entry[0] : first_entry + pdf = first_cell.instance_variable_get(:@pdf) + tagged = pdf.respond_to?(:tagged?) && pdf.tagged? + + # Phase 1: backgrounds (decorative — artifact in tagged mode) + if tagged + pdf.artifact(type: :Layout) do + cells.each do |cell, pt| + cell.set_width_constraints + cell.draw_background(pt) + end + end + else + cells.each do |cell, pt| + cell.set_width_constraints + cell.draw_background(pt) + end + end + + # Phase 2: borders and content + if tagged + draw_cells_tagged(cells, pdf) + else + cells.each do |cell, pt| + cell.draw_borders(pt) + cell.draw_bounded_content(pt) + end end + end + + # Draw cells with accessibility structure tags (TR, TH, TD). + # + # @api private + def self.draw_cells_tagged(cells, pdf) + # Group cells by row for TR wrapping + rows = cells.group_by { |cell, _pt| cell.row } + + rows.sort_by { |row_num, _| row_num }.each do |_row_num, row_cells| + pdf.structure_container(:TR) do + row_cells.each do |cell, pt| + # Skip span dummy cells — the master cell handles drawing + next if cell.is_a?(Cell::SpanDummy) + + # Borders are decorative + pdf.artifact(type: :Layout) { cell.draw_borders(pt) } - cells.each do |cell, pt| - cell.draw_borders(pt) - cell.draw_bounded_content(pt) + # Content gets TH or TD tag + tag = cell.header? ? :TH : :TD + attrs = {} + attrs[:Scope] = :Column if tag == :TH + + pdf.structure(tag, attrs) do + cell.draw_content_only(pt) + end + end + end end end @@ -437,6 +501,15 @@ def draw_bounded_content(pt) end end + # Draws only the cell content (no borders or background). + # Used by the tagged PDF rendering path where borders are + # drawn separately as artifacts. + # + # @api private + def draw_content_only(pt) + draw_bounded_content(pt) + end + # x-position of the cell within the parent bounds. # def x diff --git a/spec/table/accessibility_spec.rb b/spec/table/accessibility_spec.rb new file mode 100644 index 00000000..68107983 --- /dev/null +++ b/spec/table/accessibility_spec.rb @@ -0,0 +1,100 @@ +# encoding: utf-8 + +require 'spec_helper' + +describe 'Table Accessibility' do + let(:pdf) { Prawn::Document.new(marked: true, language: 'en-US', margin: 0) } + + describe 'tagged table rendering' do + it 'wraps the table in a Table structure element' do + data = [['Name', 'Age'], ['Alice', '30']] + pdf.table(data, header: true) + output = pdf.render + + expect(output).to include('/Table') + expect(output).to include('/StructTreeRoot') + end + + it 'creates TR structure elements for each row' do + data = [['A', 'B'], ['C', 'D']] + pdf.table(data) + output = pdf.render + + expect(output).to include('/TR') + end + + it 'creates TH elements for header cells' do + data = [['Name', 'Age'], ['Alice', '30']] + pdf.table(data, header: true) + output = pdf.render + + expect(output).to include('/TH') + end + + it 'creates TD elements for data cells' do + data = [['Name', 'Age'], ['Alice', '30']] + pdf.table(data, header: true) + output = pdf.render + + expect(output).to include('/TD') + end + + it 'sets Scope on TH elements' do + data = [['Name', 'Age'], ['Alice', '30']] + pdf.table(data, header: true) + output = pdf.render + + expect(output).to include('/Scope /Column') + end + + it 'marks all cells as TD when no header is set' do + data = [['A', 'B'], ['C', 'D']] + pdf.table(data) + output = pdf.render + + expect(output).to include('/TD') + expect(output).not_to include('/TH') + end + + it 'supports multiple header rows' do + data = [['Group', ''], ['Name', 'Age'], ['Alice', '30']] + pdf.table(data, header: 2) + output = pdf.render + + expect(output).to include('/TH') + end + end + + describe 'untagged table rendering' do + it 'does not emit structure tags when not marked' do + plain_pdf = Prawn::Document.new(margin: 0) + data = [['Name', 'Age'], ['Alice', '30']] + plain_pdf.table(data, header: true) + output = plain_pdf.render + + expect(output).not_to include('/StructTreeRoot') + expect(output).not_to include('/TH') + expect(output).not_to include('/TD') + end + end + + describe 'Cell#header?' do + it 'returns true for cells in header rows' do + data = [['Name', 'Age'], ['Alice', '30']] + table = pdf.make_table(data, header: true) + + header_cell = table.cells[0, 0] + data_cell = table.cells[1, 0] + + expect(header_cell).to be_header + expect(data_cell).not_to be_header + end + + it 'returns false when no header is set' do + data = [['A', 'B'], ['C', 'D']] + table = pdf.make_table(data) + + expect(table.cells[0, 0]).not_to be_header + end + end +end