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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +8 -0
  3. data/README.md +114 -17
  4. data/examples/price_card.rb +85 -0
  5. data/lib/stationery/cli/layout_text.rb +14 -2
  6. data/lib/stationery/cli/render.rb +7 -6
  7. data/lib/stationery/cli/verify.rb +98 -0
  8. data/lib/stationery/cli.rb +1 -0
  9. data/lib/stationery/document.rb +32 -7
  10. data/lib/stationery/elements/forms.rb +4 -2
  11. data/lib/stationery/forms/acro_form.rb +1 -1
  12. data/lib/stationery/forms/appearance.rb +26 -21
  13. data/lib/stationery/forms/auto_size.rb +94 -0
  14. data/lib/stationery/forms/field.rb +45 -3
  15. data/lib/stationery/monochrome/painting.rb +10 -0
  16. data/lib/stationery/pdf/assembler.rb +5 -3
  17. data/lib/stationery/pdf/conformance.rb +14 -1
  18. data/lib/stationery/pdf/object_streams.rb +41 -0
  19. data/lib/stationery/pdf/writer.rb +59 -5
  20. data/lib/stationery/raster/render.rb +1 -1
  21. data/lib/stationery/skill/recipes/form.md.erb +9 -6
  22. data/lib/stationery/skill/recipes/invoice.md.erb +13 -9
  23. data/lib/stationery/skill/recipes/label.md.erb +4 -2
  24. data/lib/stationery/skill/recipes/report.md.erb +9 -4
  25. data/lib/stationery/testing/inspector.rb +8 -5
  26. data/lib/stationery/testing/layout.rb +18 -1
  27. data/lib/stationery/verify/allowlist.rb +29 -0
  28. data/lib/stationery/verify/command.rb +80 -0
  29. data/lib/stationery/verify/comparison.rb +191 -0
  30. data/lib/stationery/verify/engine.rb +63 -0
  31. data/lib/stationery/verify/engines/mupdf.rb +44 -0
  32. data/lib/stationery/verify/engines/pdfium.rb +35 -0
  33. data/lib/stationery/verify/engines/pdfjs.rb +41 -0
  34. data/lib/stationery/verify/engines/pdfkit.rb +20 -0
  35. data/lib/stationery/verify/engines/poppler.rb +83 -0
  36. data/lib/stationery/verify/engines/qpdf.rb +46 -0
  37. data/lib/stationery/verify/expectation.rb +44 -0
  38. data/lib/stationery/verify/report.rb +49 -0
  39. data/lib/stationery/verify/scripts/pdfium.py +99 -0
  40. data/lib/stationery/verify/scripts/pdfjs.mjs +108 -0
  41. data/lib/stationery/verify/scripts/pdfkit.swift +80 -0
  42. data/lib/stationery/verify.rb +89 -0
  43. data/lib/stationery/version.rb +1 -1
  44. data/lib/stationery.rb +2 -0
  45. 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:`, `border:`, `background:` and `radius:`.
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.add(@signature.dictionary) if @widgets.any? { |widget| signs?(widget) }
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 and size a viewer redraws the
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} #{num(@field.font_size)} Tf 0 g"
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 size = @field.font_size
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
- [[PADDING, middle_baseline, @field.value.to_s]]
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
- [PADDING, top - (index * size * LEADING), line]
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
- # The value broken into lines no wider than `width`, keeping its own
112
- # line breaks; a word wider than a line stands alone.
113
- def wrap(text, width)
114
- text.split("\n", -1).flat_map do |paragraph|
115
- paragraph.split.each_with_object([+""]) do |word, lines|
116
- candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
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", "0 g"]
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: %i[multiline max_length comb], checkbox: [], radio: %i[checked], select: %i[options editable],
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. `render` returns the file as a String with the
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
- def initialize(encryption: nil, sink: nil)
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 (every field has an accessible name from `tooltip:` or its label).
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).transform_values do |lines|
98
- (lines.sum { |line| line.quantity * line.unit_cents } * lines.first.vat_rate / 100.0).round
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 computed by the application**, in integer cents or `BigDecimal`, never in Floats;
112
- the document only formats it. Totals are ordinary table rows: a cell `{ content:, colspan: }`
113
- spans columns, `t.rows(-3..)` selects the last three rows.
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, `encrypt`, or a character no font has (unless `missing_glyphs: :replace`, as above). Set
122
- `metadata title:`. Transparency (a PNG logo with alpha) is allowed. Validate the file with
123
- veraPDF: `verapdf --format text -v --flavour 3b invoice_pdf.pdf` prints `PASS` (`brew install
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), which prints too, but not on the printer's own dot grid.
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:` (1 to 2 pt) and check the ZPL has its `^BQ` line.
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