stationery 0.3.0 → 0.4.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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +98 -4
  4. data/lib/stationery/canvas/debug.rb +2 -1
  5. data/lib/stationery/canvas/marking.rb +124 -0
  6. data/lib/stationery/canvas.rb +24 -8
  7. data/lib/stationery/document.rb +28 -10
  8. data/lib/stationery/elements/forms.rb +55 -0
  9. data/lib/stationery/elements/lists.rb +15 -5
  10. data/lib/stationery/elements.rb +10 -7
  11. data/lib/stationery/forms/acro_form.rb +115 -0
  12. data/lib/stationery/forms/appearance.rb +119 -0
  13. data/lib/stationery/forms/field.rb +122 -0
  14. data/lib/stationery/forms/metrics.rb +49 -0
  15. data/lib/stationery/layout/box.rb +11 -5
  16. data/lib/stationery/layout/field.rb +51 -0
  17. data/lib/stationery/layout/flow.rb +17 -9
  18. data/lib/stationery/layout/image.rb +4 -2
  19. data/lib/stationery/layout/list_item.rb +15 -6
  20. data/lib/stationery/layout/node.rb +2 -0
  21. data/lib/stationery/layout/paginator.rb +3 -2
  22. data/lib/stationery/layout/svg.rb +7 -3
  23. data/lib/stationery/layout/table/cell.rb +10 -2
  24. data/lib/stationery/layout/table.rb +42 -10
  25. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  26. data/lib/stationery/layout/table_of_contents.rb +1 -1
  27. data/lib/stationery/layout/text.rb +4 -3
  28. data/lib/stationery/minitest.rb +2 -0
  29. data/lib/stationery/page.rb +5 -2
  30. data/lib/stationery/page_templates.rb +7 -4
  31. data/lib/stationery/pdf/assembler.rb +36 -4
  32. data/lib/stationery/rich/renderer.rb +3 -3
  33. data/lib/stationery/structure.rb +15 -9
  34. data/lib/stationery/tagging/element.rb +74 -0
  35. data/lib/stationery/tagging/tree.rb +43 -0
  36. data/lib/stationery/tagging/writer.rb +81 -0
  37. data/lib/stationery/testing/inspector.rb +15 -0
  38. data/lib/stationery/testing/marked_text.rb +60 -0
  39. data/lib/stationery/testing/matchers.rb +25 -0
  40. data/lib/stationery/testing/structure_reader.rb +71 -0
  41. data/lib/stationery/text/paragraph.rb +27 -12
  42. data/lib/stationery/version.rb +1 -1
  43. data/lib/stationery/warnings.rb +8 -0
  44. data/lib/stationery.rb +10 -0
  45. metadata +13 -1
@@ -9,8 +9,12 @@ module Stationery
9
9
  class ListItem < Node
10
10
  attr_reader :marker, :body, :indent, :marker_gap
11
11
 
12
- def initialize(marker, body, indent:, marker_gap:)
12
+ # `tag` and `body_tag` are the item's LI and LBody; a Text marker carries its own Lbl.
13
+ def initialize(marker, body, indent:, marker_gap:, tag: Tagging::Element.new(:LI),
14
+ body_tag: Tagging::Element.new(:LBody))
13
15
  super()
16
+ @tag = tag
17
+ @body_tag = body_tag
14
18
  @marker = marker
15
19
  @body = body
16
20
  @indent = indent
@@ -31,11 +35,13 @@ module Stationery
31
35
  end
32
36
 
33
37
  def paint(canvas, x, y, width, _height = nil, **)
34
- if @marker
35
- own = @marker.width_in(column)
36
- @marker.paint(canvas, x + column - own, y, own)
38
+ canvas.structure(@tag) do
39
+ if @marker
40
+ own = @marker.width_in(column)
41
+ @marker.paint(canvas, x + column - own, y, own)
42
+ end
43
+ canvas.structure(@body_tag) { @body.paint(canvas, x + @indent, y, body_width(width)) }
37
44
  end
38
- @body.paint(canvas, x + @indent, y, body_width(width))
39
45
  end
40
46
 
41
47
  def split(width, height, fresh: false)
@@ -51,7 +57,10 @@ module Stationery
51
57
 
