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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +11 -0
- data/README.md +128 -23
- data/lib/stationery/canvas.rb +3 -1
- data/lib/stationery/document.rb +20 -4
- data/lib/stationery/elements/forms.rb +11 -6
- data/lib/stationery/elements/rich.rb +13 -6
- data/lib/stationery/elements.rb +11 -3
- data/lib/stationery/fonts/font.rb +28 -18
- data/lib/stationery/forms/acro_form.rb +95 -25
- data/lib/stationery/forms/appearance.rb +92 -30
- data/lib/stationery/forms/field.rb +20 -11
- data/lib/stationery/forms/metrics.rb +3 -17
- data/lib/stationery/forms/typeface.rb +113 -0
- data/lib/stationery/html/document.rb +8 -2
- data/lib/stationery/html/tree_builder.rb +34 -4
- data/lib/stationery/layout/image.rb +9 -1
- data/lib/stationery/layout/table/cell.rb +14 -4
- data/lib/stationery/layout/table.rb +72 -14
- data/lib/stationery/markdown/block_parser/containers.rb +21 -0
- data/lib/stationery/markdown/block_parser.rb +9 -3
- data/lib/stationery/markdown/document.rb +8 -2
- data/lib/stationery/markdown/inline_parser/emphasis.rb +17 -7
- data/lib/stationery/markdown/inline_parser/links.rb +17 -12
- data/lib/stationery/markdown/inline_parser/nodes.rb +24 -10
- data/lib/stationery/markdown/inline_parser.rb +9 -3
- data/lib/stationery/minitest.rb +4 -0
- data/lib/stationery/pdf/assembler.rb +19 -9
- data/lib/stationery/pdf/conformance.rb +7 -5
- data/lib/stationery/pdf/serializer.rb +3 -1
- data/lib/stationery/pdf/signature/cms.rb +87 -0
- data/lib/stationery/pdf/signature.rb +167 -0
- data/lib/stationery/pdf/types.rb +4 -0
- data/lib/stationery/rich/nesting.rb +30 -0
- data/lib/stationery/rich/renderer/indents.rb +42 -0
- data/lib/stationery/rich/renderer.rb +22 -6
- data/lib/stationery/svg/document.rb +8 -4
- data/lib/stationery/svg/parser.rb +51 -11
- data/lib/stationery/svg/walker.rb +2 -0
- data/lib/stationery/tagging/element.rb +1 -1
- data/lib/stationery/testing/inspector.rb +55 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/text/wrapper.rb +12 -3
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +4 -0
- data/lib/stationery.rb +3 -0
- 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
|
|
7
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
33
|
-
#
|
|
34
|
-
# reference (a tagged
|
|
35
|
-
|
|
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
|
-
|
|
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 =
|
|
77
|
+
@fonts = fonts
|
|
47
78
|
fields = tree.children.values.map { |node| write_node(node, nil) }
|
|
48
|
-
{ Fields: fields
|
|
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
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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
|
-
|
|
100
|
-
widgets.each { |kid| @writer.set(kid.ref, widget(kid,
|
|
101
|
-
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
@
|
|
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
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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:
|
|
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
|
-
|
|
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,
|
|
101
|
+
[[PADDING, middle_baseline, @field.value.to_s]]
|
|
59
102
|
end
|
|
60
103
|
|
|
61
104
|
def multiline_runs
|
|
62
|
-
top = @height - PADDING - (size
|
|
63
|
-
|
|
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
|
-
|
|
71
|
-
|
|
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 { |_, _,
|
|
79
|
-
ops.push("BT", "
|
|
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 =
|
|
152
|
+
label = options[:label].to_s
|
|
97
153
|
return content if label.empty?
|
|
98
154
|
|
|
99
|
-
ops = ["q", "#{SIGNATURE[:label_gray]} g", "BT", "
|
|
100
|
-
|
|
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
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
|
|
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
|
-
# `
|
|
74
|
-
|
|
75
|
-
|
|
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
|
|
7
|
-
# WinAnsi characters count as a
|
|
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
|
-
|
|
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
|