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
5 changes: 5 additions & 0 deletions Gemfile
Original file line number Diff line number Diff line change
@@ -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
21 changes: 21 additions & 0 deletions lib/prawn/table.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down
85 changes: 79 additions & 6 deletions lib/prawn/table/cell.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand All @@ -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
Expand Down
100 changes: 100 additions & 0 deletions spec/table/accessibility_spec.rb
Original file line number Diff line number Diff line change
@@ -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