stationery 0.12.0 → 0.12.2
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 +8 -0
- data/README.md +114 -17
- data/examples/price_card.rb +85 -0
- data/lib/stationery/cli/layout_text.rb +14 -2
- data/lib/stationery/cli/render.rb +7 -6
- data/lib/stationery/cli/verify.rb +98 -0
- data/lib/stationery/cli.rb +1 -0
- data/lib/stationery/document.rb +32 -7
- data/lib/stationery/elements/forms.rb +4 -2
- data/lib/stationery/forms/acro_form.rb +1 -1
- data/lib/stationery/forms/appearance.rb +26 -21
- data/lib/stationery/forms/auto_size.rb +94 -0
- data/lib/stationery/forms/field.rb +45 -3
- data/lib/stationery/monochrome/painting.rb +10 -0
- data/lib/stationery/pdf/assembler.rb +5 -3
- data/lib/stationery/pdf/conformance.rb +14 -1
- data/lib/stationery/pdf/object_streams.rb +41 -0
- data/lib/stationery/pdf/writer.rb +59 -5
- data/lib/stationery/raster/render.rb +1 -1
- data/lib/stationery/skill/recipes/form.md.erb +9 -6
- data/lib/stationery/skill/recipes/invoice.md.erb +13 -9
- data/lib/stationery/skill/recipes/label.md.erb +4 -2
- data/lib/stationery/skill/recipes/report.md.erb +9 -4
- data/lib/stationery/testing/inspector.rb +8 -5
- data/lib/stationery/testing/layout.rb +18 -1
- data/lib/stationery/verify/allowlist.rb +29 -0
- data/lib/stationery/verify/command.rb +80 -0
- data/lib/stationery/verify/comparison.rb +191 -0
- data/lib/stationery/verify/engine.rb +63 -0
- data/lib/stationery/verify/engines/mupdf.rb +44 -0
- data/lib/stationery/verify/engines/pdfium.rb +35 -0
- data/lib/stationery/verify/engines/pdfjs.rb +41 -0
- data/lib/stationery/verify/engines/pdfkit.rb +20 -0
- data/lib/stationery/verify/engines/poppler.rb +83 -0
- data/lib/stationery/verify/engines/qpdf.rb +46 -0
- data/lib/stationery/verify/expectation.rb +44 -0
- data/lib/stationery/verify/report.rb +49 -0
- data/lib/stationery/verify/scripts/pdfium.py +99 -0
- data/lib/stationery/verify/scripts/pdfjs.mjs +108 -0
- data/lib/stationery/verify/scripts/pdfkit.swift +80 -0
- data/lib/stationery/verify.rb +89 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +2 -0
- metadata +21 -1
|
@@ -9,7 +9,9 @@ module Stationery
|
|
|
9
9
|
module Elements
|
|
10
10
|
# A text input. `width:` is :full or points; `multiline:`, `max_length:`,
|
|
11
11
|
# `comb:` (a cell count, or true with max_length:), `read_only:`,
|
|
12
|
-
# `required:`, `font_size
|
|
12
|
+
# `required:`, `font_size:` (points, or :auto with `min_font_size:` and
|
|
13
|
+
# `max_font_size:`), `align:` (:left, :center, :right), `color:`,
|
|
14
|
+
# `border:`, `background:` and `radius:`.
|
|
13
15
|
def text_field(name, value: "", width: :full, height: 22, at: nil, **)
|
|
14
16
|
field_node(Forms::Field.new(:text, name, value: value.to_s, typeface: field_typeface, **), width:, height:, at:)
|
|
15
17
|
end
|
|
@@ -28,7 +30,7 @@ module Stationery
|
|
|
28
30
|
end
|
|
29
31
|
|
|
30
32
|
# A drop-down (combo box) of `options`; `editable: true` also accepts
|
|
31
|
-
# typed values.
|
|
33
|
+
# typed values. `font_size:`, `align:` and `color:` as a text field's.
|
|
32
34
|
def select(name, options:, value: nil, width: :full, height: 22, at: nil, **)
|
|
33
35
|
field = Forms::Field.new(:select, name, value: value&.to_s, options: options.map(&:to_s),
|
|
34
36
|
typeface: field_typeface, **)
|
|
@@ -93,7 +93,7 @@ module Stationery
|
|
|
93
93
|
# known to exist; nil without a signature.
|
|
94
94
|
def signed
|
|
95
95
|
return unless @signature
|
|
96
|
-
return @writer.
|
|
96
|
+
return @writer.add_unpacked(@signature.dictionary) if @widgets.any? { |widget| signs?(widget) }
|
|
97
97
|
|
|
98
98
|
raise ArgumentError, %(sign field: names "#{@signature.field}", but the document has no such signature_field)
|
|
99
99
|
end
|
|
@@ -16,14 +16,21 @@ module Stationery
|
|
|
16
16
|
# The check mark's corners in the unit square (y down) and its stroke.
|
|
17
17
|
CHECK = { points: [[0.22, 0.52], [0.42, 0.72], [0.78, 0.28]], stroke: 0.12 }.freeze
|
|
18
18
|
DOT = 0.45
|
|
19
|
+
# What /DA and the appearance say when the value is black.
|
|
20
|
+
BLACK = "0 g"
|
|
19
21
|
SIGNATURE = { rule: 14, label_size: 7, label_baseline: 4, label_gray: 0.42 }.freeze
|
|
20
22
|
|
|
23
|
+
# The size the value is drawn at: the field's, or what `font_size:
|
|
24
|
+
# :auto` fits (see AutoSize).
|
|
25
|
+
attr_reader :size
|
|
26
|
+
|
|
21
27
|
# `resources` are the render's, where the field's fonts get their names.
|
|
22
28
|
def initialize(field, width, height, resources = nil)
|
|
23
29
|
@field = field
|
|
24
30
|
@width = width
|
|
25
31
|
@height = height
|
|
26
32
|
@type = field.typeface.with(resources)
|
|
33
|
+
@size = field.auto_size? ? AutoSize.new(field, @type, width, height).size : field.font_size
|
|
27
34
|
@contents = contents
|
|
28
35
|
keep
|
|
29
36
|
end
|
|
@@ -34,14 +41,18 @@ module Stationery
|
|
|
34
41
|
# The resource names of the fonts it draws with or keeps for editing.
|
|
35
42
|
def font_names = @type.names
|
|
36
43
|
|
|
37
|
-
# What the field's /DA says: the font
|
|
38
|
-
# value with. nil for a field without variable text.
|
|
44
|
+
# What the field's /DA says: the font, size (0 for auto) and colour a
|
|
45
|
+
# viewer redraws the value with. nil for a field without variable text.
|
|
39
46
|
def default_appearance
|
|
40
47
|
return unless @field.variable_text?
|
|
41
48
|
|
|
42
|
-
"/#{@type.name} #{
|
|
49
|
+
"/#{@type.name} #{@field.auto_size? ? 0 : num(size)} Tf #{text_color}"
|
|
43
50
|
end
|
|
44
51
|
|
|
52
|
+
# What it paints: its content streams and its /DA, for a check of the
|
|
53
|
+
# colours they use (see PDF::Conformance).
|
|
54
|
+
def paints = [*(@contents.is_a?(Hash) ? @contents.values : @contents), default_appearance].compact
|
|
55
|
+
|
|
45
56
|
# One stream, or a Hash of streams by appearance state for buttons.
|
|
46
57
|
# `fonts` are the references by resource name.
|
|
47
58
|
def streams(fonts)
|
|
@@ -54,7 +65,7 @@ module Stationery
|
|
|
54
65
|
private
|
|
55
66
|
|
|
56
67
|
def options = @field.options
|
|
57
|
-
def
|
|
68
|
+
def text_color = @field.color == Field::BLACK ? BLACK : @field.color.fill
|
|
58
69
|
def num(value) = PDF::Serializer.number(value.is_a?(Float) && value == value.round ? value.round : value)
|
|
59
70
|
|
|
60
71
|
def contents
|
|
@@ -98,29 +109,23 @@ module Stationery
|
|
|
98
109
|
return comb_cells if options[:comb]
|
|
99
110
|
return multiline_runs if options[:multiline]
|
|
100
111
|
|
|
101
|
-
|
|
112
|
+
value = @field.value.to_s
|
|
113
|
+
[[start(value), middle_baseline, value]]
|
|
102
114
|
end
|
|
103
115
|
|
|
104
116
|
def multiline_runs
|
|
105
117
|
top = @height - PADDING - @type.ascent(size)
|
|
106
|
-
wrap(@field.value.to_s, @width - (2 * PADDING)).each_with_index.map do |line, index|
|
|
107
|
-
[
|
|
118
|
+
AutoSize.wrap(@type, @field.value.to_s, @width - (2 * PADDING), size).each_with_index.map do |line, index|
|
|
119
|
+
[start(line), top - (index * size * LEADING), line]
|
|
108
120
|
end
|
|
109
121
|
end
|
|
110
122
|
|
|
111
|
-
#
|
|
112
|
-
#
|
|
113
|
-
def
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
if lines.last.empty? || @type.width(candidate, size) <= width
|
|
118
|
-
lines[-1] = candidate
|
|
119
|
-
else
|
|
120
|
-
lines << word
|
|
121
|
-
end
|
|
122
|
-
end
|
|
123
|
-
end
|
|
123
|
+
# Where a line starts: aligned within the box less its padding, as a
|
|
124
|
+
# viewer does (PDF 32000-1, 12.7.3.3).
|
|
125
|
+
def start(line)
|
|
126
|
+
return PADDING if @field.align == :left
|
|
127
|
+
|
|
128
|
+
PADDING + Geometry.align_offset(@field.align, @width - (2 * PADDING), @type.width(line, size))
|
|
124
129
|
end
|
|
125
130
|
|
|
126
131
|
def comb_cells
|
|
@@ -131,7 +136,7 @@ module Stationery
|
|
|
131
136
|
end
|
|
132
137
|
|
|
133
138
|
def variable_text(runs)
|
|
134
|
-
ops = ["/Tx BMC", "q", "1 1 #{num(@width - 2)} #{num(@height - 2)} re W n",
|
|
139
|
+
ops = ["/Tx BMC", "q", "1 1 #{num(@width - 2)} #{num(@height - 2)} re W n", text_color]
|
|
135
140
|
runs.reject { |_, _, text| text.empty? }.each do |x, y, text|
|
|
136
141
|
ops.push("BT", "#{num(x)} #{num(y)} Td", *@type.show(text, size), "ET")
|
|
137
142
|
end
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Forms
|
|
5
|
+
# The size `font_size: :auto` draws a field's value at: the largest that
|
|
6
|
+
# fits the field's box less its padding, no larger than `max_font_size:`
|
|
7
|
+
# and no smaller than `min_font_size:` (4 points by default, the smallest
|
|
8
|
+
# Chrome's PDFium auto-sizes to). Sizes are whole tenths of a point,
|
|
9
|
+
# rounded down.
|
|
10
|
+
#
|
|
11
|
+
# A typeface's widths and heights are linear in the size, so one line and
|
|
12
|
+
# a comb field's cells are arithmetic; wrapped lines are not, and their
|
|
13
|
+
# size is found by bisection. Only the typeface's measures are asked:
|
|
14
|
+
# nothing is drawn or named here.
|
|
15
|
+
class AutoSize
|
|
16
|
+
MIN = 4
|
|
17
|
+
# Measures are taken at this size and scaled: less rounding than at 1.
|
|
18
|
+
UNIT = 100.0
|
|
19
|
+
|
|
20
|
+
# The value broken into lines no wider than `width` at `size`, keeping
|
|
21
|
+
# its own line breaks; a word wider than a line stands alone.
|
|
22
|
+
def self.wrap(type, text, width, size)
|
|
23
|
+
text.split("\n", -1).flat_map do |paragraph|
|
|
24
|
+
paragraph.split.each_with_object([+""]) do |word, lines|
|
|
25
|
+
candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
|
|
26
|
+
if lines.last.empty? || type.width(candidate, size) <= width
|
|
27
|
+
lines[-1] = candidate
|
|
28
|
+
else
|
|
29
|
+
lines << word
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
# `type` is the typeface the appearance draws with; `width` and
|
|
36
|
+
# `height` the widget's.
|
|
37
|
+
def initialize(field, type, width, height)
|
|
38
|
+
@field = field
|
|
39
|
+
@type = type
|
|
40
|
+
@inner_width = width - (2 * Appearance::PADDING)
|
|
41
|
+
@inner_height = height - (2 * Appearance::PADDING)
|
|
42
|
+
@cell = field.options[:comb] && width.fdiv(field.max_length)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def size
|
|
46
|
+
min = @field.min_font_size || [MIN, @field.max_font_size].compact.min
|
|
47
|
+
max = [tenth([@field.max_font_size, height_fit].compact.min), min].max
|
|
48
|
+
fit = @field.options[:multiline] && !@cell ? lines_fit(min, max) : [max, width_fit].compact.min
|
|
49
|
+
[tenth(fit), min].max
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def value = @field.value.to_s
|
|
55
|
+
def tenth(size) = (size * 10).floor / 10.0
|
|
56
|
+
def per_point(measure) = measure / UNIT
|
|
57
|
+
def glyph_box = per_point(@type.ascent(UNIT) + @type.descent(UNIT))
|
|
58
|
+
def height_fit = @inner_height / glyph_box
|
|
59
|
+
|
|
60
|
+
# What the width allows one line, or the widest character a cell; nil
|
|
61
|
+
# when the value has no width.
|
|
62
|
+
def width_fit
|
|
63
|
+
return cell_fit if @cell
|
|
64
|
+
|
|
65
|
+
width = per_point(@type.width(value, UNIT))
|
|
66
|
+
@inner_width / width if width.positive?
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def cell_fit
|
|
70
|
+
widest = value[0, @field.max_length].chars.map { |char| per_point(@type.width(char, UNIT)) }.max
|
|
71
|
+
@cell / widest if widest&.positive?
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
# The largest tenth in min..max at which the wrapped value fits.
|
|
75
|
+
def lines_fit(min, max)
|
|
76
|
+
low = (min * 10).ceil
|
|
77
|
+
high = (max * 10).floor
|
|
78
|
+
return min unless high >= low && fits?(low / 10.0)
|
|
79
|
+
|
|
80
|
+
while low < high
|
|
81
|
+
middle = (low + high + 1) / 2
|
|
82
|
+
fits?(middle / 10.0) ? low = middle : high = middle - 1
|
|
83
|
+
end
|
|
84
|
+
low / 10.0
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
def fits?(size)
|
|
88
|
+
lines = AutoSize.wrap(@type, value, @inner_width, size)
|
|
89
|
+
height = @type.ascent(size) + ((lines.size - 1) * size * Appearance::LEADING) + @type.descent(size)
|
|
90
|
+
height <= @inner_height && lines.all? { |line| @type.width(line, size) <= @inner_width }
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -16,13 +16,20 @@ module Stationery
|
|
|
16
16
|
comb: 25 }.freeze
|
|
17
17
|
DEFAULTS = { font_size: 10, read_only: false, required: false, border: "#9CA3AF", background: "#FFFFFF",
|
|
18
18
|
radius: 2, tooltip: nil }.freeze
|
|
19
|
+
# What a text field and a select take beyond the defaults: where the
|
|
20
|
+
# value sits, its colour, and the bounds of `font_size: :auto`.
|
|
21
|
+
BOUNDS = %i[min_font_size max_font_size].freeze
|
|
22
|
+
STYLE = [:align, :color, *BOUNDS].freeze
|
|
19
23
|
OPTIONS = {
|
|
20
|
-
text:
|
|
21
|
-
signature: %i[label]
|
|
24
|
+
text: [:multiline, :max_length, :comb, *STYLE], checkbox: [], radio: %i[checked],
|
|
25
|
+
select: [:options, :editable, *STYLE], signature: %i[label]
|
|
22
26
|
}.freeze
|
|
23
27
|
VARIABLE_TEXT = %i[text select].freeze
|
|
28
|
+
# The /Q (quadding) of each `align:` (PDF 32000-1, 12.7.3.3).
|
|
29
|
+
ALIGNMENTS = { left: 0, center: 1, right: 2 }.freeze
|
|
30
|
+
BLACK = Color.parse("#000000")
|
|
24
31
|
|
|
25
|
-
attr_reader :kind, :name, :value, :options, :typeface
|
|
32
|
+
attr_reader :kind, :name, :value, :options, :typeface, :color
|
|
26
33
|
|
|
27
34
|
def initialize(kind, name, value: nil, typeface: Standard.new, **options)
|
|
28
35
|
@kind = kind
|
|
@@ -33,12 +40,24 @@ module Stationery
|
|
|
33
40
|
raise ArgumentError, "unknown #{kind} field option: #{unknown.join(", ")}" if unknown.any?
|
|
34
41
|
|
|
35
42
|
@options = DEFAULTS.merge(options)
|
|
43
|
+
@color = Color.parse(@options.fetch(:color, BLACK))
|
|
36
44
|
validate_comb
|
|
45
|
+
validate_style
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# A copy with `changes` to its options.
|
|
49
|
+
def with(**changes)
|
|
50
|
+
self.class.new(@kind, @name, value: @value, typeface: @typeface, **@options, **changes)
|
|
37
51
|
end
|
|
38
52
|
|
|
39
53
|
def segments = @name.split(".")
|
|
40
54
|
def type = TYPES.fetch(@kind)
|
|
41
55
|
def font_size = @options[:font_size]
|
|
56
|
+
# Whether the value is drawn at the largest size that fits (`font_size: :auto`).
|
|
57
|
+
def auto_size? = font_size == :auto
|
|
58
|
+
def min_font_size = @options[:min_font_size]
|
|
59
|
+
def max_font_size = @options[:max_font_size]
|
|
60
|
+
def align = @options.fetch(:align, :left)
|
|
42
61
|
# Whether a viewer redraws its text when the value changes.
|
|
43
62
|
def variable_text? = VARIABLE_TEXT.include?(@kind)
|
|
44
63
|
# The accessible name: `tooltip:`, a signature's label, else the name.
|
|
@@ -62,6 +81,7 @@ module Stationery
|
|
|
62
81
|
def field_entries(value = field_value, default_appearance: nil)
|
|
63
82
|
entries = { FT: type, TU: PDF::TextString.new(tooltip) }
|
|
64
83
|
entries[:DA] = default_appearance if default_appearance
|
|
84
|
+
entries[:Q] = ALIGNMENTS.fetch(align) unless align == :left
|
|
65
85
|
entries[:Ff] = flags if flags.positive?
|
|
66
86
|
entries[:V] = value unless value.nil?
|
|
67
87
|
entries[:MaxLen] = max_length if max_length
|
|
@@ -126,6 +146,28 @@ module Stationery
|
|
|
126
146
|
|
|
127
147
|
raise ArgumentError, "a comb field needs max_length: (or comb: <cells>)"
|
|
128
148
|
end
|
|
149
|
+
|
|
150
|
+
def validate_style
|
|
151
|
+
raise ArgumentError, "align: is :left, :center or :right, not #{align.inspect}" unless ALIGNMENTS.key?(align)
|
|
152
|
+
|
|
153
|
+
points(:font_size, " or :auto") unless auto_size?
|
|
154
|
+
BOUNDS.each do |key|
|
|
155
|
+
next unless @options.key?(key)
|
|
156
|
+
raise ArgumentError, "#{key}: needs font_size: :auto" unless auto_size?
|
|
157
|
+
|
|
158
|
+
points(key)
|
|
159
|
+
end
|
|
160
|
+
return unless min_font_size && max_font_size && min_font_size > max_font_size
|
|
161
|
+
|
|
162
|
+
raise ArgumentError, "min_font_size: #{min_font_size} is above max_font_size: #{max_font_size}"
|
|
163
|
+
end
|
|
164
|
+
|
|
165
|
+
def points(key, alternative = "")
|
|
166
|
+
value = @options[key]
|
|
167
|
+
return if value.is_a?(Numeric) && value.positive?
|
|
168
|
+
|
|
169
|
+
raise ArgumentError, "#{key}: is a number of points#{alternative}, not #{value.inspect}"
|
|
170
|
+
end
|
|
129
171
|
end
|
|
130
172
|
end
|
|
131
173
|
end
|
|
@@ -47,6 +47,16 @@ module Stationery
|
|
|
47
47
|
super
|
|
48
48
|
end
|
|
49
49
|
|
|
50
|
+
# A field's value is text: its colour is reported, or snapped in /DA
|
|
51
|
+
# and the appearance alike. Its frame is drawn as it is.
|
|
52
|
+
def widget(field, x, y, w, h, tag: nil)
|
|
53
|
+
if field.variable_text?
|
|
54
|
+
color, = @monochrome.paint(field.color, :text, @page)
|
|
55
|
+
field = field.with(color:) unless color.equal?(field.color)
|
|
56
|
+
end
|
|
57
|
+
super
|
|
58
|
+
end
|
|
59
|
+
|
|
50
60
|
# A gradient is reported by its stops; snapped, it is a black fill or nothing.
|
|
51
61
|
def shade_path(path, shading, matrix:, even_odd: false, opacity: nil)
|
|
52
62
|
return super unless shading.respond_to?(:stops)
|
|
@@ -17,13 +17,15 @@ module Stationery
|
|
|
17
17
|
# finished file, so it cannot be streamed. `writer:` is the Writer that
|
|
18
18
|
# already holds the bodies of pages sealed as they were painted; it
|
|
19
19
|
# brings its own encryption and sink. `print:` are the hints of
|
|
20
|
-
# PrintHints for the catalog.
|
|
20
|
+
# PrintHints for the catalog. `object_streams:` (true) packs what is not
|
|
21
|
+
# a stream into object streams (see Writer).
|
|
21
22
|
def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil,
|
|
22
23
|
page_labels: nil, attachments: [], xmp: true, xmp_extensions: {}, xmp_schemas: [],
|
|
23
|
-
conformance: nil, signature: nil, sink: nil, writer: nil, print: nil)
|
|
24
|
+
conformance: nil, signature: nil, sink: nil, writer: nil, print: nil, object_streams: false)
|
|
24
25
|
raise ArgumentError, "a signed document cannot be streamed: sign needs the whole file" if signature && sink
|
|
25
26
|
|
|
26
27
|
@writer = writer
|
|
28
|
+
@object_streams = object_streams
|
|
27
29
|
@print = print
|
|
28
30
|
@sink = sink
|
|
29
31
|
@xmp_schemas = xmp_schemas
|
|
@@ -44,7 +46,7 @@ module Stationery
|
|
|
44
46
|
|
|
45
47
|
# The file as a String, or the number of bytes streamed to the sink.
|
|
46
48
|
def render
|
|
47
|
-
writer = @writer || Writer.new(encryption: @encryption, sink: @sink)
|
|
49
|
+
writer = @writer || Writer.new(encryption: @encryption, sink: @sink, object_streams: @object_streams)
|
|
48
50
|
tree = writer.reserve
|
|
49
51
|
refs = @resources.build(writer)
|
|
50
52
|
@form = Forms::AcroForm.new(writer, fonts: refs.fetch(:Font), signature: @signature,
|
|
@@ -27,6 +27,8 @@ module Stationery
|
|
|
27
27
|
OUTPUT_CONDITION = "sRGB IEC61966-2.1"
|
|
28
28
|
PRINT = 4
|
|
29
29
|
CMYK_OPERATOR = /^(?:-?[\d.]+ ){4}[kK]$/
|
|
30
|
+
# The colour a field's /DA sets, after its font and size.
|
|
31
|
+
CMYK_DEFAULT_APPEARANCE = /Tf (?:-?[\d.]+ ){4}k\z/
|
|
30
32
|
# What a character no font has does: :raise, or :replace to draw the
|
|
31
33
|
# font's stand-in for it (see Fonts::Font#stand_in).
|
|
32
34
|
MISSING_GLYPHS = %i[raise replace].freeze
|
|
@@ -174,7 +176,18 @@ module Stationery
|
|
|
174
176
|
pages.each_with_index do |page, index|
|
|
175
177
|
subjects << "CMYK colour on page #{index + 1}" if page.content.match?(CMYK_OPERATOR)
|
|
176
178
|
end
|
|
177
|
-
subjects
|
|
179
|
+
subjects + cmyk_fields(pages)
|
|
180
|
+
end
|
|
181
|
+
|
|
182
|
+
# A field's appearance and /DA are not page content: each is read on its own.
|
|
183
|
+
def cmyk_fields(pages)
|
|
184
|
+
widgets = pages.flat_map(&:annotations).select { |annotation| annotation[:widget] && annotation[:appearance] }
|
|
185
|
+
widgets.select { |annotation| cmyk_paint?(annotation[:appearance].paints) }
|
|
186
|
+
.map { |annotation| %(CMYK colour in form field "#{annotation[:widget].name}") }.uniq
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def cmyk_paint?(paints)
|
|
190
|
+
paints.any? { |paint| paint.match?(CMYK_OPERATOR) || paint.match?(CMYK_DEFAULT_APPEARANCE) }
|
|
178
191
|
end
|
|
179
192
|
end
|
|
180
193
|
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module PDF
|
|
5
|
+
# The two stream objects of a compressed file (ISO 32000-1, 7.5.7 and
|
|
6
|
+
# 7.5.8): an object stream holding objects that are not streams, and the
|
|
7
|
+
# cross-reference stream that replaces the table and the trailer.
|
|
8
|
+
module ObjectStreams
|
|
9
|
+
# Objects in one object stream: a few hundred deflate to a tenth of
|
|
10
|
+
# their size, and a reader inflates the whole stream to reach one.
|
|
11
|
+
CAPACITY = 200
|
|
12
|
+
# Bytes of an entry's type, offset or object stream number, and
|
|
13
|
+
# generation or index.
|
|
14
|
+
WIDTHS = [1, 4, 2].freeze
|
|
15
|
+
FREE = [0, 0, 0xFFFF].freeze
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
# The object stream of `batch`, [number, value] pairs, its values
|
|
20
|
+
# written without encryption: the stream is encrypted whole.
|
|
21
|
+
def pack(batch)
|
|
22
|
+
header = String.new(encoding: Encoding::BINARY)
|
|
23
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
24
|
+
batch.each do |number, value|
|
|
25
|
+
header << "#{number} #{body.bytesize} "
|
|
26
|
+
body << Serializer.dump(value) << "\n"
|
|
27
|
+
end
|
|
28
|
+
Stream.new(header + body, { Type: :ObjStm, N: batch.size, First: header.bytesize })
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
# The cross-reference stream of `locations`, one per object from 1: an
|
|
32
|
+
# Integer offset in the file, or [object stream, index] for a packed
|
|
33
|
+
# object. `trailer` is what the trailer dictionary would hold.
|
|
34
|
+
def xref(locations, trailer)
|
|
35
|
+
fields = FREE.dup
|
|
36
|
+
locations.each { |at| at.is_a?(Integer) ? fields.push(1, at, 0) : fields.push(2, at[0], at[1]) }
|
|
37
|
+
Stream.new(fields.pack("CNn" * (fields.size / 3)), trailer.merge(Type: :XRef, W: WIDTHS))
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -5,7 +5,8 @@ require "digest/md5"
|
|
|
5
5
|
module Stationery
|
|
6
6
|
module PDF
|
|
7
7
|
# Collects numbered objects and writes them as a PDF file with a classic
|
|
8
|
-
# cross-reference table
|
|
8
|
+
# cross-reference table, or with `object_streams: true` packed into
|
|
9
|
+
# object streams with a cross-reference stream (see ObjectStreams). `render` returns the file as a String with the
|
|
9
10
|
# objects in numbered order. With a `sink:` (anything answering
|
|
10
11
|
# `call(bytes)`) the file goes there in pieces instead: `flush` writes every
|
|
11
12
|
# object set so far and drops it, so objects leave in the order they were
|
|
@@ -16,8 +17,14 @@ module Stationery
|
|
|
16
17
|
|
|
17
18
|
# `encryption` (an Encryption::StandardSecurity) encrypts every string
|
|
18
19
|
# and stream except those of its own /Encrypt dictionary.
|
|
19
|
-
|
|
20
|
+
#
|
|
21
|
+
# With `object_streams: true` every object that is not a stream goes
|
|
22
|
+
# into a deflated object stream, but for the Encrypt dictionary and an
|
|
23
|
+
# object added with #add_unpacked (a signature dictionary, which is
|
|
24
|
+
# filled in once the file is written and found by its bytes).
|
|
25
|
+
def initialize(encryption: nil, sink: nil, object_streams: false)
|
|
20
26
|
@objects = []
|
|
27
|
+
@loose = [] if object_streams
|
|
21
28
|
@offsets = []
|
|
22
29
|
@encryption = encryption
|
|
23
30
|
@sink = sink
|
|
@@ -45,6 +52,13 @@ module Stationery
|
|
|
45
52
|
set(reserve, value)
|
|
46
53
|
end
|
|
47
54
|
|
|
55
|
+
# Adds an object that stays out of the object streams.
|
|
56
|
+
def add_unpacked(value)
|
|
57
|
+
ref = add(value)
|
|
58
|
+
@loose << ref.id if @loose
|
|
59
|
+
ref
|
|
60
|
+
end
|
|
61
|
+
|
|
48
62
|
# With a sink: writes every object set and not yet written, lowest number
|
|
49
63
|
# first, and drops it. References to objects still unset stay valid, as
|
|
50
64
|
# the cross-reference table is written last. Without a sink it does
|
|
@@ -66,9 +80,10 @@ module Stationery
|
|
|
66
80
|
end
|
|
67
81
|
|
|
68
82
|
write_pending
|
|
69
|
-
xref = @size
|
|
70
83
|
id = HexString.new(file_id)
|
|
84
|
+
return write_xref_stream(root, info, id) if @loose
|
|
71
85
|
|
|
86
|
+
xref = @size
|
|
72
87
|
emit("xref\n0 #{@objects.size + 1}\n0000000000 65535 f \n")
|
|
73
88
|
@offsets.each { |offset| emit(format("%010d 00000 n \n", offset)) }
|
|
74
89
|
emit("trailer\n#{Serializer.dump(trailer(root, info, id))}")
|
|
@@ -79,6 +94,8 @@ module Stationery
|
|
|
79
94
|
private
|
|
80
95
|
|
|
81
96
|
def write_pending
|
|
97
|
+
return pack_pending if @loose
|
|
98
|
+
|
|
82
99
|
@objects.each_with_index do |object, index|
|
|
83
100
|
next if object.nil? || object.equal?(WRITTEN)
|
|
84
101
|
|
|
@@ -87,14 +104,51 @@ module Stationery
|
|
|
87
104
|
end
|
|
88
105
|
end
|
|
89
106
|
|
|
107
|
+
# Streams, the Encrypt dictionary and what was added with #add_unpacked
|
|
108
|
+
# are written as they are, the rest into object streams of at most
|
|
109
|
+
# ObjectStreams::CAPACITY objects; the last one is closed with the flush.
|
|
110
|
+
def pack_pending
|
|
111
|
+
batch = []
|
|
112
|
+
@objects.each_with_index do |object, index|
|
|
113
|
+
next if object.nil? || object.equal?(WRITTEN)
|
|
114
|
+
|
|
115
|
+
number = index + 1
|
|
116
|
+
if object.is_a?(Stream) || @loose.include?(number) || number == @encrypt&.id
|
|
117
|
+
write_object(number, object)
|
|
118
|
+
else
|
|
119
|
+
batch << [number, object]
|
|
120
|
+
batch = write_object_stream(batch) if batch.size == ObjectStreams::CAPACITY
|
|
121
|
+
end
|
|
122
|
+
@objects[index] = WRITTEN
|
|
123
|
+
end
|
|
124
|
+
write_object_stream(batch) if batch.any?
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
def write_object_stream(batch)
|
|
128
|
+
@objects << WRITTEN
|
|
129
|
+
number = @objects.size
|
|
130
|
+
batch.each_with_index { |(packed, _), index| @offsets[packed - 1] = [number, index] }
|
|
131
|
+
write_object(number, ObjectStreams.pack(batch))
|
|
132
|
+
[]
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
# The cross-reference stream, its own entry among them, never encrypted.
|
|
136
|
+
def write_xref_stream(root, info, id)
|
|
137
|
+
@objects << WRITTEN
|
|
138
|
+
number = @objects.size
|
|
139
|
+
xref = @offsets[number - 1] = @size
|
|
140
|
+
write_object(number, ObjectStreams.xref(@offsets, trailer(root, info, id)), nil)
|
|
141
|
+
emit("startxref\n#{xref}\n%%EOF\n")
|
|
142
|
+
@buffer || @size
|
|
143
|
+
end
|
|
144
|
+
|
|
90
145
|
def trailer(root, info, id)
|
|
91
146
|
trailer = { Size: @objects.size + 1, Root: root, Info: info, ID: [id, id] }
|
|
92
147
|
@encrypt ? trailer.merge(Encrypt: @encrypt) : trailer
|
|
93
148
|
end
|
|
94
149
|
|
|
95
|
-
def write_object(number, object)
|
|
150
|
+
def write_object(number, object, crypt = crypt_for(number))
|
|
96
151
|
@offsets[number - 1] = @size
|
|
97
|
-
crypt = crypt_for(number)
|
|
98
152
|
emit("#{number} 0 obj\n")
|
|
99
153
|
if object.is_a?(Stream)
|
|
100
154
|
data = crypt ? crypt.call(object.data) : object.data
|
|
@@ -16,7 +16,7 @@ module Stationery
|
|
|
16
16
|
# What only a PDF has: refused when asked for, left alone when the
|
|
17
17
|
# class declares it.
|
|
18
18
|
PDF_ONLY = %i[sign encrypt conformance attachments print tagged page_labels xmp factur_x incremental
|
|
19
|
-
missing_glyphs].freeze
|
|
19
|
+
missing_glyphs object_streams].freeze
|
|
20
20
|
GREY = "\x01-\xFE".b
|
|
21
21
|
DARK = "\x00-\x7F".b
|
|
22
22
|
LIGHT = "\x80-\xFF".b
|
|
@@ -23,17 +23,17 @@ class ApplicationFormPdf < Stationery::Document
|
|
|
23
23
|
text "Membership application", size: 20, weight: :bold, heading: 1
|
|
24
24
|
spacer 16
|
|
25
25
|
row(gap: 12) do
|
|
26
|
-
labelled("Full name") { text_field "applicant.name", value: @name, required: true }
|
|
27
|
-
labelled("Email") { text_field "applicant.email" }
|
|
26
|
+
labelled("Full name") { text_field "applicant.name", value: @name, required: true, tooltip: "Full name" }
|
|
27
|
+
labelled("Email") { text_field "applicant.email", tooltip: "Email" }
|
|
28
28
|
end
|
|
29
29
|
spacer 10
|
|
30
30
|
row(gap: 12) do
|
|
31
|
-
column(width: 90) { labelled_field("Postcode") { text_field "applicant.postcode", comb: 5 } }
|
|
32
|
-
labelled("Country") { select "applicant.country", options: %w[Sweden Norway Denmark], value: "Sweden" }
|
|
31
|
+
column(width: 90) { labelled_field("Postcode") { text_field "applicant.postcode", comb: 5, tooltip: "Postcode" } }
|
|
32
|
+
labelled("Country") { select "applicant.country", options: %w[Sweden Norway Denmark], value: "Sweden", tooltip: "Country" }
|
|
33
33
|
end
|
|
34
34
|
spacer 10
|
|
35
35
|
labelled_field("Plan") do
|
|
36
|
-
radio "plan", "basic", label: "Basic"
|
|
36
|
+
radio "plan", "basic", label: "Basic", tooltip: "Plan" # a radio group is named by its first choice
|
|
37
37
|
radio "plan", "pro", label: "Pro", checked: true
|
|
38
38
|
end
|
|
39
39
|
spacer 10
|
|
@@ -72,7 +72,10 @@ end
|
|
|
72
72
|
which paints the appearances the gem writes: `mutool draw -r 96 -o form-%d.png form.pdf`
|
|
73
73
|
(`pdftoppm` draws the values but not the borders). `stationery inspect form.pdf` lists each
|
|
74
74
|
field with its rectangle and value.
|
|
75
|
-
- Fields keep PDF/A and PDF/UA
|
|
75
|
+
- Fields keep PDF/A and PDF/UA. Every field has an accessible name: its `tooltip:`, else a check
|
|
76
|
+
box's or signature field's `label:`, else its field name (`applicant.name`), which is what a
|
|
77
|
+
screen reader then says. So a text field, a select and a radio group get a `tooltip:`, as above:
|
|
78
|
+
the text printed beside a field is not its name.
|
|
76
79
|
- Signing the file: `sign certificate:, key:, field: "signature"` fills the signature field with
|
|
77
80
|
a digital signature (see [../reference/conformance.md](../reference/conformance.md)).
|
|
78
81
|
- Every option of every field: [../reference/forms.md](../reference/forms.md) and <%= docs("forms") %>.
|
|
@@ -93,9 +93,9 @@ class InvoicePdf < Stationery::Document
|
|
|
93
93
|
|
|
94
94
|
def subtotal = @lines.sum { |line| line.quantity * line.unit_cents }
|
|
95
95
|
|
|
96
|
-
def vat_by_rate
|
|
97
|
-
@lines.group_by(&:vat_rate).
|
|
98
|
-
(lines.sum { |line| line.quantity * line.unit_cents } *
|
|
96
|
+
def vat_by_rate # exact: Rational, rounded once per rate
|
|
97
|
+
@lines.group_by(&:vat_rate).to_h do |rate, lines|
|
|
98
|
+
[rate, (lines.sum { |line| line.quantity * line.unit_cents } * Rational(rate, 100)).round]
|
|
99
99
|
end
|
|
100
100
|
end
|
|
101
101
|
|
|
@@ -108,9 +108,12 @@ end
|
|
|
108
108
|
|
|
109
109
|
## What matters here
|
|
110
110
|
|
|
111
|
-
- **Money is
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
- **Money is integer cents** (or `BigDecimal`), never a Float. This document derives the line
|
|
112
|
+
amounts and VAT from its lines, exactly (`Rational(rate, 100)`, rounded once per rate); an
|
|
113
|
+
application whose invoice already holds its totals, as its books have them, passes those in
|
|
114
|
+
and the document only formats them, so the PDF cannot disagree with the ledger by a cent.
|
|
115
|
+
Totals are ordinary table rows: a cell `{ content:, colspan: }` spans columns, `t.rows(-3..)`
|
|
116
|
+
selects the last three rows.
|
|
114
117
|
- **A table has black 0.5 pt borders on every cell by default.** `cell: { borders: [] }` takes
|
|
115
118
|
them away; `borders: [:top]` on a selection draws one rule. `header: true` repeats the first row
|
|
116
119
|
on every page the table continues onto; `t.zebra(from:, to:, color:)` stripes rows.
|
|
@@ -118,9 +121,10 @@ end
|
|
|
118
121
|
as wide as the page.
|
|
119
122
|
- **PDF/A-3b** (`conformance :pdf_a3b`) embeds the sRGB output intent and XMP identification, and
|
|
120
123
|
raises `Stationery::ConformanceError` instead of writing a file that breaks the claim: a CMYK
|
|
121
|
-
colour,
|
|
122
|
-
|
|
123
|
-
|
|
124
|
+
colour, or a character no font has (unless `missing_glyphs: :replace`, as above). `encrypt` with
|
|
125
|
+
a conformance level raises `ArgumentError` before anything renders: the two contradict each
|
|
126
|
+
other. Set `metadata title:`. Transparency (a PNG logo with alpha) is allowed. Validate the
|
|
127
|
+
file with veraPDF: `verapdf --format text -v --flavour 3b invoice_pdf.pdf` prints `PASS` (`brew install
|
|
124
128
|
verapdf`; with Docker, see [../reference/conformance.md](../reference/conformance.md)).
|
|
125
129
|
- An application that would rather send a plain PDF than none rescues the claim:
|
|
126
130
|
|
|
@@ -73,7 +73,8 @@ every change:
|
|
|
73
73
|
`CODE-128:SX0042771903` and `QR-Code:https://…`. A code it cannot read will not scan either.
|
|
74
74
|
4. **Check the native commands** are in the ZPL: `grep -o '\^B[CQEX][^^]*' label.zpl` shows `^BC`
|
|
75
75
|
(Code 128), `^BQ` (QR), `^BE` (EAN-13), `^BX` (Data Matrix). A barcode that is missing there
|
|
76
|
-
stayed in the picture (see below)
|
|
76
|
+
stayed in the picture (see below): it prints, on the dot grid as part of the `^GFA` graphic,
|
|
77
|
+
but drawn by stationery, not by the printer's own barcode encoder.
|
|
77
78
|
5. **Warnings**: `label.warnings` after `to_zpl` (or `to_png`) lists what a one-bit printer
|
|
78
79
|
cannot print as it is.
|
|
79
80
|
|
|
@@ -100,7 +101,8 @@ possibly another QR mask. (Online, labelary.com renders a `.zpl` file.)
|
|
|
100
101
|
dot grid. A barcode the printer could not draw as it is stays in the picture without a word: one
|
|
101
102
|
rotated or clipped, paler than half grey, a module wider than 10 dots (a QR code sized with a
|
|
102
103
|
large `width:` at 300 dpi), or a QR code of text outside ASCII. Size a native QR code by
|
|
103
|
-
`module_size
|
|
104
|
+
`module_size:`, at most 720 / dpi points so a module is 10 dots or fewer (3.5 pt at 203 dpi,
|
|
105
|
+
2.4 pt at 300, 1.2 pt at 600), and check the ZPL has its `^BQ` line.
|
|
104
106
|
Send it to port 9100: `TCPSocket.open(host, 9100) { |s| label.to_zpl(s) }`.
|
|
105
107
|
From the shell: `stationery render label_pdf.rb --zpl --dpi 300`.
|
|
106
108
|
- **Black and white only.** A thermal printer prints black or nothing. `monochrome dpi: 203` reports
|