stationery 0.8.0 → 0.9.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.
Files changed (47) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +11 -0
  3. data/README.md +128 -23
  4. data/lib/stationery/canvas.rb +3 -1
  5. data/lib/stationery/document.rb +20 -4
  6. data/lib/stationery/elements/forms.rb +11 -6
  7. data/lib/stationery/elements/rich.rb +13 -6
  8. data/lib/stationery/elements.rb +11 -3
  9. data/lib/stationery/fonts/font.rb +28 -18
  10. data/lib/stationery/forms/acro_form.rb +95 -25
  11. data/lib/stationery/forms/appearance.rb +92 -30
  12. data/lib/stationery/forms/field.rb +20 -11
  13. data/lib/stationery/forms/metrics.rb +3 -17
  14. data/lib/stationery/forms/typeface.rb +113 -0
  15. data/lib/stationery/html/document.rb +8 -2
  16. data/lib/stationery/html/tree_builder.rb +34 -4
  17. data/lib/stationery/layout/image.rb +9 -1
  18. data/lib/stationery/layout/table/cell.rb +14 -4
  19. data/lib/stationery/layout/table.rb +72 -14
  20. data/lib/stationery/markdown/block_parser/containers.rb +21 -0
  21. data/lib/stationery/markdown/block_parser.rb +9 -3
  22. data/lib/stationery/markdown/document.rb +8 -2
  23. data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
  24. data/lib/stationery/markdown/inline_parser/links.rb +17 -12
  25. data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
  26. data/lib/stationery/markdown/inline_parser.rb +9 -3
  27. data/lib/stationery/minitest.rb +4 -0
  28. data/lib/stationery/pdf/assembler.rb +19 -9
  29. data/lib/stationery/pdf/conformance.rb +7 -5
  30. data/lib/stationery/pdf/serializer.rb +3 -1
  31. data/lib/stationery/pdf/signature/cms.rb +87 -0
  32. data/lib/stationery/pdf/signature.rb +167 -0
  33. data/lib/stationery/pdf/types.rb +4 -0
  34. data/lib/stationery/rich/nesting.rb +30 -0
  35. data/lib/stationery/rich/renderer/indents.rb +42 -0
  36. data/lib/stationery/rich/renderer.rb +22 -6
  37. data/lib/stationery/svg/document.rb +8 -4
  38. data/lib/stationery/svg/parser.rb +51 -11
  39. data/lib/stationery/svg/walker.rb +2 -0
  40. data/lib/stationery/tagging/element.rb +1 -1
  41. data/lib/stationery/testing/inspector.rb +55 -0
  42. data/lib/stationery/testing/matchers.rb +25 -0
  43. data/lib/stationery/text/wrapper.rb +12 -3
  44. data/lib/stationery/version.rb +1 -1
  45. data/lib/stationery/warnings.rb +4 -0
  46. data/lib/stationery.rb +3 -0
  47. metadata +6 -1
@@ -3,14 +3,24 @@
3
3
  module Stationery
4
4
  module Forms
5
5
  # Collects the widgets pages place and writes the document's interactive
6
- # form: one field per name, parent fields for dotted names, the shared
7
- # Helvetica and ZapfDingbats resources and the catalog's /AcroForm.
6
+ # form: one field per name, parent fields for dotted names, the fonts the
7
+ # appearances draw with as default resources and the catalog's /AcroForm.
8
+ #
9
+ # The appearances themselves need no standard font: text is set in the
10
+ # document's embedded fonts and marks are paths. Only a form that asks
11
+ # viewers to regenerate appearances lists ZapfDingbats, which is what
12
+ # they redraw a check box or radio button's /MK caption with.
13
+ #
14
+ # A `signature:` (a PDF::Signature) becomes the value of the signature
15
+ # field it names, or of a field of its own whose widget nobody sees.
8
16
  class AcroForm
9
- FONTS = { Helv: :Helvetica, ZaDb: :ZapfDingbats }.freeze
17
+ SYMBOLS = { ZaDb: { Type: :Font, Subtype: :Type1, BaseFont: :ZapfDingbats }.freeze }.freeze
18
+ # SignaturesExist and AppendOnly: what a signed form tells viewers.
19
+ SIGNED = 3
10
20
 