52
58
  def column = [@indent - @marker_gap, 0].max
53
59
  def body_width(width) = [width - @indent, 0].max
54
- def with(marker, body) = self.class.new(marker, body, indent: @indent, marker_gap: @marker_gap)
60
+
61
+ def with(marker, body)
62
+ self.class.new(marker, body, indent: @indent, marker_gap: @marker_gap, tag: @tag, body_tag: @body_tag)
63
+ end
55
64
  end
56
65
 
57
66
  # A drawn bullet (:disc, :circle or :square) sized to the text style and
@@ -26,6 +26,8 @@ module Stationery
26
26
  # - fixed_width(available) → its own width, or nil when it fills the width
27
27
  class Node
28
28
  attr_accessor :keep_with_next, :break_inside
29
+ # The Tagging::Element this node paints into, shared by its fragments.
30
+ attr_reader :tag
29
31
 
30
32
  def measure(_width) = raise(NotImplementedError, "#{self.class} must implement measure")
31
33
  def paint(_canvas, _x, _y, _width, _height = nil, **) = raise(NotImplementedError, "#{self.class}#paint")
@@ -7,8 +7,9 @@ module Stationery
7
7
  class Paginator
8
8
  attr_reader :warnings
9
9
 
10
- def initialize(resources:, page: {}, warnings: Warnings.new, debug: false, regions: nil)
10
+ def initialize(resources:, page: {}, warnings: Warnings.new, debug: false, regions: nil, tagging: nil)
11
11
  @resources = resources
12
+ @tagging = tagging
12
13
  @debug = debug
13
14
  @page_options = page
14
15
  @regions = regions
@@ -61,7 +62,7 @@ module Stationery
61
62
  box = page.content_box
62
63
  height = head.measure(box.width)
63
64
  @warnings << Overflow.new(page: number, height:, available: box.height) if height > box.height + EPSILON
64
- canvas = Canvas.new(page, @resources, debug: @debug)
65
+ canvas = Canvas.new(page, @resources, debug: @debug, tagging: @tagging)
65
66
  head.paint(canvas, box.x, box.y, box.width)
66
67
  canvas.debug_rect(box.x, box.y, box.width, box.height, :page)
67
68
  end
@@ -6,8 +6,10 @@ module Stationery
6
6
  # follows the viewBox's aspect ratio), never wider than the space given.
7
7
  class Svg < Node
8
8
  # `context` gives SVG text its font book and default family.
9
- def initialize(document, width: nil, height: nil, color: "#000000", context: nil)
9
+ # `alt:` describes the drawing in a tagged PDF; `alt: false` marks it decorative.
10
+ def initialize(document, width: nil, height: nil, color: "#000000", context: nil, alt: nil)
10
11
  super()
12
+ @tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :svg)
11
13
  @document = document
12
14
  @context = context
13
15
  @width = width
@@ -28,8 +30,10 @@ module Stationery
28
30
 
29
31
  def paint(canvas, x, y, width, _height = nil, **)
30
32
  w, h = size(width)
31
- @document.draw(canvas, x:, y:, width: w, height: h, color: @color, book: @context&.book,
32
- family: @context&.style&.family)
33
+ canvas.tag(@tag, bbox: [x, y, w, h]) do
34
+ @document.draw(canvas, x:, y:, width: w, height: h, color: @color, book: @context&.book,
35
+ family: @context&.style&.family)
36
+ end
33
37
  canvas.debug_rect(x, y, w, h, :image)
34
38
  end
35
39
 
@@ -9,7 +9,7 @@ module Stationery
9
9
  TEXT_OPTIONS = { size: :size, color: :color, weight: :weight, style: :style, font: :family,
10
10
  letter_spacing: :letter_spacing }.freeze
11
11
 
12
- attr_reader :content, :options, :colspan, :rowspan
12
+ attr_reader :content, :options, :colspan, :rowspan, :tag, :row_tag
13
13
 
14
14
  def initialize(content, options, colspan: 1, rowspan: 1)
15
15
  @content = content
@@ -23,7 +23,15 @@ module Stationery
23
23
  end
24
24
 
25
25
  # The same cell holding another node, as a row split into two parts needs.
