stationery 0.2.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 (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/README.md +143 -12
  4. data/lib/stationery/builder.rb +2 -1
  5. data/lib/stationery/canvas/debug.rb +2 -1
  6. data/lib/stationery/canvas/marking.rb +124 -0
  7. data/lib/stationery/canvas/text.rb +5 -4
  8. data/lib/stationery/canvas.rb +37 -8
  9. data/lib/stationery/document.rb +35 -10
  10. data/lib/stationery/elements/forms.rb +55 -0
  11. data/lib/stationery/elements/lists.rb +15 -5
  12. data/lib/stationery/elements.rb +10 -7
  13. data/lib/stationery/fonts/font.rb +36 -20
  14. data/lib/stationery/fonts/font_book.rb +3 -0
  15. data/lib/stationery/fonts/glyph_run.rb +4 -3
  16. data/lib/stationery/fonts/gpos.rb +11 -9
  17. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  18. data/lib/stationery/fonts/gsub.rb +65 -0
  19. data/lib/stationery/fonts/ligatures.rb +16 -0
  20. data/lib/stationery/fonts/registry.rb +18 -9
  21. data/lib/stationery/fonts/to_unicode.rb +1 -1
  22. data/lib/stationery/fonts/true_type.rb +36 -12
  23. data/lib/stationery/fonts/woff.rb +52 -0
  24. data/lib/stationery/forms/acro_form.rb +115 -0
  25. data/lib/stationery/forms/appearance.rb +119 -0
  26. data/lib/stationery/forms/field.rb +122 -0
  27. data/lib/stationery/forms/metrics.rb +49 -0
  28. data/lib/stationery/layout/box.rb +34 -11
  29. data/lib/stationery/layout/field.rb +51 -0
  30. data/lib/stationery/layout/flow.rb +17 -9
  31. data/lib/stationery/layout/image.rb +4 -2
  32. data/lib/stationery/layout/list_item.rb +15 -6
  33. data/lib/stationery/layout/node.rb +2 -0
  34. data/lib/stationery/layout/paginator.rb +3 -2
  35. data/lib/stationery/layout/row.rb +1 -1
  36. data/lib/stationery/layout/svg.rb +9 -2
  37. data/lib/stationery/layout/table/cell.rb +10 -2
  38. data/lib/stationery/layout/table.rb +42 -10
  39. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  40. data/lib/stationery/layout/table_of_contents.rb +1 -1
  41. data/lib/stationery/layout/text.rb +6 -4
  42. data/lib/stationery/minitest.rb +2 -0
  43. data/lib/stationery/page.rb +5 -2
  44. data/lib/stationery/page_templates.rb +7 -4
  45. data/lib/stationery/pdf/assembler.rb +38 -5
  46. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  47. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  48. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  49. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  50. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  51. data/lib/stationery/pdf/serializer.rb +17 -9
  52. data/lib/stationery/pdf/writer.rb +24 -5
  53. data/lib/stationery/resources.rb +8 -2
  54. data/lib/stationery/rich/renderer.rb +3 -3
  55. data/lib/stationery/structure.rb +15 -9
  56. data/lib/stationery/svg/bounds.rb +76 -0
  57. data/lib/stationery/svg/document.rb +48 -18
  58. data/lib/stationery/svg/gradient.rb +115 -0
  59. data/lib/stationery/svg/painter.rb +70 -0
  60. data/lib/stationery/svg/parser.rb +24 -4
  61. data/lib/stationery/svg/selector.rb +37 -0
  62. data/lib/stationery/svg/shading.rb +34 -0
  63. data/lib/stationery/svg/style.rb +37 -20
  64. data/lib/stationery/svg/stylesheet.rb +47 -0
  65. data/lib/stationery/svg/text.rb +122 -0
  66. data/lib/stationery/svg/transform.rb +11 -0
  67. data/lib/stationery/tagging/element.rb +74 -0
  68. data/lib/stationery/tagging/tree.rb +43 -0
  69. data/lib/stationery/tagging/writer.rb +81 -0
  70. data/lib/stationery/testing/inspector.rb +15 -0
  71. data/lib/stationery/testing/marked_text.rb +60 -0
  72. data/lib/stationery/testing/matchers.rb +25 -0
  73. data/lib/stationery/testing/structure_reader.rb +71 -0
  74. data/lib/stationery/text/paragraph.rb +28 -12
  75. data/lib/stationery/text/style.rb +4 -2
  76. data/lib/stationery/text/wrapper.rb +2 -1
  77. data/lib/stationery/version.rb +1 -1
  78. data/lib/stationery/warnings.rb +8 -0
  79. data/lib/stationery.rb +26 -0
  80. metadata +29 -1
@@ -8,19 +8,26 @@ module Stationery
8
8
  # `:avoid` never). A fragment's cut sides are `open`: `decoration: :slice`
9
9
  # drops their padding and border, `:clone` keeps the padding. A
10
10
  # fixed-height box can truncate or shrink text that does not fit
11
- # (`overflow:`) and never splits.
11
+ # (`overflow:`) and never splits. `min_height:` is a floor that does split:
12
+ # the first fragment keeps as much of it as the page holds and the next
13
+ # one carries only what is left of it.
12
14
  class Box < Node
13
15
  BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
14
16
 
15
17
  attr_reader :content, :width_spec
16
18
 
17
19
  def initialize(content = Flow.new, padding: 0, background: nil, border: nil, radius: 0, width: nil,
18
- height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
19
- open: [], decoration: :slice)
20
+ height: nil, min_height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
21
+ open: [], decoration: :slice, role: nil)
22
+ raise ArgumentError, "pass height: or min_height:, not both" if height && min_height
23
+
20
24
  super()