11
- # A widget placed on a page: its field, PDF-space rect, page and the
12
- # reference the page's /Annots already points at.
13
- Widget = Data.define(:field, :rect, :page, :ref, :extra)
21
+ # A widget placed on a page: its field, appearance, PDF-space rect, page
22
+ # and the reference the page's /Annots already points at.
23
+ Widget = Data.define(:field, :appearance, :rect, :page, :ref, :extra)
14
24
 
15
25
  # A name segment: widgets when it is a field, children when a group.
16
26
  Node = Struct.new(:name, :widgets, :children)
@@ -24,35 +34,89 @@ module Stationery
24
34
  end
25
35
  end
26
36
 
27
- def initialize(writer)
37
+ # `fonts` are the document's embedded fonts by resource name.
38
+ # `need_appearances: false` leaves out the flag asking viewers to
39
+ # regenerate every appearance (PDF/A and PDF/UA forbid it, and a
40
+ # signed file must not be redrawn).
41
+ def initialize(writer, fonts: {}, need_appearances: true, signature: nil)
28
42
  @writer = writer
43
+ @embedded = fonts
44
+ @need_appearances = need_appearances
45
+ @signature = signature
29
46
  @widgets = []
30
47
  end
31
48
 
32
- # Reserves the widget annotation for `field` at `rect` on `page`.
33
- # `extra` entries for the widget dictionary may be computed from its
34
- # reference (a tagged PDF's /StructParent).
35
- def add(field, rect, page)
49
+ # Reserves the widget annotation (a page annotation holding the field
50
+ # as :widget, its :appearance and :rect) on `page`. `extra` entries for
51
+ # the widget dictionary may be computed from its reference (a tagged
52
+ # PDF's /StructParent).
53
+ def add(annotation, page)
36
54
  ref = @writer.reserve
37
- @widgets << Widget.new(field, rect, page, ref, block_given? ? yield(ref) : {})
55
+ appearance = annotation[:appearance] || Appearance.new(annotation[:widget], *size_of(annotation[:rect]))
56
+ @widgets << Widget.new(annotation[:widget], appearance, annotation[:rect], page, ref,
57
+ block_given? ? yield(ref) : {})
58
+ ref
59
+ end
60
+
61
+ # Reserves the widget of a signature that fills no field: without a
62
+ # size or an appearance, printable and locked, on `page`. `taken` are
63
+ # the names the document's fields use.
64
+ def sign(page, taken: [])
65
+ name = (1..).lazy.map { |number| "Signature#{number}" }.find { |candidate| !taken.include?(candidate) }
66
+ ref = @writer.reserve
67
+ @widgets << Widget.new(Field.new(:signature, name, label: ""), nil, [0, 0, 0, 0], page, ref, {})
38
68
  ref
39
69
  end
40
70
 
41
71
  # Writes every field; returns the /AcroForm dictionary, or nil without
42
- # fields.
72
+ # fields. /DR lists the fonts the appearances use, /DA selects the first.
43
73
  def write
74
+ @signed = signed
44
75
  return if @widgets.empty?
45
76
 
46
- @fonts = FONTS.transform_values { |base| @writer.add(font(base)) }
77
+ @fonts = fonts
47
78
  fields = tree.children.values.map { |node| write_node(node, nil) }
48
- { Fields: fields, NeedAppearances: true, DA: "/#{Field::FONT} 0 Tf 0 g", DR: { Font: @fonts } }
79
+ form = { Fields: fields }
80
+ form[:SigFlags] = SIGNED if @signed
81
+ form[:NeedAppearances] = true if @need_appearances
82
+ form[:DA] = "/#{@fonts.keys.first} 0 Tf 0 g" if @fonts.any?
83
+ resources = @fonts.merge(symbols)
84
+ form[:DR] = { Font: resources } if resources.any?
85
+ form
49
86
  end
50
87
 
51
88
  private
52
89
 