26
- def with_content(node) = Cell.new(node, @options, colspan:, rowspan:)
26
+ def with_content(node) = Cell.new(node, @options, colspan:, rowspan:).tagged(@tag, @row_tag)
27
+
28
+ # Its TH or TD and its row's TR in a tagged PDF; set once, by the
29
+ # table the cell is first laid out in.
30
+ def tagged(tag, row_tag)
31
+ @tag ||= tag
32
+ @row_tag ||= row_tag
33
+ self
34
+ end
27
35
 
28
36
  # Changes an option and forgets the node built from the old ones.
29
37
  def []=(name, value)
@@ -10,8 +10,13 @@ module Stationery
10
10
  DEFAULT_CELL = { padding: 5, borders: %i[top right bottom left], border_width: 0.5,
11
11
  border_color: "#000000" }.freeze
12
12
 
13
- def initialize(rows, context:, widths: nil, width: :auto, header: false, split_rows: false, cell: {})
13
+ # `tag` is the Table element its fragments share; a `continued` fragment
14
+ # repeats header rows already read on an earlier page.
15
+ def initialize(rows, context:, widths: nil, width: :auto, header: false, split_rows: false, cell: {},
16
+ tag: Tagging::Element.new(:Table), continued: false)
14
17
  super()
18
+ @tag = tag
19
+ @continued = continued
15
20
  @split_rows = split_rows
16
21
  @context = context
17
22
  @widths = widths
@@ -20,6 +25,7 @@ module Stationery
20
25
  defaults = DEFAULT_CELL.merge(cell)
21
26
  @cells = rows.map { |row| row.map { |content| build_cell(content, defaults) } }
22
27
  check_header
28
+ tag_cells
23
29
  yield self if block_given?
24
30
  end
25
31
 
@@ -74,11 +80,12 @@ module Stationery
74
80
  def paint(canvas, x, y, width, _height = nil, **)
75
81
  widths = column_widths(width)
76
82
  heights = row_heights(width)
77
- grid.placements.each do |p|
78
- rect = Rect.new(x + widths[0...p.column].sum, y + heights[0...p.row].sum,
79
- widths[p.columns].sum, heights[p.rows].sum)
80
- p.cell.paint(canvas, @context, rect, last_column: p.columns.end == column_count,
81
- last_row: p.rows.end == row_count)
83
+ canvas.structure(@tag) do
84
+ grid.placements.each do |p|
85
+ rect = Rect.new(x + widths[0...p.column].sum, y + heights[0...p.row].sum,
86
+ widths[p.columns].sum, heights[p.rows].sum)
87
+ paint_cell(canvas, p, rect)
88
+ end
82
89
  end
83
90
  end
84
91
 
@@ -97,10 +104,34 @@ module Stationery
97
104
 
98
105
  def grid = @grid ||= Grid.new(@cells)
99
106
 
107
+ def paint_cell(canvas, placement, rect)
108
+ cell = placement.cell
109
+ paint = lambda do
110
+ cell.paint(canvas, @context, rect, last_column: placement.columns.end == column_count,
111
+ last_row: placement.rows.end == row_count)
112
+ end
113
+ return canvas.artifact(type: :pagination, &paint) if @continued && placement.row < @header
114
+
115
+ canvas.structure(cell.row_tag) { canvas.structure(cell.tag, &paint) }
116
+ end
117
+
118
+ def tag_cells
119
+ @cells.each_with_index do |row, index|
120
+ row_tag = Tagging::Element.new(:TR)
121
+ row.each { |cell| cell.tagged(cell_tag(cell, index < @header), row_tag) }
122
+ end
123
+ end
124
+
125
+ def cell_tag(cell, header)
126
+ attributes = { Scope: (:Column if header), ColSpan: (cell.colspan if cell.colspan > 1),
127
+ RowSpan: (cell.rowspan if cell.rowspan > 1) }.compact
128
+ Tagging::Element.new(header ? :TH : :TD, attributes: attributes.empty? ? {} : { Table: attributes })
129
+ end
130
+
100
131
  def split_before(count)
101
132
  return [nil, self] if count == @header
102
133
 
