stationery 0.3.0 → 0.5.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 +93 -0
- data/README.md +136 -14
- data/lib/stationery/builder.rb +12 -0
- data/lib/stationery/canvas/debug.rb +2 -1
- data/lib/stationery/canvas/marking.rb +124 -0
- data/lib/stationery/canvas.rb +50 -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/rich.rb +7 -5
- data/lib/stationery/elements.rb +34 -7
- data/lib/stationery/fonts/fallback.rb +6 -4
- data/lib/stationery/fonts/font.rb +51 -8
- 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 +64 -12
- data/lib/stationery/layout/field.rb +51 -0
- data/lib/stationery/layout/flow.rb +17 -9
- data/lib/stationery/layout/image.rb +35 -4
- 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/stack.rb +81 -0
- 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 +3 -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/preview.rb +23 -2
- data/lib/stationery/rails/previews_controller.rb +2 -7
- data/lib/stationery/rich/renderer/inlines.rb +10 -2
- data/lib/stationery/rich/renderer/links.rb +28 -0
- data/lib/stationery/rich/renderer.rb +9 -6
- data/lib/stationery/rich/styles.rb +2 -0
- data/lib/stationery/rspec.rb +4 -0
- 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 +22 -1
- data/lib/stationery/testing/marked_text.rb +60 -0
- data/lib/stationery/testing/matchers.rb +35 -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 +12 -0
- data/lib/stationery.rb +11 -0
- metadata +15 -1
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Layout
|
|
5
|
+
# A base flow with layers painted over it. The base sets the height and
|
|
6
|
+
# widths; each Layer is placed relative to the stack's rectangle and takes
|
|
7
|
+
# no space, so it can overhang the edges. A stack never splits: it moves
|
|
8
|
+
# to the next page whole (and is reported as an Overflow when taller than
|
|
9
|
+
# a page).
|
|
10
|
+
class Stack < Node
|
|
11
|
+
attr_reader :base, :layers
|
|
12
|
+
|
|
13
|
+
def initialize(base, layers)
|
|
14
|
+
super()
|
|
15
|
+
@base = base
|
|
16
|
+
@layers = layers
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def measure(width) = @base.measure(width)
|
|
20
|
+
def natural_width = @base.natural_width
|
|
21
|
+
def min_width = @base.min_width
|
|
22
|
+
def fixed_width(available) = @base.fixed_width(available)
|
|
23
|
+
def prefer_whole? = true
|
|
24
|
+
|
|
25
|
+
def paint(canvas, x, y, width, height = nil, **)
|
|
26
|
+
height ||= measure(width)
|
|
27
|
+
@base.paint(canvas, x, y, width, height)
|
|
28
|
+
@layers.each { |layer| layer.paint(canvas, x, y, width, height) }
|
|
29
|
+
canvas.debug_rect(x, y, width, height, :stack)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# A box placed over a Stack by CSS-like insets. `top:`, `right:`,
|
|
34
|
+
# `bottom:`, `left:`, `width:` and `height:` are points, or fractions of
|
|
35
|
+
# the stack's width (horizontal ones) or height (vertical ones) when given
|
|
36
|
+
# as a Rational or a Float between -1 and 1; negative points overhang.
|
|
37
|
+
# Without `left:`/`top:` the layer sits at the stack's left/top edge;
|
|
38
|
+
# without `width:` it takes its content's natural width, at most the
|
|
39
|
+
# stack's, and without `height:` its content's height.
|
|
40
|
+
class Layer
|
|
41
|
+
def initialize(box, top: nil, right: nil, bottom: nil, left: nil, width: nil, height: nil)
|
|
42
|
+
@box = box
|
|
43
|
+
@top = top
|
|
44
|
+
@right = right
|
|
45
|
+
@bottom = bottom
|
|
46
|
+
@left = left
|
|
47
|
+
@width = width
|
|
48
|
+
@height = height
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def paint(canvas, x, y, width, height)
|
|
52
|
+
own = layer_width(width)
|
|
53
|
+
tall = @height ? resolve(@height, height) : @box.measure(own)
|
|
54
|
+
@box.paint(canvas, x + offset(@left, @right, width, own), y + offset(@top, @bottom, height, tall), own, tall)
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
private
|
|
58
|
+
|
|
59
|
+
# Where the layer's near edge sits along one axis of the stack: `start`
|
|
60
|
+
# points from the start edge, else `finish` points before the end edge.
|
|
61
|
+
def offset(start, finish, extent, own)
|
|
62
|
+
return resolve(start, extent) if start
|
|
63
|
+
return extent - resolve(finish, extent) - own if finish
|
|
64
|
+
|
|
65
|
+
0
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def layer_width(width)
|
|
69
|
+
return [@box.natural_width, width].min unless @width
|
|
70
|
+
|
|
71
|
+
resolve(@width, width)
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def resolve(value, extent)
|
|
75
|
+
fraction?(value) ? extent * value : value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def fraction?(value) = value.is_a?(Rational) || (value.is_a?(Float) && value.abs <= 1)
|
|
79
|
+
end
|
|
80
|
+
end
|
|
81
|
+
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
|
@@ -16,7 +16,10 @@ module Stationery
|
|
|
16
16
|
def assert_pdf_link(subject, url, msg = nil) = assert_pdf(Matchers::HaveLink.new(url), subject, msg)
|
|
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
|
+
def assert_pdf_language(subject, lang, msg = nil) = assert_pdf(Matchers::HaveLanguage.new(lang), subject, msg)
|
|
19
20
|
def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
|
|
21
|
+
def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
|
|
22
|
+
def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
|
|
20
23
|
|
|
21
24
|
def refute_pdf_text(subject, expected, msg = nil)
|
|
22
25
|
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] }
|
data/lib/stationery/preview.rb
CHANGED
|
@@ -4,15 +4,19 @@ require "stationery"
|
|
|
4
4
|
|
|
5
5
|
module Stationery
|
|
6
6
|
# Browsable sample documents, like ActionMailer previews. Each public method
|
|
7
|
-
# returns one document
|
|
7
|
+
# returns one document. `around_render` wraps building and rendering it,
|
|
8
|
+
# for anything that must be in effect while the document paints:
|
|
8
9
|
#
|
|
9
10
|
# # spec/pdfs/previews/invoice_pdf_preview.rb
|
|
10
11
|
# class InvoicePdfPreview < Stationery::Preview
|
|
11
12
|
# def paid = InvoicePdf.new(Invoice.first)
|
|
12
13
|
# def overdue(params) = InvoicePdf.new(Invoice.find(params.fetch("id")))
|
|
14
|
+
#
|
|
15
|
+
# def around_render(_name, params) = I18n.with_locale(params.fetch("locale", I18n.default_locale)) { yield }
|
|
13
16
|
# end
|
|
14
17
|
class Preview
|
|
15
18
|
REGISTRY_LOCK = Mutex.new
|
|
19
|
+
HOOKS = %w[around_render].freeze
|
|
16
20
|
|
|
17
21
|
class << self
|
|
18
22
|
def inherited(subclass)
|
|
@@ -40,7 +44,7 @@ module Stationery
|
|
|
40
44
|
end
|
|
41
45
|
|
|
42
46
|
def preview_name = underscore(name.delete_suffix("Preview"))
|
|
43
|
-
def pdfs = public_instance_methods(false).sort.map(&:to_s)
|
|
47
|
+
def pdfs = public_instance_methods(false).sort.map(&:to_s) - HOOKS
|
|
44
48
|
|
|
45
49
|
protected
|
|
46
50
|
|
|
@@ -53,6 +57,19 @@ module Stationery
|
|
|
53
57
|
end
|
|
54
58
|
end
|
|
55
59
|
|
|
60
|
+
# The PDF bytes of one preview, built and rendered inside around_render.
|
|
61
|
+
# `debug: true` reaches to_pdf when the document accepts it.
|
|
62
|
+
def to_pdf(name, params = {}, debug: false)
|
|
63
|
+
around_render(name, params) do
|
|
64
|
+
document = render(name, params)
|
|
65
|
+
document.to_pdf(**(debug && accepts_debug?(document) ? { debug: true } : {}))
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Override to wrap the preview: set an I18n locale, Current attributes,
|
|
70
|
+
# a time zone. Both the preview method and to_pdf run inside the block.
|
|
71
|
+
def around_render(_name, _params) = yield
|
|
72
|
+
|
|
56
73
|
def render(name, params = {})
|
|
57
74
|
method = public_method(name)
|
|
58
75
|
document = method.arity.zero? ? method.call : method.call(params)
|
|
@@ -60,5 +77,9 @@ module Stationery
|
|
|
60
77
|
|
|
61
78
|
raise Error, "#{self.class}##{name} must return a Stationery::Document (got #{document.class})"
|
|
62
79
|
end
|
|
80
|
+
|
|
81
|
+
private
|
|
82
|
+
|
|
83
|
+
def accepts_debug?(document) = document.method(:to_pdf).parameters.include?(%i[key debug])
|
|
63
84
|
end
|
|
64
85
|
end
|
|
@@ -19,8 +19,8 @@ module Stationery
|
|
|
19
19
|
klass, pdf = Preview.find(params[:path])
|
|
20
20
|
return head(:not_found) unless klass
|
|
21
21
|
|
|
22
|
-
|
|
23
|
-
send_data
|
|
22
|
+
pdf = klass.new.to_pdf(pdf, request.query_parameters.except("debug"), debug: params[:debug].present?)
|
|
23
|
+
send_data pdf, type: "application/pdf", disposition: "inline"
|
|
24
24
|
end
|
|
25
25
|
|
|
26
26
|
private
|
|
@@ -35,11 +35,6 @@ module Stationery
|
|
|
35
35
|
Preview.load(Railtie.preview_paths(::Rails.root, config.preview_paths))
|
|
36
36
|
end
|
|
37
37
|
|
|
38
|
-
def debug_option(document)
|
|
39
|
-
debug = params[:debug].present? && document.method(:to_pdf).parameters.include?(%i[key debug])
|
|
40
|
-
debug ? { debug: true } : {}
|
|
41
|
-
end
|
|
42
|
-
|
|
43
38
|
def page(paths)
|
|
44
39
|
items = paths.map do |path|
|
|
45
40
|
href = ERB::Util.html_escape("#{request.path.chomp("/")}/#{path}")
|
|
@@ -19,16 +19,24 @@ module Stationery
|
|
|
19
19
|
bold: [[:b]], italic: [[:i]], underline: [[:u]], strike: [[:strikethrough]]
|
|
20
20
|
}.freeze
|
|
21
21
|
|
|
22
|
-
def initialize(runs, styles)
|
|
22
|
+
def initialize(runs, styles, links = Links.new(:all, nil))
|
|
23
23
|
@runs = runs
|
|
24
24
|
@styles = styles
|
|
25
|
+
@links = links
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def write(inlines) = inlines.grep(Inline).each { |inline| inline.break? ? @runs.br : write_one(inline) }
|
|
28
29
|
|
|
29
30
|
private
|
|
30
31
|
|
|
31
|
-
def write_one(inline) = nest(inline.
|
|
32
|
+
def write_one(inline) = nest(marks_of(inline).flat_map { |mark, value| steps(mark, value) }, inline.text)
|
|
33
|
+
|
|
34
|
+
def marks_of(inline)
|
|
35
|
+
marks = inline.marks
|
|
36
|
+
return marks unless marks.key?(:link) && !@links.allowed?(marks[:link])
|
|
37
|
+
|
|
38
|
+
marks.except(:link)
|
|
39
|
+
end
|
|
32
40
|
|
|
33
41
|
def nest(steps, text)
|
|
34
42
|
return @runs.plain(text) if steps.empty?
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Rich
|
|
5
|
+
class Renderer
|
|
6
|
+
# Which hrefs become link annotations: `#anchor` always, otherwise only
|
|
7
|
+
# the allowed URL schemes (case-insensitive). A dropped href keeps its
|
|
8
|
+
# text and is reported once as a DroppedLink warning. `:all` keeps every
|
|
9
|
+
# href, for trusted sources.
|
|
10
|
+
class Links
|
|
11
|
+
SCHEME = /\A([a-z][a-z0-9+.-]*):/i
|
|
12
|
+
|
|
13
|
+
def initialize(schemes, warnings)
|
|
14
|
+
@schemes = schemes == :all ? :all : Array(schemes).map { |scheme| scheme.to_s.downcase }
|
|
15
|
+
@warnings = warnings
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def allowed?(href)
|
|
19
|
+
return true if @schemes == :all || href.start_with?("#")
|
|
20
|
+
return true if @schemes.include?(href[SCHEME, 1].to_s.downcase)
|
|
21
|
+
|
|
22
|
+
@warnings << Warnings::DroppedLink.new(href:)
|
|
23
|
+
false
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
require_relative "nodes"
|
|
4
4
|
require_relative "styles"
|
|
5
5
|
require_relative "renderer/inlines"
|
|
6
|
+
require_relative "renderer/links"
|
|
6
7
|
|
|
7
8
|
module Stationery
|
|
8
9
|
module Rich
|
|
@@ -11,8 +12,9 @@ module Stationery
|
|
|
11
12
|
# from files under `base_path:`; remote URLs are never fetched.
|
|
12
13
|
class Renderer
|
|
13
14
|
REMOTE = /\A[a-z][a-z0-9+.-]*:/i
|
|
15
|
+
LINK_SCHEMES = %w[http https mailto tel].freeze
|
|
14
16
|
|
|
15
|
-
def initialize(component, builder, gap:, styles:, images:, base_path:, bookmarks: false)
|
|
17
|
+
def initialize(component, builder, gap:, styles:, images:, base_path:, bookmarks: false, links: nil)
|
|
16
18
|
@component = component
|
|
17
19
|
@builder = builder
|
|
18
20
|
@gap = gap
|
|
@@ -20,6 +22,7 @@ module Stationery
|
|
|
20
22
|
@images = images
|
|
21
23
|
@base_path = base_path && File.expand_path(base_path.to_s)
|
|
22
24
|
@bookmarks = bookmarks
|
|
25
|
+
@links = Links.new(links || LINK_SCHEMES, builder.warnings)
|
|
23
26
|
end
|
|
24
27
|
|
|
25
28
|
def render(blocks) = @component.group(gap: @gap) { blocks.each { |block| block(block) } }
|
|
@@ -31,7 +34,7 @@ module Stationery
|
|
|
31
34
|
when Paragraph then paragraph(block.inlines, **@styles[:p])
|
|
32
35
|
when Heading then heading(block)
|
|
33
36
|
when List then list(block)
|
|
34
|
-
when Blockquote then @component.box(**@styles[:blockquote]) { render(block.blocks) }
|
|
37
|
+
when Blockquote then @component.box(role: :blockquote, **@styles[:blockquote]) { render(block.blocks) }
|
|
35
38
|
when CodeBlock then code(block)
|
|
36
39
|
when Rule then @component.rule(**@styles[:hr])
|
|
37
40
|
when Table then table(block)
|
|
@@ -40,14 +43,14 @@ module Stationery
|
|
|
40
43
|
end
|
|
41
44
|
|
|
42
45
|
def paragraph(inlines, **)
|
|
43
|
-
@component.text(**) { |runs| Inlines.new(runs, @styles).write(inlines) }
|
|
46
|
+
@component.text(**) { |runs| Inlines.new(runs, @styles, @links).write(inlines) }
|
|
44
47
|
end
|
|
45
48
|
|
|
46
49
|
def heading(heading)
|
|
47
50
|
style = @styles[:"h#{heading.level}"]
|
|
48
51
|
size = style[:size] || (@builder.style.size * style.fetch(:scale, 1))
|
|
49
52
|
bookmark = heading_bookmark(heading)
|
|
50
|
-
paragraph(heading.inlines, **style.except(:scale), size:, **({ bookmark: } if bookmark))
|
|
53
|
+
paragraph(heading.inlines, **style.except(:scale), size:, heading: heading.level, **({ bookmark: } if bookmark))
|
|
51
54
|
end
|
|
52
55
|
|
|
53
56
|
def heading_bookmark(heading)
|
|
@@ -57,7 +60,7 @@ module Stationery
|
|
|
57
60
|
end
|
|
58
61
|
|
|
59
62
|
def list(list)
|
|
60
|
-
options = list.ordered ? { start: list.start || 1 } :
|
|
63
|
+
options = list.ordered ? { **@styles[:ol], start: list.start || 1 } : @styles[:ul]
|
|
61
64
|
@component.public_send(list.ordered ? :ol : :ul, **options) do
|
|
62
65
|
list.items.each do |blocks|
|
|
63
66
|
@component.li { @component.text_style(**@styles[:li]) { render(blocks) } }
|
|
@@ -86,7 +89,7 @@ module Stationery
|
|
|
86
89
|
return unless source
|
|
87
90
|
|
|
88
91
|
loaded = Images.load(source)
|
|
89
|
-
@component.image(loaded, **dimensions(node, loaded))
|
|
92
|
+
@component.image(loaded, alt: node.alt, **dimensions(node, loaded))
|
|
90
93
|
rescue UnsupportedImage => e
|
|
91
94
|
skip(node.src, e.message)
|
|
92
95
|
end
|