25
+ @tag = role && Tagging::Element.new(Tagging.role(role))
26
+ @min_height = min_height
21
27
  @open = open
22
28
  @decoration = decoration
23
29
  @link = link
30
+ @link_tag = link && Tagging::Element.new(:Link)
24
31
  @outset = Geometry.box(outset)
25
32
  @content = content
26
33
  @padding = Geometry.box(padding)
@@ -58,38 +65,54 @@ module Stationery
58
65
  available = height - vertical(cut)
59
66
  head, tail = @content.split(inner_width(width), available, fresh:)
60
67
  return [nil, self] unless head
61
- return [with_content(head), nil] unless tail
68
+ return [with_content(head), nil] unless tail || @min_height.to_f > height + EPSILON
62
69
 
63
- [with_content(head, cut).tap { |part| part.keep_with_next = nil }, with_content(tail, @open | [:top])]
70
+ fragments(width, height, head, tail || Flow.new, cut)
64
71
  end
65
72
 
73
+ # An empty continuation: open at the top, without a floor.
74
+ def continued = fragment(Flow.new, @open | [:top], nil)
75
+
66
76
  # A copy holding other content and open sides, every option kept.
67
77
  def with_content(content, open = @open) = dup.reopen(content, open)
68
78
  def with_open(*sides) = with_content(@content, @open | sides)
69
79
 
70
80
  def measure(width)
71
- @height || memoize_by_width(width) { @content.measure(inner_width(width)) + vertical }
81
+ @height || memoize_by_width(width) { [@content.measure(inner_width(width)) + vertical, @min_height || 0].max }
72
82
  end
73
83
 
74
84
  def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
75
85
  height ||= measure(width)
76
- paint_background(canvas, x, y, width, height)
77
- paint_border(canvas, x, y, width, height)
78
- paint_content(canvas, x, y, width, height, valign || @valign)
79
- canvas.link(x, y, width, height, @link) if @link
86
+ canvas.structure(@tag) do
87
+ canvas.structure(@link_tag) do
88
+ paint_background(canvas, x, y, width, height)
89
+ paint_border(canvas, x, y, width, height)
90
+ paint_content(canvas, x, y, width, height, valign || @valign)
91
+ end
92
+ end
93
+ canvas.link(x, y, width, height, @link, tag: @link_tag) if @link
80
94
  paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