103
- [with_rows(@cells.first(count)), with_rows(@cells.first(@header) + @cells.drop(count))]
134
+ [with_rows(@cells.first(count)), with_rows(@cells.first(@header) + @cells.drop(count), continued: true)]
104
135
  end
105
136
 
106
137
  def split_row(row, width, space, fresh:)
@@ -113,7 +144,7 @@ module Stationery
113
144
  return unless heads
114
145
 
115
146
  [with_rows(@cells.first(row) + [heads], widths:),
116
- with_rows(@cells.first(@header) + [tails] + @cells.drop(row + 1), widths:)]
147
+ with_rows(@cells.first(@header) + [tails] + @cells.drop(row + 1), widths:, continued: true)]
117
148
  end
118
149
 
119
150
  def build_cell(content, defaults)
@@ -156,8 +187,9 @@ module Stationery
156
187
  @row_heights[width] ||= grid.row_heights(column_widths(width)) { |p, span| p.cell.measure(@context, span) }
157
188
  end
158
189
 
159
- def with_rows(rows, widths: @widths)
160
- self.class.new(rows, context: @context, widths:, width: @width, header: @header, split_rows: @split_rows)
190
+ def with_rows(rows, widths: @widths, continued: @continued)
191
+ self.class.new(rows, context: @context, widths:, width: @width, header: @header, split_rows: @split_rows,
192
+ tag: @tag, continued:)
161
193
  end
162
194
  end
163
195
  end
@@ -5,7 +5,8 @@ module Stationery
5
5
  class TableOfContents
6
6
  # One contents row. The title wraps within the room the number slot
7
7
  # leaves; the leader and the number sit on its last line, and the whole
8
- # row links to the entry's anchor once that anchor has painted.
8
+ # row links to the entry's anchor once that anchor has painted. Tagged,
9
+ # it is a TOCI holding the title as a Link and the number as a Reference.
9
10
  class Entry < Node
10
11
  LEADERS = { dots: { dash: [0, 3], cap: :round, width: 1 }, line: { width: 0.5 } }.freeze
11
12
 
@@ -16,20 +17,27 @@ module Stationery
16
17
  @options = options
17
18
  @slot = slot
18
19
  @paragraphs = {}
20
+ @tag = Tagging::Element.new(:TOCI)
21
+ @link = Tagging::Element.new(:Link)
22
+ @reference = Tagging::Element.new(:Reference)
19
23
  end
20
24
 
21
25
  def measure(width) = paragraph(width).height
22
26
  def natural_width = paragraph(Float::INFINITY).lines.map(&:width).max.to_f + offset + @slot + @options.gap
23
27
 
24
28
  def paint(canvas, x, y, width, _height = nil, **)
25
- text = paragraph(width)
26
- text.draw(canvas, x + offset, y)
27
- last = text.lines.last
28
- baseline = y + text.height - last.height + last.ascent
29
- slot_x = x + width - @slot
30
- draw_leader(canvas, x + offset + last.width + 2, slot_x, baseline)
31
- canvas.number_slot(@entry.anchor, x: slot_x, baseline:, width: @slot, style: @context.style,
32
- link: [x + offset, y, width - offset, text.height])
29
+ canvas.structure(@tag) do
30
+ text = paragraph(width)
31
+ text.draw(canvas, x + offset, y, tag: @link)
32
+ last = text.lines.last
33
+ baseline = y + text.height - last.height + last.ascent
34
+ slot_x = x + width - @slot
35
+ draw_leader(canvas, x + offset + last.width + 2, slot_x, baseline)
36
+ canvas.structure(@reference) { nil } # its place in reading order; Structure fills it
37
+ canvas.number_slot(@entry.anchor, x: slot_x, baseline:, width: @slot, style: @context.style,
38
+ link: [x + offset, y, width - offset, text.height],
39
+ tags: [@link, @reference])
40
+ end
33
41
  end
34
42
 
35
43
  private
@@ -26,7 +26,7 @@ module Stationery
26
26
 
27
27
  def rows
28
28
  @rows ||= Flow.new(entries.map { |entry| Entry.new(entry, context: @context, options: @options, slot:) },
29
- gap: @options.gap)
29
+ gap: @options.gap, tag: Tagging::Element.new(:TOC))
30
30
  end
31
31
 
32
32
  def entries
