stationery 0.4.0 → 0.6.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 +21 -0
- data/README.md +75 -12
- data/lib/stationery/builder.rb +12 -0
- data/lib/stationery/canvas/debug.rb +1 -1
- data/lib/stationery/canvas.rb +26 -0
- data/lib/stationery/document.rb +39 -19
- data/lib/stationery/elements/rich.rb +7 -5
- data/lib/stationery/elements.rb +25 -1
- data/lib/stationery/fonts/fallback.rb +6 -4
- data/lib/stationery/fonts/font.rb +56 -9
- data/lib/stationery/fonts/font_book.rb +7 -2
- data/lib/stationery/fonts/registry.rb +7 -1
- data/lib/stationery/html/document.rb +5 -1
- data/lib/stationery/images/images.rb +9 -1
- data/lib/stationery/instrumentation.rb +41 -0
- data/lib/stationery/layout/box.rb +59 -13
- data/lib/stationery/layout/image.rb +33 -4
- data/lib/stationery/layout/stack.rb +81 -0
- data/lib/stationery/markdown/document.rb +4 -2
- data/lib/stationery/minitest.rb +1 -0
- data/lib/stationery/preview.rb +23 -2
- data/lib/stationery/rails/previews_controller.rb +2 -7
- data/lib/stationery/railtie.rb +5 -0
- data/lib/stationery/rich/renderer/inlines.rb +10 -2
- data/lib/stationery/rich/renderer/links.rb +28 -0
- data/lib/stationery/rich/renderer.rb +6 -3
- data/lib/stationery/rich/styles.rb +2 -0
- data/lib/stationery/rspec.rb +4 -0
- data/lib/stationery/testing/inspector.rb +8 -2
- data/lib/stationery/testing/matchers.rb +10 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +4 -0
- data/lib/stationery.rb +2 -0
- metadata +4 -1
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Events a render emits, named `<phase>.stationery` so tools that group by
|
|
5
|
+
# the last segment (AppSignal, Skylight, Rails log subscribers) file them
|
|
6
|
+
# together. With ActiveSupport::Notifications loaded they go there and
|
|
7
|
+
# subscribers see the same payload the block filled in; otherwise a Null
|
|
8
|
+
# instrumenter yields and costs one method call.
|
|
9
|
+
module Instrumentation
|
|
10
|
+
class Null
|
|
11
|
+
def instrument(_name, payload = {})
|
|
12
|
+
yield payload
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
class << self
|
|
17
|
+
# Any object answering `instrument(name, payload) { |payload| }`; set
|
|
18
|
+
# once at boot, so a plain module attribute is enough.
|
|
19
|
+
attr_writer :instrumenter # rubocop:disable ThreadSafety/ClassAndModuleAttributes
|
|
20
|
+
|
|
21
|
+
def instrumenter
|
|
22
|
+
@instrumenter ||= defined?(::ActiveSupport::Notifications) ? ::ActiveSupport::Notifications : Null.new # rubocop:disable ThreadSafety/ClassInstanceVariable
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def instrument(name, payload = {}, &)
|
|
26
|
+
instrumenter.instrument(name, payload, &)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
class << self
|
|
32
|
+
def instrumenter = Instrumentation.instrumenter
|
|
33
|
+
|
|
34
|
+
def instrumenter=(value)
|
|
35
|
+
Instrumentation.instrumenter = value
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
# Runs the block as one `name` event; the block may add to `payload`.
|
|
39
|
+
def instrument(name, payload = {}, &) = Instrumentation.instrument(name, payload, &)
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -10,15 +10,20 @@ module Stationery
|
|
|
10
10
|
# fixed-height box can truncate or shrink text that does not fit
|
|
11
11
|
# (`overflow:`) and never splits. `min_height:` is a floor that does split:
|
|
12
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.
|
|
13
|
+
# one carries only what is left of it. `rotate:` turns the painted box
|
|
14
|
+
# around its centre without moving its layout rectangle (a rotated box
|
|
15
|
+
# never splits); `shadow:` paints a soft drop shadow under it, taking no
|
|
16
|
+
# space; `overflow: :hidden` clips the content to the rounded outline.
|
|
14
17
|
class Box < Node
|
|
15
18
|
BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
|
|
19
|
+
SHADOW = { offset: [0, 4], blur: 8, color: "#000000", opacity: 0.15 }.freeze
|
|
20
|
+
SHADOW_LAYERS = 8
|
|
16
21
|
|
|
17
22
|
attr_reader :content, :width_spec
|
|
18
23
|
|
|
19
24
|
def initialize(content = Flow.new, padding: 0, background: nil, border: nil, radius: 0, width: nil,
|
|
20
25
|
height: nil, min_height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
|
|
21
|
-
open: [], decoration: :slice, role: nil)
|
|
26
|
+
open: [], decoration: :slice, role: nil, rotate: 0, shadow: nil)
|
|
22
27
|
raise ArgumentError, "pass height: or min_height:, not both" if height && min_height
|
|
23
28
|
|
|
24
29
|
super()
|
|
@@ -39,6 +44,8 @@ module Stationery
|
|
|
39
44
|
@overflow = overflow
|
|
40
45
|
@valign = valign
|
|
41
46
|
@opacity = opacity
|
|
47
|
+
@rotate = rotate
|
|
48
|
+
@shadow = shadow && SHADOW.merge(shadow == true ? {} : shadow)
|
|
42
49
|
end
|
|
43
50
|
|
|
44
51
|
def natural_width
|
|
@@ -55,7 +62,10 @@ module Stationery
|
|
|
55
62
|
end
|
|
56
63
|
end
|
|
57
64
|
|
|
58
|
-
def splittable?
|
|
65
|
+
def splittable?
|
|
66
|
+
@height.nil? && @rotate.zero? && %i[visible hidden].include?(@overflow) && @content.splittable?
|
|
67
|
+
end
|
|
68
|
+
|
|
59
69
|
def prefer_whole? = break_inside.nil? && splittable?
|
|
60
70
|
|
|
61
71
|
def split(width, height, fresh: false)
|
|
@@ -83,15 +93,18 @@ module Stationery
|
|
|
83
93
|
|
|
84
94
|
def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
|
|
85
95
|
height ||= measure(width)
|
|
86
|
-
canvas.
|
|
87
|
-
canvas.structure(@
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
96
|
+
canvas.rotate(@rotate, around: [x + (width / 2.0), y + (height / 2.0)]) do
|
|
97
|
+
canvas.structure(@tag) do
|
|
98
|
+
canvas.structure(@link_tag) do
|
|
99
|
+
paint_shadow(canvas, x, y, width, height)
|
|
100
|
+
paint_background(canvas, x, y, width, height)
|
|
101
|
+
paint_border(canvas, x, y, width, height)
|
|
102
|
+
paint_content(canvas, x, y, width, height, valign || @valign)
|
|
103
|
+
end
|
|
91
104
|
end
|
|
105
|
+
paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
|
|
92
106
|
end
|
|
93
107
|
canvas.link(x, y, width, height, @link, tag: @link_tag) if @link
|
|
94
|
-
paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
|
|
95
108
|
end
|
|
96
109
|
|
|
97
110
|
protected
|
|
@@ -125,6 +138,27 @@ module Stationery
|
|
|
125
138
|
def vertical(open = @open) = insets(open).values_at(0, 2).sum
|
|
126
139
|
def inner_width(width) = [width - horizontal, 0].max
|
|
127
140
|
|
|
141
|
+
# Stacked rounded rectangles under the box, the outermost grown by the
|
|
142
|
+
# blur and each fainter, so the centre sums to the opacity and the edge
|
|
143
|
+
# fades. An artifact: decoration, not content. Skipped on a fragment
|
|
144
|
+
# with cut sides, whose corners are square anyway.
|
|
145
|
+
def paint_shadow(canvas, x, y, width, height)
|
|
146
|
+
return unless @shadow && @open.empty?
|
|
147
|
+
|
|
148
|
+
blur = @shadow[:blur].to_f
|
|
149
|
+
layers = blur.positive? ? [(blur / 2).ceil, SHADOW_LAYERS].min : 1
|
|
150
|
+
dx, dy = @shadow[:offset]
|
|
151
|
+
canvas.artifact do
|
|
152
|
+
layers.downto(1) do |layer|
|
|
153
|
+
grow = blur * layer / layers
|
|
154
|
+
canvas.rounded_rect(x + dx - grow, y + dy - grow, width + (2 * grow), height + (2 * grow),
|
|
155
|
+
radius: grown_radius(grow), fill: @shadow[:color], opacity: @shadow[:opacity] / layers)
|
|
156
|
+
end
|
|
157
|
+
end
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def grown_radius(grow) = @radius.is_a?(Array) ? @radius.map { |r| r + grow } : @radius + grow
|
|
161
|
+
|
|
128
162
|
# The outset paints the background past the box's own edges (a band that
|
|
129
163
|
# bleeds into the page margins) without moving the content.
|
|
130
164
|
def paint_background(canvas, x, y, width, height)
|
|
@@ -190,15 +224,27 @@ module Stationery
|
|
|
190
224
|
end,
|
|
191
225
|
inner.height, used)
|
|
192
226
|
paint_inner = -> { content.paint(canvas, x + left, y + top + [offset, 0].max, inner.width) }
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
else
|
|
196
|
-
canvas.clip(inner.x, inner.y, inner.width, inner.height) do
|
|
227
|
+
clip_outline(canvas, Rect.new(x, y, width, height)) do
|
|
228
|
+
if @overflow == :visible || (@overflow == :hidden && @height.nil?)
|
|
197
229
|
paint_inner.call
|
|
230
|
+
else
|
|
231
|
+
canvas.clip(inner.x, inner.y, inner.width, inner.height) do
|
|
232
|
+
paint_inner.call
|
|
233
|
+
end
|
|
198
234
|
end
|
|
199
235
|
end
|
|
200
236
|
end
|
|
201
237
|
|
|
238
|
+
# `overflow: :hidden` keeps the content inside the rounded outline; cut
|
|
239
|
+
# sides stay square like the background's.
|
|
240
|
+
def clip_outline(canvas, area, &)
|
|
241
|
+
return yield unless @overflow == :hidden && @radius.positive?
|
|
242
|
+
|
|
243
|
+
cut(canvas, area, area) do |shape|
|
|
244
|
+
canvas.clip(shape.x, shape.y, shape.width, shape.height, radius: @radius, &)
|
|
245
|
+
end
|
|
246
|
+
end
|
|
247
|
+
|
|
202
248
|
def paint_debug(canvas, rect, kind)
|
|
203
249
|
canvas.debug_rect(rect.x, rect.y, rect.width, rect.height, kind)
|
|
204
250
|
return if insets.all?(&:zero?)
|
|
@@ -4,10 +4,16 @@ module Stationery
|
|
|
4
4
|
module Layout
|
|
5
5
|
# An image sized by width, height, both, or a box to fit into (aspect
|
|
6
6
|
# preserved), never wider than the space it is given. One pixel is one
|
|
7
|
-
# point when no size is given.
|
|
7
|
+
# point when no size is given. `fit: :cover` fills `width:` × `height:`
|
|
8
|
+
# instead, scaling up and clipping the excess around the centre.
|
|
9
|
+
# `radius:` clips to rounded corners and `rotate:` (degrees, clockwise)
|
|
10
|
+
# turns the painted image around the centre of its rectangle; neither
|
|
11
|
+
# changes the space the image takes up.
|
|
8
12
|
class Image < Node
|
|
9
13
|
# `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)
|
|
14
|
+
def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil, radius: 0, rotate: 0)
|
|
15
|
+
raise ArgumentError, "fit: :cover needs width: and height:" if fit == :cover && !(width && height)
|
|
16
|
+
|
|
11
17
|
super()
|
|
12
18
|
@tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
|
|
13
19
|
@image = source.respond_to?(:build) ? source : Images.load(source)
|
|
@@ -15,6 +21,8 @@ module Stationery
|
|
|
15
21
|
@height = height
|
|
16
22
|
@fit = fit
|
|
17
23
|
@opacity = opacity
|
|
24
|
+
@radius = radius
|
|
25
|
+
@rotate = rotate
|
|
18
26
|
end
|
|
19
27
|
|
|
20
28
|
def size(available)
|
|
@@ -31,16 +39,37 @@ module Stationery
|
|
|
31
39
|
|
|
32
40
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
33
41
|
w, h = size(width)
|
|
34
|
-
canvas.tag(@tag, bbox: [x, y, w, h])
|
|
42
|
+
canvas.tag(@tag, bbox: [x, y, w, h]) do
|
|
43
|
+
canvas.rotate(@rotate, around: [x + (w / 2.0), y + (h / 2.0)]) { paint_clipped(canvas, x, y, w, h) }
|
|
44
|
+
end
|
|
35
45
|
canvas.debug_rect(x, y, w, h, :image)
|
|
36
46
|
end
|
|
37
47
|
|
|
38
48
|
private
|
|
39
49
|
|
|
50
|
+
def paint_clipped(canvas, x, y, w, h)
|
|
51
|
+
return paint_image(canvas, x, y, w, h) unless @fit == :cover || @radius.positive?
|
|
52
|
+
|
|
53
|
+
canvas.clip(x, y, w, h, radius: @radius) { paint_image(canvas, x, y, w, h) }
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def paint_image(canvas, x, y, w, h)
|
|
57
|
+
if @fit == :cover
|
|
58
|
+
scale = [w / @image.width.to_f, h / @image.height.to_f].max
|
|
59
|
+
cw = tidy(@image.width * scale)
|
|
60
|
+
ch = tidy(@image.height * scale)
|
|
61
|
+
x = tidy(x - ((cw - w) / 2.0))
|
|
62
|
+
y = tidy(y - ((ch - h) / 2.0))
|
|
63
|
+
w = cw
|
|
64
|
+
h = ch
|
|
65
|
+
end
|
|
66
|
+
canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity)
|
|
67
|
+
end
|
|
68
|
+
|
|
40
69
|
def requested
|
|
41
70
|
iw = @image.width.to_f
|
|
42
71
|
ih = @image.height.to_f
|
|
43
|
-
if @fit
|
|
72
|
+
if @fit.is_a?(Array)
|
|
44
73
|
scale = [@fit[0] / iw, @fit[1] / ih].min
|
|
45
74
|
[iw * scale, ih * scale].map { |v| tidy(v) }
|
|
46
75
|
elsif @width && @height then [@width, @height]
|
|
@@ -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
|
|
@@ -7,8 +7,10 @@ module Stationery
|
|
|
7
7
|
# A CommonMark subset (plus GFM tables and strikethrough) to rich-text blocks (see Stationery::Rich).
|
|
8
8
|
module Markdown
|
|
9
9
|
def self.parse(source)
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
Stationery.instrument("parse.stationery", format: :markdown, bytes: source.bytesize) do
|
|
11
|
+
refs = {}
|
|
12
|
+
Resolver.new(refs).blocks(BlockParser.parse(source, refs))
|
|
13
|
+
end
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
# Runs the raw text the block parser left in paragraphs, headings and cells through the inline parser.
|
data/lib/stationery/minitest.rb
CHANGED
|
@@ -16,6 +16,7 @@ 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)
|
|
20
21
|
def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
|
|
21
22
|
def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
|
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}")
|
data/lib/stationery/railtie.rb
CHANGED
|
@@ -23,6 +23,11 @@ module Stationery
|
|
|
23
23
|
Railtie.add_renderer if app.config.stationery.renderer
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
# Whatever the require order, a Rails app reports through ActiveSupport::Notifications.
|
|
27
|
+
initializer "stationery.instrumentation" do
|
|
28
|
+
Stationery.instrumenter = ActiveSupport::Notifications
|
|
29
|
+
end
|
|
30
|
+
|
|
26
31
|
initializer "stationery.previews" do |app|
|
|
27
32
|
Railtie.add_previews(app.routes) if app.config.stationery.show_previews
|
|
28
33
|
end
|
|
@@ -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) } }
|
|
@@ -40,7 +43,7 @@ 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)
|
|
@@ -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) } }
|
|
@@ -18,6 +18,8 @@ module Stationery
|
|
|
18
18
|
blockquote: { border: { sides: [:left], width: 3, color: "#E5E7EB" }, padding: [0, 0, 0, 10] },
|
|
19
19
|
hr: { height: 1, color: "#E5E7EB" },
|
|
20
20
|
table: { cell: { padding: 4 }, header: { weight: :bold } },
|
|
21
|
+
ul: {},
|
|
22
|
+
ol: {},
|
|
21
23
|
li: {},
|
|
22
24
|
img: { max_width: nil }
|
|
23
25
|
}.freeze
|
data/lib/stationery/rspec.rb
CHANGED
|
@@ -3,4 +3,8 @@
|
|
|
3
3
|
require "stationery"
|
|
4
4
|
require_relative "testing/matchers"
|
|
5
5
|
|
|
6
|
+
# Composable adds `and`/`or` and lets a matcher sit inside `include`, `match`
|
|
7
|
+
# and `all`; Base stays framework-free for Minitest, so it is mixed in here.
|
|
8
|
+
Stationery::Testing::Matchers::Base.include(RSpec::Matchers::Composable)
|
|
9
|
+
|
|
6
10
|
RSpec.configure { |config| config.include Stationery::Testing::Matchers }
|
|
@@ -60,16 +60,21 @@ module Stationery
|
|
|
60
60
|
end
|
|
61
61
|
|
|
62
62
|
def bookmarks
|
|
63
|
-
outlines =
|
|
63
|
+
outlines = catalog[:Outlines]
|
|
64
64
|
outlines ? titles(outlines[:First]) : []
|
|
65
65
|
end
|
|
66
66
|
|
|
67
|
+
# The catalog /Lang written by `metadata lang:`, or nil.
|
|
68
|
+
def lang
|
|
69
|
+
lang = catalog[:Lang]
|
|
70
|
+
decode(lang) if lang
|
|
71
|
+
end
|
|
72
|
+
|
|
67
73
|
# The structure tree of a tagged PDF as nested arrays; see StructureReader.
|
|
68
74
|
def structure = @structure ||= StructureReader.new(reader).tree
|
|
69
75
|
|
|
70
76
|
# Whether the catalog marks the PDF as tagged and holds a structure tree.
|
|
71
77
|
def tagged?
|
|
72
|
-
catalog = objects.deref!(objects.trailer[:Root])
|
|
73
78
|
catalog.dig(:MarkInfo, :Marked) == true && !catalog[:StructTreeRoot].nil?
|
|
74
79
|
end
|
|
75
80
|
|
|
@@ -81,6 +86,7 @@ module Stationery
|
|
|
81
86
|
private
|
|
82
87
|
|
|
83
88
|
def objects = reader.objects
|
|
89
|
+
def catalog = objects.deref!(objects.trailer[:Root])
|
|
84
90
|
|
|
85
91
|
def annotations
|
|
86
92
|
reader.pages.flat_map do |page|
|
|
@@ -101,6 +101,15 @@ module Stationery
|
|
|
101
101
|
def actual = "got bookmarks #{@inspector.bookmarks.inspect}"
|
|
102
102
|
end
|
|
103
103
|
|
|
104
|
+
class HaveLanguage < Base
|
|
105
|
+
def description = "have language #{show(@expected)}"
|
|
106
|
+
|
|
107
|
+
private
|
|
108
|
+
|
|
109
|
+
def match?(pdf) = pdf.lang == @expected.to_s
|
|
110
|
+
def actual = @inspector.lang ? "got #{@inspector.lang.inspect}" : "got none"
|
|
111
|
+
end
|
|
112
|
+
|
|
104
113
|
class HaveNoWarnings < Base
|
|
105
114
|
def description = "have no warnings"
|
|
106
115
|
|
|
@@ -140,6 +149,7 @@ module Stationery
|
|
|
140
149
|
def have_pdf_link(expected) = HaveLink.new(expected)
|
|
141
150
|
def have_image_count(expected) = HaveImageCount.new(expected)
|
|
142
151
|
def have_bookmark(title) = HaveBookmark.new(title)
|
|
152
|
+
def have_pdf_language(lang) = HaveLanguage.new(lang)
|
|
143
153
|
def have_no_warnings = HaveNoWarnings.new
|
|
144
154
|
def have_structure(expected) = HaveStructure.new(expected)
|
|
145
155
|
def have_tagged_content = HaveTaggedContent.new
|
data/lib/stationery/version.rb
CHANGED
data/lib/stationery/warnings.rb
CHANGED
|
@@ -33,6 +33,10 @@ module Stationery
|
|
|
33
33
|
def message = %(link to "#{name}" on page #{page} has no matching anchor)
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
+
DroppedLink = Data.define(:href) do
|
|
37
|
+
def message = %(link "#{href}" dropped: scheme not allowed)
|
|
38
|
+
end
|
|
39
|
+
|
|
36
40
|
DuplicateAnchor = Data.define(:name, :page) do
|
|
37
41
|
def message = %(anchor "#{name}" on page #{page} is already defined)
|
|
38
42
|
end
|
data/lib/stationery.rb
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "stationery/version"
|
|
4
4
|
require_relative "stationery/errors"
|
|
5
|
+
require_relative "stationery/instrumentation"
|
|
5
6
|
require_relative "stationery/pdf/types"
|
|
6
7
|
require_relative "stationery/pdf/serializer"
|
|
7
8
|
require_relative "stationery/pdf/stream"
|
|
@@ -107,6 +108,7 @@ require_relative "stationery/svg/document"
|
|
|
107
108
|
require_relative "stationery/layout/svg"
|
|
108
109
|
require_relative "stationery/layout/wrap"
|
|
109
110
|
require_relative "stationery/layout/positioned"
|
|
111
|
+
require_relative "stationery/layout/stack"
|
|
110
112
|
require_relative "stationery/layout/field"
|
|
111
113
|
require_relative "stationery/layout/list_item"
|
|
112
114
|
require_relative "stationery/list_markers"
|