81
95
  end
82
96
 
83
97
  protected
84
98
 
85
- def reopen(content, open)
99
+ def reopen(content, open, min_height = @min_height)
86
100
  @content = content
87
101
  @open = open
102
+ @min_height = min_height
88
103
  self
89
104
  end
90
105
 
91
106
  private
92
107
 
108
+ def fragment(content, open, min_height) = dup.reopen(content, open, min_height)
109
+
110
+ def fragments(width, height, head, tail, cut)
111
+ first = fragment(head, cut, @min_height && [@min_height, height].min).tap { |part| part.keep_with_next = nil }
112
+ rest = @min_height.to_f - first.measure(width)
113
+ [first, fragment(tail, @open | [:top], rest.positive? ? rest : nil)]
114
+ end
115
+
93
116
  def insets(open = @open)
94
117
  border = @border ? @border[:width] : 0
95
118
  sides = @border ? @border[:sides] - open : []
@@ -0,0 +1,51 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Layout
5
+ # An interactive form field: a widget `height` tall that fills the width
6
+ # (`width: :full`) or takes `width` points. Its look comes from the
7
+ # widget's own appearance, so it paints nothing into the page but an
8
+ # optional `label` node to the right of the widget.
9
+ class Field < Node
10
+ LABEL_GAP = 6
11
+
12
+ def initialize(field, height:, width: :full, label: nil)
13
+ super()
14
+ @field = field
15
+ @height = height
16
+ @width = width
17
+ @label = label
18
+ @tag = Tagging::Element.new(:Form, kind: :field)
19
+ end
20
+
21
+ def measure(width)
22
+ @label ? [@height, @label.measure(label_width(width))].max : @height
23
+ end
24
+
25
+ def natural_width = @label ? label_offset + @label.natural_width : fixed_points || 0
26
+ def min_width = @label ? label_offset + @label.min_width : fixed_points || 0
27
+
28
+ def fixed_width(available)
29
+ return [natural_width, available].min if @label
30
+
31
+ fixed_points && [fixed_points, available].min
32
+ end
33
+
34
+ def paint(canvas, x, y, width, _height = nil, **)
35
+ own = @label ? @width : width
36
+ total = measure(width)
37
+ top = y + ((total - @height) / 2.0)
38
+ canvas.widget(@field, x, top, own, @height, tag: @tag)
39
+ @label&.paint(canvas, x + label_offset, y + ((total - @label.measure(label_width(width))) / 2.0),
40
+ label_width(width))
41
+ canvas.debug_rect(x, top, own, @height, :field) if canvas.debug?
42
+ end
43
+
44
+ private
45
+
46
+ def fixed_points = @width.is_a?(Numeric) ? @width : nil
47
+ def label_offset = @width + LABEL_GAP
48
+ def label_width(width) = [width - label_offset, 0].max
49
+ end
50
+ end
51
+ end
@@ -10,11 +10,13 @@ module Stationery
10
10
  class Flow < Node
11
11
  attr_reader :children, :gap, :align
12
12
 
13
- def initialize(children = [], gap: 0, align: :left)
13
+ # `tag` groups the children in a tagged PDF (a list's L).
14
+ def initialize(children = [], gap: 0, align: :left, tag: nil)
14
15
  super()
15
16
  @children = children
16
17
  @gap = gap
17
18
  @align = align
19
+ @tag = tag
18
20
  end
19
21
 
20
22
  def <<(child)
@@ -35,6 +37,20 @@ module Stationery
35
37
  end
36
38
 
37
39
  def paint(canvas, x, y, width, _height = nil, **)