@@ -6,8 +6,9 @@ module Stationery
6
6
  class Text < Node
7
7
  attr_reader :runs
8
8
 
9
- def initialize(runs, context:, align: :left, leading: 0, paragraph: nil)
9
+ def initialize(runs, context:, align: :left, leading: 0, paragraph: nil, tag: Tagging::Element.new(:P))
10
10
  super()
11
+ @tag = tag
11
12
  @runs = context.book.fallback(runs)
12
13
  @context = context
13
14
  @align = align
@@ -21,7 +22,7 @@ module Stationery
21
22
  def measure(width) = paragraph(width).height
22
23
 
23
24
  def paint(canvas, x, y, width, _height = nil, **)
24
- paragraph(width).draw(canvas, x, y)
25
+ paragraph(width).draw(canvas, x, y, tag: @tag)
25
26
  end
26
27
 
27
28
  def split(width, height, **)
@@ -57,7 +58,7 @@ module Stationery
57
58
  end
58
59
 
59
60
  def from(paragraph)
60
- self.class.new(@runs, context: @context, align: @align, leading: @leading, paragraph:)
61
+ self.class.new(@runs, context: @context, align: @align, leading: @leading, paragraph:, tag: @tag)
61
62
  end
62
63
  end
63
64
  end
@@ -17,6 +17,8 @@ module Stationery
17
17
  def assert_image_count(subject, count, msg = nil) = assert_pdf(Matchers::HaveImageCount.new(count), subject, msg)
18
18
  def assert_bookmark(subject, title, msg = nil) = assert_pdf(Matchers::HaveBookmark.new(title), subject, msg)
19
19
  def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
20
+ def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
21
+ def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
20
22
 
21
23
  def refute_pdf_text(subject, expected, msg = nil)
22
24
  matcher = Matchers::HaveText.new(expected)
@@ -8,8 +8,11 @@ module Stationery
8
8
  class Page
9
9
  # Room for the page number of `anchor`, right-aligned in `width` from `x`
10
10
  # on `baseline` (top-left coordinates), drawn in `style`. `link` is an
11
- # [x, y, w, h] area linked to the anchor only once it resolves.
12
- Slot = Data.define(:anchor, :x, :baseline, :width, :style, :link)
11
+ # [x, y, w, h] area linked to the anchor only once it resolves. `tags` are
12
+ # its [link, number] structure elements in a tagged PDF.
13
+ Slot = Data.define(:anchor, :x, :baseline, :width, :style, :link, :tags) do
14
+ def initialize(anchor:, x:, baseline:, width:, style:, link:, tags: nil) = super
15
+ end
13
16
 
14
17
  SIZES = {
15
18
  a3: [841.89, 1190.55], a4: [595.28, 841.89], a5: [419.53, 595.28],
@@ -7,8 +7,9 @@ module Stationery
7
7
  # Paints a document's headers, footers and page templates on every
8
8
  # finished page.
9
9
  class PageTemplates
10
- def initialize(document, book:, resources:, debug: false, regions: nil, warnings: [])
10
+ def initialize(document, book:, resources:, debug: false, regions: nil, warnings: [], tagging: nil)
11
11
  @document = document
12
+ @tagging = tagging
12
13
  @debug = debug
13
14
  @book = book
14
15
  @resources = resources
@@ -38,7 +39,7 @@ module Stationery
38
39
  reserved = @regions.height_of(region, info.number)
39
40
  overflow(info.number, height, reserved)
40
41
  y = slot == :header ? box.y : box.bottom - height
41
- paint(page, root, Rect.new(box.x, y, box.width, height))
42
+ paint(page, root, Rect.new(box.x, y, box.width, height), subtype: slot)
42
43
  end
43
44
  end
44
45
 
@@ -50,9 +51,11 @@ module Stationery
50
51
 
51
52
  def root(info, block) = @document.template_root(info, book: @book, &block)
52
53
 
53
- def paint(page, root, rect, layer = :foreground)
54
+ # Everything a template paints is a pagination artifact in a tagged PDF.
55
+ def paint(page, root, rect, layer = :foreground, subtype: nil)
54
56
  mark = page.content.bytesize
55
- root.paint(Canvas.new(page, @resources, template: true, debug: @debug), rect.x, rect.y, rect.width)
57
+ canvas = Canvas.new(page, @resources, template: true, debug: @debug, tagging: @tagging)
58
+ canvas.artifact(type: :pagination, subtype:) { root.paint(canvas, rect.x, rect.y, rect.width) }
56
59
  page.content.prepend(page.content.slice!(mark..)) if layer == :background
57
60
  end
58
61
  end
@@ -4,7 +4,11 @@ module Stationery
4
4
  module PDF
5
5
  # Writes finished pages, their shared resources and document info as PDF.
6
6
  class Assembler
7
- def initialize(pages:, resources:, info: {}, outline: [], encryption: nil)
7
+ # `tagging:` (a Tagging::Tree) writes the structure tree of a tagged PDF;
8
+ # `lang:` is the document's natural language.
9
+ def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil)
10
+ @tagging = tagging
11
+ @lang = lang
8
12
  @pages = pages