53
- def font(base)
54
- font = { Type: :Font, Subtype: :Type1, BaseFont: base }
55
- base == :Helvetica ? font.merge(Encoding: :WinAnsiEncoding) : font
90
+ def size_of(rect) = [rect[2] - rect[0], rect[3] - rect[1]]
91
+
92
+ # The signature dictionary's reference, once the field it fills is
93
+ # known to exist; nil without a signature.
94
+ def signed
95
+ return unless @signature
96
+ return @writer.add(@signature.dictionary) if @widgets.any? { |widget| signs?(widget) }
97
+
98
+ raise ArgumentError, %(sign field: names "#{@signature.field}", but the document has no such signature_field)
99
+ end
100
+
101
+ def signs?(widget)
102
+ return widget.appearance.nil? if @signature.invisible?
103
+
104
+ widget.field.kind == :signature && widget.field.name == @signature.field
105
+ end
106
+
107
+ def symbols
108
+ return {} unless @need_appearances && @widgets.any? { |widget| widget.field.type == :Btn }
109
+
110
+ SYMBOLS.transform_values { |font| @writer.add(font) }
111
+ end
112
+
113
+ # The fonts the appearances name: the embedded ones, and the standard
114
+ # Helvetica when a field without a font book draws with it.
115
+ def fonts
116
+ names = @widgets.flat_map { |widget| widget.appearance&.font_names.to_a }.uniq
117
+ names.to_h do |name|
118
+ [name, @embedded.fetch(name) { @writer.add(Standard::FONT) }]
119
+ end
56
120
  end
57
121
 
58
122
  def tree
@@ -91,21 +155,27 @@ module Stationery
91
155
  # field's kids. A radio group's value is its checked choice.
92
156
  def write_field(widgets, own)
93
157
  field = widgets.first.field
158
+ default_appearance = widgets.first.appearance&.default_appearance
159
+ value = @signed && signs?(widgets.first) ? { V: @signed } : {}
94
160
  unless field.radio? || !widgets.one?
95
- return @writer.set(widgets.first.ref, own.merge(field.field_entries, widget(widgets.first)))
161
+ entries = field.field_entries(default_appearance:)
162
+ return @writer.set(widgets.first.ref, own.merge(entries, value, widget(widgets.first)))
96
163
  end
97
164
 
98
165
  ref = @writer.reserve
99
- value = field.radio? ? widgets.map(&:field).find(&:checked?)&.on_state || :Off : nil
100
- widgets.each { |kid| @writer.set(kid.ref, widget(kid, value).merge(Parent: ref)) }
101
- entries = value ? field.field_entries(value) : field.field_entries
102
- @writer.set(ref, own.merge(entries, Kids: widgets.map(&:ref)))
166
+ choice = field.radio? ? widgets.map(&:field).find(&:checked?)&.on_state || :Off : nil
167
+ widgets.each { |kid| @writer.set(kid.ref, widget(kid, choice).merge(Parent: ref)) }
168
+ entries = choice ? field.field_entries(choice, default_appearance:) : field.field_entries(default_appearance:)
169
+ @writer.set(ref, own.merge(entries, value, Kids: widgets.map(&:ref)))
103
170
  end
104
171
 
105
172
  def widget(widget, group_value = nil)
106
- x1, y1, x2, y2 = widget.rect
173
+ unless widget.appearance
174
+ return { Type: :Annot, Subtype: :Widget, F: PDF::Signature::INVISIBLE, Rect: widget.rect, P: widget.page }
175
+ end
176
+
107
177
  state = group_value && (widget.field.on_state == group_value ? group_value : :Off)
108
- entries = widget.field.widget_entries(x2 - x1, y2 - y1, @fonts, state:)
178
+ entries = widget.field.widget_entries(widget.appearance, @fonts, state:)
109
179
  normal = entries.dig(:AP, :N)
110
180
  normal = normal.is_a?(Hash) ? normal.transform_values { |stream| @writer.add(stream) } : @writer.add(normal)
111
181
  entries.merge(AP: { N: normal }, Rect: widget.rect, P: widget.page, **widget.extra)
@@ -6,28 +6,49 @@ module Stationery
6
6
  # a form looks right without relying on the viewer to regenerate it.
7
7
  # Variable text sits between `/Tx BMC … EMC`, the part a viewer redraws
8
8
  # when the value changes.
9
+ #
10
+ # It is drawn when the widget is placed, so the glyphs it uses are in the
11
+ # fonts before they are subset, and written as streams once the fonts
12
+ # have their references (#streams).
9
13
  class Appearance
10
14
  PADDING = 2
11
15
  LEADING = 1.15