40
+ canvas.structure(@tag) { paint_children(canvas, x, y, width) }
41
+ end
42
+
43
+ def split(width, height, fresh: false)
44
+ Splitter.new(self, width, height, fresh).call
45
+ end
46
+
47
+ def with_children(children)
48
+ self.class.new(children, gap: @gap, align: @align, tag: @tag)
49
+ end
50
+
51
+ private
52
+
53
+ def paint_children(canvas, x, y, width)
38
54
  cursor = y
39
55
  @children.reject(&:page_break?).each_with_index do |child, index|
40
56
  cursor += @gap unless index.zero?
@@ -46,14 +62,6 @@ module Stationery
46
62
  cursor += height
47
63
  end
48
64
  end
49
-
50
- def split(width, height, fresh: false)
51
- Splitter.new(self, width, height, fresh).call
52
- end
53
-
54
- def with_children(children)
55
- self.class.new(children, gap: @gap, align: @align)
56
- end
57
65
  end
58
66
 
59
67
  # One pass of Flow#split, kept apart so the rules read top to bottom.
@@ -6,8 +6,10 @@ module Stationery
6
6
  # preserved), never wider than the space it is given. One pixel is one
7
7
  # point when no size is given.
8
8
  class Image < Node
9
- def initialize(source, width: nil, height: nil, fit: nil, opacity: nil)
9
+ # `alt:` describes the image in a tagged PDF; `alt: false` marks it decorative.
10
+ def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil)
10
11
  super()
12
+ @tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
11
13
  @image = source.respond_to?(:build) ? source : Images.load(source)
12
14
  @width = width
13
15
  @height = height
@@ -29,7 +31,7 @@ module Stationery
29
31
 
30
32
  def paint(canvas, x, y, width, _height = nil, **)
31
33
  w, h = size(width)
32
- canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity)
34
+ canvas.tag(@tag, bbox: [x, y, w, h]) { canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity) }
33
35
  canvas.debug_rect(x, y, w, h, :image)
34
36
  end
35
37
 
@@ -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
@@ -36,7 +36,7 @@ module Stationery
36
36
  return [nil, self] if parts.any? { |head, _| head.nil? }
37
37
 
38
38
  heads = parts.map { |head, _| head.with_open(:bottom) }
39
- tails = parts.zip(@columns).map { |(_, tail), column| tail || column.with_content(Flow.new).with_open(:top) }
39
+ tails = parts.zip(@columns).map { |(_, tail), column| tail || column.continued }
40
40
  [with_columns(heads).tap { |row| row.keep_with_next = nil }, with_columns(tails)]
41
41
  end
42
42
 
@@ -5,9 +5,13 @@ module Stationery
5
5
  # A vector drawing at a fixed size: `width:` and/or `height:` (the other
6
6
  # follows the viewBox's aspect ratio), never wider than the space given.
7
7
  class Svg < Node
8
- def initialize(document, width: nil, height: nil, color: "#000000")
8
+ # `context` gives SVG text its font book and default family.
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)
9
11
  super()
12
+ @tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :svg)
10
13
  @document = document
14
+ @context = context
11
15
  @width = width
12
16
  @height = height
13
17
  @color = color
@@ -26,7 +30,10 @@ module Stationery
26
30
 
27
31
  def paint(canvas, x, y, width, _height = nil, **)
28
32
  w, h = size(width)
29
- @document.draw(canvas, x:, y:, width: w, height: h, color: @color)
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
30
37
  canvas.debug_rect(x, y, w, h, :image)
31
38
  end
32
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, **)
@@ -42,7 +43,8 @@ module Stationery
42
43
  style = run.style
43
44
  font = @context.book.resolve(style).first
44
45
  run.text.split(/[ \t\n]+/).map do |word|
45
- font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning)
46
+ font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
47
+ ligatures: style.ligatures)
46
48
  end
47
49
  end.max || 0
48
50
  end
@@ -56,7 +58,7 @@ module Stationery
56
58
  end
57
59
 
58
60
  def from(paragraph)
59
- 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)
60
62
  end
61
63
  end
62
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