9
13
  @resources = resources
10
14
  @info = info
@@ -14,28 +18,43 @@ module Stationery
14
18
 
15
19
  def render
16
20
  writer = Writer.new(encryption: @encryption)
21
+ @form = Forms::AcroForm.new(writer)
17
22
  tree = writer.reserve
18
23
  refs = @resources.build(writer)
19
24
  kids = @kids = @pages.map { writer.reserve }
25
+ @structure = @tagging && Tagging::Writer.new(@tagging, pages: @pages, refs: kids)
20
26
  @pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
21
27
  writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
22
- root = writer.add(catalog(tree, OutlineWriter.new(writer, @outline, kids).write))
28
+ outlines = OutlineWriter.new(writer, @outline, kids).write
29
+ root = writer.add(catalog(tree, outlines, @form.write).merge(accessibility(writer)))
23
30
  writer.render(root:, info: writer.add(info_dictionary))
24
31
  end
25
32
 
26
33
  private
27
34
 
28
- def catalog(tree, outlines)
35
+ def catalog(tree, outlines, form)
29
36
  catalog = { Type: :Catalog, Pages: tree }
37
+ catalog[:AcroForm] = form if form
30
38
  outlines ? catalog.merge(Outlines: outlines, PageMode: :UseOutlines) : catalog
31
39
  end
32
40
 
41
+ def accessibility(writer)
42
+ entries = @lang ? { Lang: PDF::TextString.new(@lang.to_s) } : {}
43
+ return entries unless @structure
44
+
45
+ entries.merge!(@structure.write(writer))
46
+ @info[:Title] ? entries.merge(ViewerPreferences: { DisplayDocTitle: true }) : entries
47
+ end
48
+
33
49
  def write_page(writer, page, ref, tree, refs)
34
50
  dictionary = {
35
51
  Type: :Page, Parent: tree, MediaBox: [0, 0, *page.size],
36
52
  Contents: writer.add(Stream.new(page.content)), Resources: page_resources(page, refs)
37
53
  }
38
- dictionary[:Annots] = page.annotations.map { |link| writer.add(annotation(link)) } if page.annotations.any?
54
+ if page.annotations.any?
55
+ dictionary[:Annots] = page.annotations.map { |annot| annotation_ref(writer, annot, ref) }
56
+ end
57
+ dictionary.merge!(@structure.page_entries(page)) if @structure
39
58
  writer.set(ref, dictionary)
40
59
  end
41
60
 
@@ -45,6 +64,19 @@ module Stationery
45
64
  end
46
65
  end
47
66
 
67
+ def annotation_ref(writer, annotation, page)
68
+ if annotation[:widget]
69
+ return @form.add(annotation[:widget], annotation[:rect], page) do |widget_ref|
70
+ @structure ? @structure.annotation(annotation, widget_ref) : {}
71
+ end
72
+ end
73
+
74
+ ref = writer.reserve
75
+ dictionary = annotation(annotation)
76
+ dictionary = dictionary.merge(@structure.annotation(annotation, ref)) if @structure
77
+ writer.set(ref, dictionary)
78
+ end
79
+
48
80
  def annotation(link)
49
81
  target = if (dest = link[:dest])
50
82
  { Dest: [@kids.fetch(dest.page), :XYZ, nil, dest.top, nil] }
