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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +79 -0
- data/README.md +98 -4
- data/lib/stationery/canvas/debug.rb +2 -1
- data/lib/stationery/canvas/marking.rb +124 -0
- data/lib/stationery/canvas.rb +24 -8
- data/lib/stationery/document.rb +28 -10
- data/lib/stationery/elements/forms.rb +55 -0
- data/lib/stationery/elements/lists.rb +15 -5
- data/lib/stationery/elements.rb +10 -7
- data/lib/stationery/forms/acro_form.rb +115 -0
- data/lib/stationery/forms/appearance.rb +119 -0
- data/lib/stationery/forms/field.rb +122 -0
- data/lib/stationery/forms/metrics.rb +49 -0
- data/lib/stationery/layout/box.rb +11 -5
- data/lib/stationery/layout/field.rb +51 -0
- data/lib/stationery/layout/flow.rb +17 -9
- data/lib/stationery/layout/image.rb +4 -2
- data/lib/stationery/layout/list_item.rb +15 -6
- data/lib/stationery/layout/node.rb +2 -0
- data/lib/stationery/layout/paginator.rb +3 -2
- data/lib/stationery/layout/svg.rb +7 -3
- data/lib/stationery/layout/table/cell.rb +10 -2
- data/lib/stationery/layout/table.rb +42 -10
- data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
- data/lib/stationery/layout/table_of_contents.rb +1 -1
- data/lib/stationery/layout/text.rb +4 -3
- data/lib/stationery/minitest.rb +2 -0
- data/lib/stationery/page.rb +5 -2
- data/lib/stationery/page_templates.rb +7 -4
- data/lib/stationery/pdf/assembler.rb +36 -4
- data/lib/stationery/rich/renderer.rb +3 -3
- data/lib/stationery/structure.rb +15 -9
- data/lib/stationery/tagging/element.rb +74 -0
- data/lib/stationery/tagging/tree.rb +43 -0
- data/lib/stationery/tagging/writer.rb +81 -0
- data/lib/stationery/testing/inspector.rb +15 -0
- data/lib/stationery/testing/marked_text.rb +60 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/testing/structure_reader.rb +71 -0
- data/lib/stationery/text/paragraph.rb +27 -12
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +10 -0
- 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
|
-
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
32
|
-
|
|
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
|
-
|
|
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
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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
|
|
@@ -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
|
data/lib/stationery/minitest.rb
CHANGED
|
@@ -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)
|
data/lib/stationery/page.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
data/lib/stationery/structure.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
62
|
-
canvas.
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
|
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
|