12
- CHECK = { glyph: "4", width: 0.846, middle: 0.345 }.freeze
16
+ # The check mark's corners in the unit square (y down) and its stroke.
17
+ CHECK = { points: [[0.22, 0.52], [0.42, 0.72], [0.78, 0.28]], stroke: 0.12 }.freeze
13
18
  DOT = 0.45
14
19
  SIGNATURE = { rule: 14, label_size: 7, label_baseline: 4, label_gray: 0.42 }.freeze
15
20
 
16
- def initialize(field, width, height, fonts)
21
+ # `resources` are the render's, where the field's fonts get their names.
22
+ def initialize(field, width, height, resources = nil)
17
23
  @field = field
18
24
  @width = width
19
25
  @height = height
20
- @fonts = fonts
26
+ @type = field.typeface.with(resources)
27
+ @contents = contents
28
+ keep
29
+ end
30
+
31
+ # Whether everything it draws with is embedded in the file.
32
+ def embedded? = @type.names.empty? || @type.embedded?
33
+
34
+ # The resource names of the fonts it draws with or keeps for editing.
35
+ def font_names = @type.names
36
+
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.
39
+ def default_appearance
40
+ return unless @field.variable_text?
41
+
42
+ "/#{@type.name} #{num(@field.font_size)} Tf 0 g"
21
43
  end
22
44
 
23
45
  # 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
46
+ # `fonts` are the references by resource name.
47
+ def streams(fonts)
48
+ resources = font_names.empty? ? {} : { Font: fonts.slice(*font_names) }
49
+ return stream(@contents, resources) unless @contents.is_a?(Hash)
50
+
51
+ @contents.transform_values { |content| stream(content, resources) }
31
52
  end
32
53
 
33
54
  private
@@ -36,9 +57,29 @@ module Stationery
36
57
  def size = @field.font_size
37
58
  def num(value) = PDF::Serializer.number(value.is_a?(Float) && value == value.round ? value.round : value)
38
59
 
39
- def stream(content)
60
+ def contents
61
+ case @field.kind
62
+ when :text, :select then frame + variable_text(text_lines)
63
+ when :checkbox then { @field.on_state => frame + check, Off: frame }
64
+ when :radio then { @field.on_state => circle + dot, Off: circle }
65
+ when :signature then signature
66
+ end
67
+ end
68
+
69
+ # What a viewer may draw after an edit: the other options of a select,
70
+ # and the base repertoire of a field that can be typed into.
71
+ def keep
72
+ return unless @field.variable_text?
73
+
74
+ @type.name
75
+ return if options[:read_only]
76
+
77
+ @type.keep(Array(options[:options]).join, size, repertoire: true)
78
+ end
79
+
80
+ def stream(content, resources)
40
81
  PDF::Stream.new(content, { Type: :XObject, Subtype: :Form, BBox: [0, 0, @width, @height],
41
- Resources: { Font: @fonts } })
82
+ Resources: resources })
42
83
  end
43
84
 
44
85
  def frame
@@ -50,34 +91,49 @@ module Stationery
50
91
  end
51
92
  end
52
93
 
53
- # [x, baseline, WinAnsi bytes] runs in PDF space.
94
+ def middle_baseline = (@height / 2.0) - ((@type.ascent(size) - @type.descent(size)) / 2)
95
+
96
+ # [x, baseline, text] runs in PDF space.
54
97
  def text_lines
55
98
  return comb_cells if options[:comb]
56
99
  return multiline_runs if options[:multiline]
57
100
 
58
- [[PADDING, (@height / 2.0) - (size * (Metrics::ASCENT + Metrics::DESCENT) / 2), Metrics.encode(@field.value)]]
101
+ [[PADDING, middle_baseline, @field.value.to_s]]
59
102
  end
60
103
 
61
104
  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|
105
+ top = @height - PADDING - @type.ascent(size)
106
+ wrap(@field.value.to_s, @width - (2 * PADDING)).each_with_index.map do |line, index|
64
107
  [PADDING, top - (index * size * LEADING), line]
65
108
  end
66
109
  end
67
110
 
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
124
+ end
125
+
68
126
  def comb_cells
69
127
  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]