@@ -31,7 +31,7 @@ module Stationery
31
31
  when Paragraph then paragraph(block.inlines, **@styles[:p])
32
32
  when Heading then heading(block)
33
33
  when List then list(block)
34
- when Blockquote then @component.box(**@styles[:blockquote]) { render(block.blocks) }
34
+ when Blockquote then @component.box(role: :blockquote, **@styles[:blockquote]) { render(block.blocks) }
35
35
  when CodeBlock then code(block)
36
36
  when Rule then @component.rule(**@styles[:hr])
37
37
  when Table then table(block)
@@ -47,7 +47,7 @@ module Stationery
47
47
  style = @styles[:"h#{heading.level}"]
48
48
  size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
49
49
  bookmark = heading_bookmark(heading)
50
- paragraph(heading.inlines, **style.except(:scale), size:, **({ bookmark: } if bookmark))
50
+ paragraph(heading.inlines, **style.except(:scale), size:, heading: heading.level, **({ bookmark: } if bookmark))
51
51
  end
52
52
 
53
53
  def heading_bookmark(heading)
@@ -86,7 +86,7 @@ module Stationery
86
86
  return unless source
87
87
 
88
88
  loaded = Images.load(source)
89
- @component.image(loaded, **dimensions(node, loaded))
89
+ @component.image(loaded, alt: node.alt, **dimensions(node, loaded))
90
90
  rescue UnsupportedImage => e
91
91
  skip(node.src, e.message)
92
92
  end
@@ -11,10 +11,13 @@ module Stationery
11
11
 
12
12
  # Returns the destinations by name; unresolved links are dropped.
13
13
  # Runs before resources are written, so slot digits join the font subset.
14
- def self.resolve(pages, warnings:, resources: nil, book: nil) = new(pages, warnings, resources, book).resolve
14
+ def self.resolve(pages, warnings:, resources: nil, book: nil, tagging: nil)
15
+ new(pages, warnings, resources, book, tagging).resolve
16
+ end
15
17
 
16
- def initialize(pages, warnings, resources, book)
18
+ def initialize(pages, warnings, resources, book, tagging = nil)
17
19
  @pages = pages
20
+ @tagging = tagging
18
21
  @warnings = warnings
19
22
  @resources = resources
20
23
  @book = book
@@ -57,19 +60,22 @@ module Stationery
57
60
  font, face = @book.resolve(slot.style)
58
61
  style = slot.style
59
62
  width = font.width_of(label, style.render_size, letter_spacing: style.letter_spacing)
60
- canvas = Canvas.new(page, @resources)
61
- canvas.link(*slot.link, "##{slot.anchor}") if slot.link
62
- canvas.text(label, x: slot.x + slot.width - width, y: slot.baseline, font:,
63
- size: style.render_size, color: style.color,
64
- letter_spacing: style.letter_spacing, opacity: style.opacity,
65
- synthetic_bold: face.synthetic_bold, synthetic_oblique: face.synthetic_oblique)
63
+ canvas = Canvas.new(page, @resources, tagging: @tagging)
64
+ link, number = slot.tags
65
+ canvas.link(*slot.link, "##{slot.anchor}", tag: link) if slot.link
66
+ canvas.tag(number) do
67
+ canvas.text(label, x: slot.x + slot.width - width, y: slot.baseline, font:,
68
+ size: style.render_size, color: style.color,
69
+ letter_spacing: style.letter_spacing, opacity: style.opacity,
70
+ synthetic_bold: face.synthetic_bold, synthetic_oblique: face.synthetic_oblique)
71
+ end
66
72
  end
67
73
 
68
74
  def link_to(link, index)
69
75
  return link unless link.key?(:dest)
70
76
 
71
77
  destination = @destinations[link[:dest]]
72
- return { rect: link[:rect], dest: destination } if destination
78
+ return link.merge!(dest: destination) if destination
73
79
 
74
80
  @warnings << Warnings::UnresolvedLink.new(name: link[:dest], page: index + 1)
75
81
  nil
