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,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Forms
|
|
5
|
+
# A widget's normal appearance: the form XObject(s) every viewer draws, so
|
|
6
|
+
# a form looks right without relying on the viewer to regenerate it.
|
|
7
|
+
# Variable text sits between `/Tx BMC … EMC`, the part a viewer redraws
|
|
8
|
+
# when the value changes.
|
|
9
|
+
class Appearance
|
|
10
|
+
PADDING = 2
|
|
11
|
+
LEADING = 1.15
|
|
12
|
+
CHECK = { glyph: "4", width: 0.846, middle: 0.345 }.freeze
|
|
13
|
+
DOT = 0.45
|
|
14
|
+
SIGNATURE = { rule: 14, label_size: 7, label_baseline: 4, label_gray: 0.42 }.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(field, width, height, fonts)
|
|
17
|
+
@field = field
|
|
18
|
+
@width = width
|
|
19
|
+
@height = height
|
|
20
|
+
@fonts = fonts
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# One stream, or a Hash of streams by appearance state for buttons.
|
|
24
|
+
def normal
|
|
25
|
+
case @field.kind
|
|
26
|
+
when :text, :select then stream(frame + variable_text(text_lines))
|
|
27
|
+
when :checkbox then { @field.on_state => stream(frame + check), Off: stream(frame) }
|
|
28
|
+
when :radio then { @field.on_state => stream(circle + dot), Off: stream(circle) }
|
|
29
|
+
when :signature then stream(signature)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def options = @field.options
|
|
36
|
+
def size = @field.font_size
|
|
37
|
+
def num(value) = PDF::Serializer.number(value.is_a?(Float) && value == value.round ? value.round : value)
|
|
38
|
+
|
|
39
|
+
def stream(content)
|
|
40
|
+
PDF::Stream.new(content, { Type: :XObject, Subtype: :Form, BBox: [0, 0, @width, @height],
|
|
41
|
+
Resources: { Font: @fonts } })
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def frame
|
|
45
|
+
return +"" unless options[:background] || options[:border]
|
|
46
|
+
|
|
47
|
+
draw do |canvas|
|
|
48
|
+
canvas.rounded_rect(0.5, 0.5, @width - 1, @height - 1,
|
|
49
|
+
radius: options[:radius], fill: options[:background], stroke: options[:border])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# [x, baseline, WinAnsi bytes] runs in PDF space.
|
|
54
|
+
def text_lines
|
|
55
|
+
return comb_cells if options[:comb]
|
|
56
|
+
return multiline_runs if options[:multiline]
|
|
57
|
+
|
|
58
|
+
[[PADDING, (@height / 2.0) - (size * (Metrics::ASCENT + Metrics::DESCENT) / 2), Metrics.encode(@field.value)]]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def multiline_runs
|
|
62
|
+
top = @height - PADDING - (size * Metrics::ASCENT)
|
|
63
|
+
Metrics.wrap(@field.value, @width - (2 * PADDING), size).each_with_index.map do |line, index|
|
|
64
|
+
[PADDING, top - (index * size * LEADING), line]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def comb_cells
|
|
69
|
+
cell = @width.fdiv(@field.max_length)
|
|
70
|
+
baseline = (@height / 2.0) - (size * (Metrics::ASCENT + Metrics::DESCENT) / 2)
|
|
71
|
+
Metrics.encode(@field.value)[0, @field.max_length].chars.each_with_index.map do |char, index|
|
|
72
|
+
[(index * cell) + ((cell - Metrics.width(char, size)) / 2), baseline, char]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def variable_text(runs)
|
|
77
|
+
ops = ["/Tx BMC", "q", "1 1 #{num(@width - 2)} #{num(@height - 2)} re W n", "0 g"]
|
|
78
|
+
runs.reject { |_, _, bytes| bytes.empty? }.each do |x, y, bytes|
|
|
79
|
+
ops.push("BT", "/#{Field::FONT} #{num(size)} Tf", "#{num(x)} #{num(y)} Td",
|
|
80
|
+
"#{PDF::Serializer.literal(bytes)} Tj", "ET")
|
|
81
|
+
end
|
|
82
|
+
ops.push("Q", "EMC").join("\n") << "\n"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def circle
|
|
86
|
+
draw { |canvas| canvas.circle(*center, radius - 0.5, fill: options[:background], stroke: options[:border]) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def dot = draw { |canvas| canvas.circle(*center, radius * DOT, fill: "#000000") }
|
|
90
|
+
def center = [@width / 2.0, @height / 2.0]
|
|
91
|
+
def radius = [@width, @height].min / 2.0
|
|
92
|
+
|
|
93
|
+
def signature
|
|
94
|
+
y = @height - SIGNATURE[:rule]
|
|
95
|
+
content = draw { |canvas| canvas.line(PADDING, y, @width - PADDING, y, color: options[:border], width: 0.75) }
|
|
96
|
+
label = Metrics.encode(options[:label])
|
|
97
|
+
return content if label.empty?
|
|
98
|
+
|
|
99
|
+
ops = ["q", "#{SIGNATURE[:label_gray]} g", "BT", "/#{Field::FONT} #{SIGNATURE[:label_size]} Tf",
|
|
100
|
+
"#{PADDING} #{SIGNATURE[:label_baseline]} Td", "#{PDF::Serializer.literal(label)} Tj", "ET", "Q"]
|
|
101
|
+
"#{content}#{ops.join("\n")}\n"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def draw
|
|
105
|
+
page = Page.new(size: [@width, @height])
|
|
106
|
+
yield Canvas.new(page, nil)
|
|
107
|
+
page.content
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def check
|
|
111
|
+
glyph = [@width, @height].min * 0.8
|
|
112
|
+
x = (@width - (glyph * CHECK[:width])) / 2
|
|
113
|
+
y = (@height / 2.0) - (glyph * CHECK[:middle])
|
|
114
|
+
["q", "0 g", "BT", "/ZaDb #{num(glyph)} Tf", "#{num(x)} #{num(y)} Td", "(#{CHECK[:glyph]}) Tj", "ET", "Q"]
|
|
115
|
+
.join("\n") << "\n"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Interactive form fields (AcroForm): what each one holds and how it is
|
|
5
|
+
# written, apart from where the layout puts it.
|
|
6
|
+
module Forms
|
|
7
|
+
# One form field widget: its kind, full (dotted) name, value and options.
|
|
8
|
+
# Widgets sharing a name form one field (a radio group); dotted names are
|
|
9
|
+
# grouped under parent fields.
|
|
10
|
+
class Field
|
|
11
|
+
TYPES = { text: :Tx, checkbox: :Btn, radio: :Btn, select: :Ch, signature: :Sig }.freeze
|
|
12
|
+
# Field flag bit positions (PDF 32000-1, 12.7.3.1 and 12.7.4).
|
|
13
|
+
BITS = { read_only: 1, required: 2, multiline: 13, no_toggle_to_off: 15, radio: 16, combo: 18, edit: 19,
|
|
14
|
+
comb: 25 }.freeze
|
|
15
|
+
DEFAULTS = { font_size: 10, read_only: false, required: false, border: "#9CA3AF", background: "#FFFFFF",
|
|
16
|
+
radius: 2 }.freeze
|
|
17
|
+
OPTIONS = {
|
|
18
|
+
text: %i[multiline max_length comb], checkbox: [], radio: %i[checked], select: %i[options editable],
|
|
19
|
+
signature: %i[label]
|
|
20
|
+
}.freeze
|
|
21
|
+
FONT = "Helv"
|
|
22
|
+
|
|
23
|
+
attr_reader :kind, :name, :value, :options
|
|
24
|
+
|
|
25
|
+
def initialize(kind, name, value: nil, **options)
|
|
26
|
+
@kind = kind
|
|
27
|
+
@name = validate_name(name.to_s)
|
|
28
|
+
@value = value
|
|
29
|
+
unknown = options.keys - DEFAULTS.keys - OPTIONS.fetch(kind)
|
|
30
|
+
raise ArgumentError, "unknown #{kind} field option: #{unknown.join(", ")}" if unknown.any?
|
|
31
|
+
|
|
32
|
+
@options = DEFAULTS.merge(options)
|
|
33
|
+
validate_comb
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def segments = @name.split(".")
|
|
37
|
+
def type = TYPES.fetch(@kind)
|
|
38
|
+
def font_size = @options[:font_size]
|
|
39
|
+
def default_appearance = "/#{FONT} #{PDF::Serializer.number(font_size)} Tf 0 g"
|
|
40
|
+
def max_length = @options[:comb].is_a?(Integer) ? @options[:comb] : @options[:max_length]
|
|
41
|
+
def radio? = @kind == :radio
|
|
42
|
+
def checked? = radio? ? @options[:checked] == true : @value == true
|
|
43
|
+
|
|
44
|
+
# The on-state of a button: /Yes, or a radio's export value.
|
|
45
|
+
def on_state = radio? ? @value.to_s.to_sym : :Yes
|
|
46
|
+
|
|
47
|
+
def flags
|
|
48
|
+
set = %i[read_only required].select { |key| @options[key] }
|
|
49
|
+
set += kind_flags
|
|
50
|
+
set.sum { |key| 1 << (BITS.fetch(key) - 1) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The field-level entries; the widget's come from #widget_entries.
|
|
54
|
+
# `value` overrides this widget's own: a radio group's checked choice.
|
|
55
|
+
def field_entries(value = field_value)
|
|
56
|
+
entries = { FT: type, DA: default_appearance }
|
|
57
|
+
entries[:Ff] = flags if flags.positive?
|
|
58
|
+
entries[:V] = value unless value.nil?
|
|
59
|
+
entries[:MaxLen] = max_length if max_length
|
|
60
|
+
entries[:Opt] = @options[:options].map { |option| PDF::TextString.new(option.to_s) } if @kind == :select
|
|
61
|
+
entries
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# What Document#fields reports for this field.
|
|
65
|
+
def data_value
|
|
66
|
+
case @kind
|
|
67
|
+
when :checkbox then checked?
|
|
68
|
+
when :radio then checked? ? @value : nil
|
|
69
|
+
else @value
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# `state` overrides the button's own appearance state.
|
|
74
|
+
def widget_entries(width, height, fonts, state: nil)
|
|
75
|
+
normal = Appearance.new(self, width, height, fonts).normal
|
|
76
|
+
entries = { Type: :Annot, Subtype: :Widget, F: 4, AP: { N: normal } }
|
|
77
|
+
entries[:MK] = appearance_characteristics unless @kind == :signature
|
|
78
|
+
entries[:AS] = state || (checked? ? on_state : :Off) if normal.is_a?(Hash)
|
|
79
|
+
entries
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def field_value
|
|
85
|
+
case @kind
|
|
86
|
+
when :checkbox, :radio then checked? ? on_state : :Off
|
|
87
|
+
when :signature then nil
|
|
88
|
+
else @value.nil? ? nil : PDF::TextString.new(@value.to_s)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def kind_flags
|
|
93
|
+
case @kind
|
|
94
|
+
when :text then %i[multiline comb].select { |key| @options[key] }
|
|
95
|
+
when :radio then %i[radio no_toggle_to_off]
|
|
96
|
+
when :select then @options[:editable] ? %i[combo edit] : %i[combo]
|
|
97
|
+
else []
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def appearance_characteristics
|
|
102
|
+
mk = {}
|
|
103
|
+
mk[:BG] = Color.parse(@options[:background]).components if @options[:background]
|
|
104
|
+
mk[:BC] = Color.parse(@options[:border]).components if @options[:border]
|
|
105
|
+
mk[:CA] = radio? ? "l" : "4" if type == :Btn
|
|
106
|
+
mk
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_name(name)
|
|
110
|
+
return name unless name.empty? || name.split(".", -1).any?(&:empty?)
|
|
111
|
+
|
|
112
|
+
raise ArgumentError, "invalid field name #{name.inspect}: use non-empty, dot-separated segments"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_comb
|
|
116
|
+
return unless @options[:comb] && !max_length
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "a comb field needs max_length: (or comb: <cells>)"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Forms
|
|
5
|
+
# Helvetica's advance widths (the standard-14 AFM, in 1/1000 em) for the
|
|
6
|
+
# printable ASCII range, enough to lay out a field's appearance; other
|
|
7
|
+
# WinAnsi characters count as a digit's width.
|
|
8
|
+
module Metrics
|
|
9
|
+
ASCII = [
|
|
10
|
+
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, # space - /
|
|
11
|
+
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, # 0 - ?
|
|
12
|
+
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, # @ - O
|
|
13
|
+
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, # P - _
|
|
14
|
+
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, # ` - o
|
|
15
|
+
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584 # p - ~
|
|
16
|
+
].freeze
|
|
17
|
+
DEFAULT = 556
|
|
18
|
+
ASCENT = 0.718
|
|
19
|
+
DESCENT = -0.207
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# WinAnsi bytes for an appearance stream; unmappable characters become "?".
|
|
24
|
+
def encode(text)
|
|
25
|
+
text.to_s.encode(Encoding::Windows_1252, invalid: :replace, undef: :replace).b
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The width of WinAnsi `bytes` at `size`.
|
|
29
|
+
def width(bytes, size)
|
|
30
|
+
bytes.each_byte.sum { |byte| (byte in 32..126) ? ASCII[byte - 32] : DEFAULT } * size / 1000.0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# `text` broken into WinAnsi lines no wider than `width`, keeping its own
|
|
34
|
+
# line breaks; a word wider than a line stands alone.
|
|
35
|
+
def wrap(text, width, size)
|
|
36
|
+
encode(text).split("\n", -1).flat_map do |paragraph|
|
|
37
|
+
paragraph.split.each_with_object([+""]) do |word, lines|
|
|
38
|
+
candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
|
|
39
|
+
if lines.last.empty? || width(candidate, size) <= width
|
|
40
|
+
lines[-1] = candidate
|
|
41
|
+
else
|
|
42
|
+
lines << word
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
@@ -10,22 +10,29 @@ 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)
|
|
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()
|
|
30
|
+
@tag = role && Tagging::Element.new(Tagging.role(role))
|
|
25
31
|
@min_height = min_height
|
|
26
32
|
@open = open
|
|
27
33
|
@decoration = decoration
|
|
28
34
|
@link = link
|
|
35
|
+
@link_tag = link && Tagging::Element.new(:Link)
|
|
29
36
|
@outset = Geometry.box(outset)
|
|
30
37
|
@content = content
|
|
31
38
|
@padding = Geometry.box(padding)
|
|
@@ -37,6 +44,8 @@ module Stationery
|
|
|
37
44
|
@overflow = overflow
|
|
38
45
|
@valign = valign
|
|
39
46
|
@opacity = opacity
|
|
47
|
+
@rotate = rotate
|
|
48
|
+
@shadow = shadow && SHADOW.merge(shadow == true ? {} : shadow)
|
|
40
49
|
end
|
|
41
50
|
|
|
42
51
|
def natural_width
|
|
@@ -53,7 +62,10 @@ module Stationery
|
|
|
53
62
|
end
|
|
54
63
|
end
|
|
55
64
|
|
|
56
|
-
def splittable?
|
|
65
|
+
def splittable?
|
|
66
|
+
@height.nil? && @rotate.zero? && %i[visible hidden].include?(@overflow) && @content.splittable?
|
|
67
|
+
end
|
|
68
|
+
|
|
57
69
|
def prefer_whole? = break_inside.nil? && splittable?
|
|
58
70
|
|
|
59
71
|
def split(width, height, fresh: false)
|
|
@@ -81,11 +93,18 @@ module Stationery
|
|
|
81
93
|
|
|
82
94
|
def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
|
|
83
95
|
height ||= measure(width)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
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
|
|
104
|
+
end
|
|
105
|
+
paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
|
|
106
|
+
end
|
|
107
|
+
canvas.link(x, y, width, height, @link, tag: @link_tag) if @link
|
|
89
108
|
end
|
|
90
109
|
|
|
91
110
|
protected
|
|
@@ -119,6 +138,27 @@ module Stationery
|
|
|
119
138
|
def vertical(open = @open) = insets(open).values_at(0, 2).sum
|
|
120
139
|
def inner_width(width) = [width - horizontal, 0].max
|
|
121
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
|
+
|
|
122
162
|
# The outset paints the background past the box's own edges (a band that
|
|
123
163
|
# bleeds into the page margins) without moving the content.
|
|
124
164
|
def paint_background(canvas, x, y, width, height)
|
|
@@ -184,15 +224,27 @@ module Stationery
|
|
|
184
224
|
end,
|
|
185
225
|
inner.height, used)
|
|
186
226
|
paint_inner = -> { content.paint(canvas, x + left, y + top + [offset, 0].max, inner.width) }
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
else
|
|
190
|
-
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?)
|
|
191
229
|
paint_inner.call
|
|
230
|
+
else
|
|
231
|
+
canvas.clip(inner.x, inner.y, inner.width, inner.height) do
|
|
232
|
+
paint_inner.call
|
|
233
|
+
end
|
|
192
234
|
end
|
|
193
235
|
end
|
|
194
236
|
end
|
|
195
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
|
+
|
|
196
248
|
def paint_debug(canvas, rect, kind)
|
|
197
249
|
canvas.debug_rect(rect.x, rect.y, rect.width, rect.height, kind)
|
|
198
250
|
return if insets.all?(&:zero?)
|
|
@@ -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
|
-
|
|
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.
|
|
@@ -4,15 +4,25 @@ 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.
|
|
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
|
+
|
|
10
17
|
super()
|
|
18
|
+
@tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
|
|
11
19
|
@image = source.respond_to?(:build) ? source : Images.load(source)
|
|
12
20
|
@width = width
|
|
13
21
|
@height = height
|
|
14
22
|
@fit = fit
|
|
15
23
|
@opacity = opacity
|
|
24
|
+
@radius = radius
|
|
25
|
+
@rotate = rotate
|
|
16
26
|
end
|
|
17
27
|
|
|
18
28
|
def size(available)
|
|
@@ -29,16 +39,37 @@ module Stationery
|
|
|
29
39
|
|
|
30
40
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
31
41
|
w, h = size(width)
|
|
32
|
-
canvas.
|
|
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
|
|
33
45
|
canvas.debug_rect(x, y, w, h, :image)
|
|
34
46
|
end
|
|
35
47
|
|
|
36
48
|
private
|
|
37
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
|
+
|
|
38
69
|
def requested
|
|
39
70
|
iw = @image.width.to_f
|
|
40
71
|
ih = @image.height.to_f
|
|
41
|
-
if @fit
|
|
72
|
+
if @fit.is_a?(Array)
|
|
42
73
|
scale = [@fit[0] / iw, @fit[1] / ih].min
|
|
43
74
|
[iw * scale, ih * scale].map { |v| tidy(v) }
|
|
44
75
|
elsif @width && @height then [@width, @height]
|
|
@@ -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
|