128
+ @field.value.to_s[0, @field.max_length].chars.each_with_index.map do |char, index|
129
+ [(index * cell) + ((cell - @type.width(char, size)) / 2), middle_baseline, char]
73
130
  end
74
131
  end
75
132
 
76
133
  def variable_text(runs)
77
134
  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")
135
+ runs.reject { |_, _, text| text.empty? }.each do |x, y, text|
136
+ ops.push("BT", "#{num(x)} #{num(y)} Td", *@type.show(text, size), "ET")
81
137
  end
82
138
  ops.push("Q", "EMC").join("\n") << "\n"
83
139
  end
@@ -93,11 +149,11 @@ module Stationery
93
149
  def signature
94
150
  y = @height - SIGNATURE[:rule]
95
151
  content = draw { |canvas| canvas.line(PADDING, y, @width - PADDING, y, color: options[:border], width: 0.75) }
96
- label = Metrics.encode(options[:label])
152
+ label = options[:label].to_s
97
153
  return content if label.empty?
98
154
 
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"]
155
+ ops = ["q", "#{SIGNATURE[:label_gray]} g", "BT", "#{PADDING} #{SIGNATURE[:label_baseline]} Td",
156
+ *@type.show(label, SIGNATURE[:label_size]), "ET", "Q"]
101
157
  "#{content}#{ops.join("\n")}\n"
102
158
  end
103
159
 
@@ -107,12 +163,18 @@ module Stationery
107
163
  page.content
108
164
  end
109
165
 
166
+ # A check mark stroked as a path, so no symbol font is needed.
110
167
  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"
168
+ side = [@width, @height].min * 0.8
169
+ left = (@width - side) / 2.0
170
+ top = (@height - side) / 2.0
171
+ first, *rest = CHECK[:points].map { |x, y| [left + (x * side), top + (y * side)] }
172
+ draw do |canvas|
173
+ canvas.path(stroke: "#000000", line_width: side * CHECK[:stroke], cap: :round, join: :round) do |path|
174
+ path.move_to(*first)
175
+ rest.each { |point| path.line_to(*point) }
176
+ end
177
+ end
116
178
  end
117
179
  end
118
180
  end
@@ -6,26 +6,29 @@ module Stationery
6
6
  module Forms
7
7
  # One form field widget: its kind, full (dotted) name, value and options.
8
8
  # Widgets sharing a name form one field (a radio group); dotted names are
9
- # grouped under parent fields.
9
+ # grouped under parent fields. `typeface:` is what its appearance is set
10
+ # in: the document's fonts from the element DSL, the standard Helvetica
11
+ # otherwise. `tooltip:` is its accessible name (/TU), the name by default.
10
12
  class Field
11
13
  TYPES = { text: :Tx, checkbox: :Btn, radio: :Btn, select: :Ch, signature: :Sig }.freeze
12
14
  # Field flag bit positions (PDF 32000-1, 12.7.3.1 and 12.7.4).
13
15
  BITS = { read_only: 1, required: 2, multiline: 13, no_toggle_to_off: 15, radio: 16, combo: 18, edit: 19,
14
16
  comb: 25 }.freeze
15
17
  DEFAULTS = { font_size: 10, read_only: false, required: false, border: "#9CA3AF", background: "#FFFFFF",
16
- radius: 2 }.freeze
18
+ radius: 2, tooltip: nil }.freeze
17
19
  OPTIONS = {
18
20
  text: %i[multiline max_length comb], checkbox: [], radio: %i[checked], select: %i[options editable],
19
21
  signature: %i[label]
20
22
  }.freeze
21
- FONT = "Helv"
23
+ VARIABLE_TEXT = %i[text select].freeze
22
24
 
23
- attr_reader :kind, :name, :value, :options
25
+ attr_reader :kind, :name, :value, :options, :typeface
24
26
 
25
- def initialize(kind, name, value: nil, **options)
27
+ def initialize(kind, name, value: nil, typeface: Standard.new, **options)
26
28
  @kind = kind
27
29
  @name = validate_name(name.to_s)
28
30
  @value = value
31
+ @typeface = typeface
29
32
  unknown = options.keys - DEFAULTS.keys - OPTIONS.fetch(kind)
30
33
  raise ArgumentError, "unknown #{kind} field option: #{unknown.join(", ")}" if unknown.any?
31
34
 