@@ -0,0 +1,74 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Tagged (accessible) PDF: a structure tree of standard structure types
5
+ # (ISO 32000-1 §14.8.4) whose leaves point at marked content on the pages.
6
+ module Tagging
7
+ # Grouping roles a box can take, as standard structure types.
8
+ ROLES = { section: :Sect, div: :Div, blockquote: :BlockQuote, note: :Note, caption: :Caption,
9
+ article: :Art, part: :Part }.freeze
10
+
11
+ # One marked-content sequence: the page it is on and its MCID there.
12
+ MarkedContent = Data.define(:page, :mcid)
13
+
14
+ # An annotation (the page's link Hash) that belongs to an element.
15
+ ObjectRef = Data.define(:page, :annotation)
16
+
17
+ def self.role(role)
18
+ ROLES.fetch(role) { raise ArgumentError, "unknown role #{role.inspect} (use #{ROLES.keys.join(", ")})" }
19
+ end
20
+
21
+ def self.heading(level)
22
+ return :P unless level
23
+ raise ArgumentError, "heading: takes 1 to 6, got #{level.inspect}" unless (1..6).cover?(level)
24
+
25
+ :"H#{level}"
26
+ end
27
+
28
+ # A structure element. Layout nodes create one per source node and share
29
+ # it with every fragment a split produces, so content continued on the
30
+ # next page stays one element. It joins the tree where it first paints.
31
+ class Element
32
+ CELLS = %i[TD TH].freeze
33
+
34
+ attr_reader :type, :alt, :kind, :kids, :parent, :attributes
35
+
36
+ # `alt` is a Figure's alternate text; `kind` names what drew it in
37
+ # warnings; `attributes` are { owner => { key => value } } (/A entries).
38
+ def initialize(type, alt: nil, kind: nil, attributes: {})
39
+ @type = type
40
+ @alt = alt
41
+ @kind = kind
42
+ @attributes = attributes.transform_values(&:dup)
43
+ @kids = []
44
+ end
45
+
46
+ def attached? = !@parent.nil?
47
+
48
+ def attach(parent)
49
+ return if @parent
50
+
51
+ @parent = parent
52
+ parent.kids << self
53
+ end
54
+
55
+ # A bounding box in PDF space, kept from the first fragment painted.
56
+ def place(bbox)
57
+ (@attributes[:Layout] ||= {})[:BBox] ||= bbox
58
+ self
59
+ end
60
+
61
+ def bbox = @attributes.dig(:Layout, :BBox)
62
+ def marked_content = @kids.grep(MarkedContent)
63
+ def elements = @kids.grep(Element)
64
+
65
+ # Holds no content, so the writer leaves it out; an empty table cell
66
+ # stays, keeping its row's columns in place.
67
+ def empty?
68
+ return false if CELLS.include?(@type)
69
+
70
+ @kids.none?(MarkedContent) && @kids.none?(ObjectRef) && elements.all?(&:empty?)
71
+ end
72
+ end
73
+ end
74
+ end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Tagging
5
+ # One render's structure: the Document element and, per page, the element
6
+ # each MCID belongs to (what the parent tree maps back from).
7
+ class Tree
8
+ attr_reader :root
9
+
10
+ def initialize
11
+ @root = Element.new(:Document)
12
+ @pages = {}.compare_by_identity
13
+ end
14
+
15
+ # The next MCID on `page`, recorded as marked content of `element`.
16
+ def mark(page, element)
17
+ marked = (@pages[page] ||= [])
18
+ marked << element
19
+ element.kids << MarkedContent.new(page, marked.size - 1)
20
+ marked.size - 1
21
+ end
22
+
23
+ def marked(page) = @pages.fetch(page, [])
24
+
25
+ # Records the accessibility gaps `strict` should catch.
26
+ def audit(pages, warnings, lang:)
27
+ warnings << Warnings::MissingLanguage.new unless lang
28
+ figures(@root).each do |figure|
29
+ next if figure.alt
30
+
31
+ page = pages.index(figure.marked_content.first.page) + 1
32
+ warnings << Warnings::MissingAlt.new(kind: figure.kind, page:)
33
+ end
34
+ end
35
+
36
+ private
37
+
38
+ def figures(element)
39
+ element.elements.flat_map { |kid| kid.type == :Figure ? [kid] : figures(kid) }
40
+ end
41
+ end
42
+ end
43
+ end