@@ -36,7 +39,10 @@ module Stationery
36
39
  def segments = @name.split(".")
37
40
  def type = TYPES.fetch(@kind)
38
41
  def font_size = @options[:font_size]
39
- def default_appearance = "/#{FONT} #{PDF::Serializer.number(font_size)} Tf 0 g"
42
+ # Whether a viewer redraws its text when the value changes.
43
+ def variable_text? = VARIABLE_TEXT.include?(@kind)
44
+ # The accessible name: `tooltip:`, a signature's label, else the name.
45
+ def tooltip = (@options[:tooltip] || @options[:label]).to_s.strip.then { |text| text.empty? ? @name : text }
40
46
  def max_length = @options[:comb].is_a?(Integer) ? @options[:comb] : @options[:max_length]
41
47
  def radio? = @kind == :radio
42
48
  def checked? = radio? ? @options[:checked] == true : @value == true
@@ -52,8 +58,10 @@ module Stationery
52
58
 
53
59
  # The field-level entries; the widget's come from #widget_entries.
54
60
  # `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 }
61
+ # `default_appearance` is the /DA of a field with variable text.
62
+ def field_entries(value = field_value, default_appearance: nil)
63
+ entries = { FT: type, TU: PDF::TextString.new(tooltip) }
64
+ entries[:DA] = default_appearance if default_appearance
57
65
  entries[:Ff] = flags if flags.positive?
58
66
  entries[:V] = value unless value.nil?
59
67
  entries[:MaxLen] = max_length if max_length
@@ -70,9 +78,10 @@ module Stationery
70
78
  end
71
79
  end
72
80
 
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
81
+ # `appearance` is the widget's Appearance and `fonts` the references by
82
+ # resource name; `state` overrides the button's own appearance state.
83
+ def widget_entries(appearance, fonts, state: nil)
84
+ normal = appearance.streams(fonts)
76
85
  entries = { Type: :Annot, Subtype: :Widget, F: 4, AP: { N: normal } }
77
86
  entries[:MK] = appearance_characteristics unless @kind == :signature
78
87
  entries[:AS] = state || (checked? ? on_state : :Off) if normal.is_a?(Hash)
@@ -3,8 +3,9 @@
3
3
  module Stationery
4
4
  module Forms
5
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.
6
+ # printable ASCII range, enough to lay out the appearance of a field made
7
+ # without a font book (see Standard); other WinAnsi characters count as a
8
+ # digit's width.
8
9
  module Metrics
9
10
  ASCII = [
10
11
  278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, # space - /
@@ -29,21 +30,6 @@ module Stationery
29
30
  def width(bytes, size)
30
31
  bytes.each_byte.sum { |byte| (byte in 32..126) ? ASCII[byte - 32] : DEFAULT } * size / 1000.0
31
32
  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
33
  end
48
34
  end
49
35
  end
@@ -0,0 +1,113 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Forms
5
+ # What a field's appearance is set in. A field built by the element DSL
6
+ # draws with the document's own fonts (Typeface): embedded, subset and
7
+ # Identity-H encoded like any other text, with the book's fallbacks per
8
+ # character, so a value in any script the fonts cover renders. A field
9
+ # made without a font book (`Forms::Field.new` on a bare canvas) draws
10
+ # with the standard Helvetica in Windows-1252 (Standard).
11
+ #
12
+ # Both answer the same questions for an Appearance once bound to a
13
+ # render's resources with #with.
14
+ class Typeface
15
+ # What an editable field's font keeps beyond the value it shows, so the
16
+ # text a viewer redraws after an edit has its glyphs: printable ASCII
17
+ # and Latin-1. A read-only field keeps its value's glyphs only.
18
+ REPERTOIRE = [*0x20..0x7E, *0xA0..0xFF].pack("U*").freeze
19
+
20
+ def initialize(book, style, resources = nil)
21
+ @book = book
22
+ @style = style
23
+ @resources = resources
24
+ @names = {}
25
+ end
26
+
27
+ # This typeface naming its fonts in `resources` (a render's Resources).
28
+ def with(resources) = self.class.new(@book, @style, resources)
29
+
30
+ def embedded? = true
31
+ def ascent(size) = primary.ascender(size)
32
+ def descent(size) = primary.descender(size)
33
+ def width(text, size) = runs(text, size).sum { |font, run| font.width_of(run.text, size, ligatures: false) }
34
+
35
+ # The resource names of the fonts drawn or kept so far.
36
+ def names = @names.values
37
+
38
+ # The name the field's /DA selects: the font of the surrounding text.
39
+ def name = name_of(primary)
40
+
41
+ # The operators showing `text` at the current text position: a font
42
+ # selection and a string per run of one font. Characters no font has
43
+ # draw as .notdef and are reported, as in any other text.
44
+ def show(text, size)
45
+ runs(text, size).flat_map do |font, run|
46
+ report(font, run)
47
+ ["/#{name_of(font)} #{PDF::Serializer.number(size)} Tf", glyphs(font, run.text).to_operator]
48
+ end
49
+ end
50
+
51
+ # Keeps the glyphs of `text` in the embedded fonts without drawing it
52
+ # (a select's other options), and the REPERTOIRE when `repertoire:`.
53
+ def keep(text, size, repertoire: false)
54
+ runs(text, size).each do |font, run|
55
+ name_of(font)
56
+ glyphs(font, run.text)
57
+ end
58
+ return unless repertoire
59
+
60
+ name
61
+ glyphs(primary, REPERTOIRE.each_char.select { |char| primary.glyph?(char) }.join)
62
+ end
63
+
64
+ private
65
+
66
+ def primary = @primary ||= @book.resolve(@style).first
67
+ def name_of(font) = @names[font] ||= @resources.font(font)
68
+
69
+ # Marks the glyphs as used, so the subset embeds them.
70
+ def glyphs(font, text) = font.glyph_run(text, ligatures: false)
71
+
72
+ # [font, run] per stretch of `text` one font draws.
73
+ def runs(text, size)
74
+ return [] if text.empty?
75
+
76
+ @book.fallback([Text::Run.new(text, @style.with(size:))]).map { |run| [@book.resolve(run.style).first, run] }
77
+ end
78
+
79
+ def report(font, run)
80
+ run.text.each_char do |char|
81
+ next if Fonts::Fallback.carried?(char) || font.glyph?(char)
82
+
83
+ @book.warnings.missing_glyph(char, run.style.family)
84
+ end
85
+ end
86
+ end
87
+
88
+ # The standard Helvetica, not embedded: text is Windows-1252 bytes and
89
+ # characters outside it become "?".
90
+ class Standard
91
+ NAME = :Helv
92
+ FONT = { Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding }.freeze
93
+
94
+ def with(_resources) = self.class.new
95
+ def embedded? = false
96
+ def ascent(size) = size * Metrics::ASCENT
97
+ def descent(size) = size * -Metrics::DESCENT
98
+ def width(text, size) = Metrics.width(Metrics.encode(text), size)
99
+ def names = @named ? [NAME] : []
100
+ # Nothing to keep: a standard font is the viewer's, whole.
101
+ def keep(*, **) = nil
102
+
103
+ def name
104
+ @named = true
105
+ NAME
106
+ end
107
+
108
+ def show(text, size)
109
+ ["/#{name} #{PDF::Serializer.number(size)} Tf", "#{PDF::Serializer.literal(Metrics.encode(text))} Tj"]
110
+ end
111
+ end
112
+ end
113
+ end
@@ -1,14 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "../rich/nesting"
3
4
  require_relative "tokenizer"
4
5
  require_relative "tree_builder"
5
6
 
6
7
  module Stationery
7
8
  # Lenient HTML to rich-text blocks (see Stationery::Rich).
8
9
  module HTML
9
- def self.parse(source)
10
+ # Elements nested deeper than `max_depth` are flattened into the deepest
11
+ # one kept; the block is then given how deep the source went.
12
+ def self.parse(source, max_depth: Rich::Nesting::DEFAULT)
13
+ nesting = Rich::Nesting.new(max_depth)
10
14
  Stationery.instrument("parse.stationery", format: :html, bytes: source.bytesize) do
11
- TreeBuilder.parse(Tokenizer.tokenize(source))
15
+ TreeBuilder.parse(Tokenizer.tokenize(source), nesting).tap do
16
+ yield nesting.deepest if nesting.exceeded? && block_given?
17
+ end
12
18
  end
13
19
  end
14
20
  end