stationery 0.2.0 → 0.4.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 +100 -1
- data/README.md +143 -12
- data/lib/stationery/builder.rb +2 -1
- data/lib/stationery/canvas/debug.rb +2 -1
- data/lib/stationery/canvas/marking.rb +124 -0
- data/lib/stationery/canvas/text.rb +5 -4
- data/lib/stationery/canvas.rb +37 -8
- data/lib/stationery/document.rb +35 -10
- data/lib/stationery/elements/forms.rb +55 -0
- data/lib/stationery/elements/lists.rb +15 -5
- data/lib/stationery/elements.rb +10 -7
- data/lib/stationery/fonts/font.rb +36 -20
- data/lib/stationery/fonts/font_book.rb +3 -0
- data/lib/stationery/fonts/glyph_run.rb +4 -3
- data/lib/stationery/fonts/gpos.rb +11 -9
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -0
- data/lib/stationery/fonts/ligatures.rb +16 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- data/lib/stationery/fonts/to_unicode.rb +1 -1
- data/lib/stationery/fonts/true_type.rb +36 -12
- data/lib/stationery/fonts/woff.rb +52 -0
- data/lib/stationery/forms/acro_form.rb +115 -0
- data/lib/stationery/forms/appearance.rb +119 -0
- data/lib/stationery/forms/field.rb +122 -0
- data/lib/stationery/forms/metrics.rb +49 -0
- data/lib/stationery/layout/box.rb +34 -11
- data/lib/stationery/layout/field.rb +51 -0
- data/lib/stationery/layout/flow.rb +17 -9
- data/lib/stationery/layout/image.rb +4 -2
- data/lib/stationery/layout/list_item.rb +15 -6
- data/lib/stationery/layout/node.rb +2 -0
- data/lib/stationery/layout/paginator.rb +3 -2
- data/lib/stationery/layout/row.rb +1 -1
- data/lib/stationery/layout/svg.rb +9 -2
- data/lib/stationery/layout/table/cell.rb +10 -2
- data/lib/stationery/layout/table.rb +42 -10
- data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
- data/lib/stationery/layout/table_of_contents.rb +1 -1
- data/lib/stationery/layout/text.rb +6 -4
- data/lib/stationery/minitest.rb +2 -0
- data/lib/stationery/page.rb +5 -2
- data/lib/stationery/page_templates.rb +7 -4
- data/lib/stationery/pdf/assembler.rb +38 -5
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/serializer.rb +17 -9
- data/lib/stationery/pdf/writer.rb +24 -5
- data/lib/stationery/resources.rb +8 -2
- data/lib/stationery/rich/renderer.rb +3 -3
- data/lib/stationery/structure.rb +15 -9
- data/lib/stationery/svg/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +48 -18
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +24 -4
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/style.rb +37 -20
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +11 -0
- data/lib/stationery/tagging/element.rb +74 -0
- data/lib/stationery/tagging/tree.rb +43 -0
- data/lib/stationery/tagging/writer.rb +81 -0
- data/lib/stationery/testing/inspector.rb +15 -0
- data/lib/stationery/testing/marked_text.rb +60 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/testing/structure_reader.rb +71 -0
- data/lib/stationery/text/paragraph.rb +28 -12
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +2 -1
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +26 -0
- metadata +29 -1
|
@@ -2,21 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Fonts
|
|
5
|
-
# Process-wide cache of parsed TrueType files, keyed by path and
|
|
6
|
-
# edited font is re-read. Parsing is the expensive part; the
|
|
7
|
-
# Font objects that track used glyphs are cheap wrappers
|
|
5
|
+
# Process-wide cache of parsed TrueType files, keyed by path, face and
|
|
6
|
+
# mtime so an edited font is re-read. Parsing is the expensive part; the
|
|
7
|
+
# per-document Font objects that track used glyphs are cheap wrappers
|
|
8
|
+
# around a parse. A `#N` suffix on the path picks face N of a collection.
|
|
8
9
|
module Registry
|
|
9
10
|
SIZE = 16
|
|
11
|
+
FACE = /\A(.+)#(\d+)\z/
|
|
10
12
|
@cache = {}
|
|
11
13
|
@mutex = Mutex.new
|
|
12
14
|
|
|
13
15
|
class << self
|
|
14
16
|
def load(path)
|
|
15
|
-
path =
|
|
17
|
+
path, index = split(path.to_s)
|
|
18
|
+
path = File.expand_path(path)
|
|
16
19
|
raise UnsupportedFont, "font file not found: #{path}" unless File.file?(path)
|
|
17
20
|
|
|
18
21
|
mtime = File.mtime(path)
|
|
19
|
-
@mutex.synchronize { fetch(path, mtime) }
|
|
22
|
+
@mutex.synchronize { fetch(path, index, mtime) }
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
def clear
|
|
@@ -25,10 +28,16 @@ module Stationery
|
|
|
25
28
|
|
|
26
29
|
private
|
|
27
30
|
|
|
28
|
-
def
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
31
|
+
def split(path)
|
|
32
|
+
match = FACE.match(path)
|
|
33
|
+
match ? [match[1], match[2].to_i] : [path, 0]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def fetch(path, index, mtime)
|
|
37
|
+
key = [path, index]
|
|
38
|
+
cached_mtime, ttf = @cache.delete(key)
|
|
39
|
+
ttf = TrueType.new(File.binread(path), index:) unless cached_mtime == mtime
|
|
40
|
+
@cache[key] = [mtime, ttf]
|
|
32
41
|
@cache.shift while @cache.size > SIZE
|
|
33
42
|
ttf
|
|
34
43
|
end
|
|
@@ -7,7 +7,7 @@ module Stationery
|
|
|
7
7
|
module ToUnicode
|
|
8
8
|
module_function
|
|
9
9
|
|
|
10
|
-
# `chars` is { code =>
|
|
10
|
+
# `chars` is { code => text }; a ligature maps to several characters.
|
|
11
11
|
def cmap(chars)
|
|
12
12
|
mappings = chars.sort.map do |code, char|
|
|
13
13
|
format("<%<code>04X> <%<utf16>s>", code:, utf16: char.encode(Encoding::UTF_16BE).unpack1("H*").upcase)
|
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Fonts
|
|
5
5
|
# Reads the sfnt tables of a static font needed to measure text and to
|
|
6
|
-
# embed it: TrueType outlines (.ttf) or CFF outlines (.otf)
|
|
7
|
-
#
|
|
8
|
-
# reason.
|
|
6
|
+
# embed it: TrueType outlines (.ttf) or CFF outlines (.otf), alone or as one
|
|
7
|
+
# face of a collection (.ttc), or unwrapped from a WOFF web font. Variable
|
|
8
|
+
# CFF2 fonts and WOFF2 are rejected with a named reason.
|
|
9
9
|
class TrueType
|
|
10
10
|
include SfntMetrics
|
|
11
11
|
|
|
@@ -13,10 +13,9 @@ module Stationery
|
|
|
13
13
|
OUTLINE_TABLES = { "OTTO" => ["CFF "] }.freeze
|
|
14
14
|
GLYF_TABLES = %w[loca glyf].freeze
|
|
15
15
|
SFNT_VERSIONS = ["\x00\x01\x00\x00".b, "true", "OTTO"].freeze
|
|
16
|
+
COLLECTION = "ttcf"
|
|
16
17
|
SIGNATURES = {
|
|
17
|
-
"
|
|
18
|
-
"wOFF" => "WOFF web fonts are not supported, use the TrueType (.ttf) file",
|
|
19
|
-
"wOF2" => "WOFF2 web fonts are not supported, use the TrueType (.ttf) file"
|
|
18
|
+
"wOF2" => "WOFF2 needs Brotli; convert to .ttf or .woff"
|
|
20
19
|
}.freeze
|
|
21
20
|
|
|
22
21
|
attr_reader :data, :tables, :units_per_em, :bbox, :ascender, :descender, :line_gap, :num_glyphs,
|
|
@@ -24,9 +23,12 @@ module Stationery
|
|
|
24
23
|
:strikeout_position, :strikeout_size, :cap_height, :x_height, :weight, :postscript_name
|
|
25
24
|
|
|
26
25
|
# A subset embedded in a PDF carries no cmap (the PDF maps glyphs itself),
|
|
27
|
-
# so reading one back passes `cmap: false`.
|
|
28
|
-
|
|
26
|
+
# so reading one back passes `cmap: false`. `index:` picks a face of a
|
|
27
|
+
# collection, face 0 by default.
|
|
28
|
+
def initialize(data, cmap: true, index: nil)
|
|
29
29
|
@data = data.b
|
|
30
|
+
@data = WOFF.unpack(@data) if @data.start_with?(WOFF::SIGNATURE)
|
|
31
|
+
@sfnt = face_offset(index || 0)
|
|
30
32
|
check_signature
|
|
31
33
|
read_table_directory(cmap)
|
|
32
34
|
parse_head
|
|
@@ -40,6 +42,12 @@ module Stationery
|
|
|
40
42
|
@postscript_name = NameTable.postscript_name(self)
|
|
41
43
|
end
|
|
42
44
|
|
|
45
|
+
def self.collection?(data) = data.byteslice(0, 4) == COLLECTION
|
|
46
|
+
|
|
47
|
+
def self.faces(data)
|
|
48
|
+
collection?(data) ? data.byteslice(8, 4).unpack1("N") : 1
|
|
49
|
+
end
|
|
50
|
+
|
|
43
51
|
def inspect = "#<#{self.class} #{@postscript_name} glyphs=#{@num_glyphs}>"
|
|
44
52
|
|
|
45
53
|
def glyph_id(codepoint)
|
|
@@ -68,6 +76,11 @@ module Stationery
|
|
|
68
76
|
@kerning ||= Kerning.for(self)
|
|
69
77
|
end
|
|
70
78
|
|
|
79
|
+
# Standard ligatures, read on first use and shared like #kerning.
|
|
80
|
+
def ligatures
|
|
81
|
+
@ligatures ||= Ligatures.for(self)
|
|
82
|
+
end
|
|
83
|
+
|
|
71
84
|
def cff?
|
|
72
85
|
@tables.key?("CFF ")
|
|
73
86
|
end
|
|
@@ -106,8 +119,19 @@ module Stationery
|
|
|
106
119
|
|
|
107
120
|
private
|
|
108
121
|
|
|
122
|
+
# Table offsets in a collection are already absolute, so a face is read
|
|
123
|
+
# from the shared data at its own table directory.
|
|
124
|
+
def face_offset(index)
|
|
125
|
+
count = self.class.faces(@data)
|
|
126
|
+
unless index.between?(0, count - 1)
|
|
127
|
+
raise ArgumentError, "font face #{index} is out of range (numFonts #{count})"
|
|
128
|
+
end
|
|
129
|
+
|
|
130
|
+
self.class.collection?(@data) ? u32(12 + (index * 4)) : 0
|
|
131
|
+
end
|
|
132
|
+
|
|
109
133
|
def check_signature
|
|
110
|
-
signature = @data.byteslice(
|
|
134
|
+
signature = @data.byteslice(@sfnt, 4)
|
|
111
135
|
return if SFNT_VERSIONS.include?(signature)
|
|
112
136
|
|
|
113
137
|
raise UnsupportedFont, SIGNATURES.fetch(signature, "not a TrueType font")
|
|
@@ -115,13 +139,13 @@ module Stationery
|
|
|
115
139
|
|
|
116
140
|
def read_table_directory(cmap)
|
|
117
141
|
@tables = {}
|
|
118
|
-
u16(4).times do |i|
|
|
119
|
-
record = 12 + (i * 16)
|
|
142
|
+
u16(@sfnt + 4).times do |i|
|
|
143
|
+
record = @sfnt + 12 + (i * 16)
|
|
120
144
|
@tables[@data.byteslice(record, 4)] = [u32(record + 8), u32(record + 12)]
|
|
121
145
|
end
|
|
122
146
|
raise UnsupportedFont, "variable CFF2 fonts are not supported" if @tables.key?("CFF2")
|
|
123
147
|
|
|
124
|
-
required = REQUIRED_TABLES + OUTLINE_TABLES.fetch(@data.byteslice(
|
|
148
|
+
required = REQUIRED_TABLES + OUTLINE_TABLES.fetch(@data.byteslice(@sfnt, 4), GLYF_TABLES)
|
|
125
149
|
required += ["cmap"] if cmap
|
|
126
150
|
missing = required.reject { |tag| @tables.key?(tag) }
|
|
127
151
|
raise UnsupportedFont, "font is missing the #{missing.map(&:strip).join(", ")} table" if missing.any?
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "zlib"
|
|
4
|
+
|
|
5
|
+
module Stationery
|
|
6
|
+
module Fonts
|
|
7
|
+
# Unwraps a WOFF 1.0 web font into the sfnt it was made from: each table is
|
|
8
|
+
# inflated (or copied when stored) and laid out behind a fresh table
|
|
9
|
+
# directory, 4-byte aligned in directory order. Metadata and private data
|
|
10
|
+
# blocks are dropped.
|
|
11
|
+
module WOFF
|
|
12
|
+
SIGNATURE = "wOFF"
|
|
13
|
+
HEADER = 44
|
|
14
|
+
ENTRY = 20
|
|
15
|
+
|
|
16
|
+
module_function
|
|
17
|
+
|
|
18
|
+
def unpack(data)
|
|
19
|
+
data = data.b
|
|
20
|
+
flavor = data.byteslice(4, 4)
|
|
21
|
+
tables = Array.new(data.byteslice(12, 2).unpack1("n")) do |i|
|
|
22
|
+
tag, offset, compressed, length, checksum = data.byteslice(HEADER + (i * ENTRY), ENTRY).unpack("a4NNNN")
|
|
23
|
+
[tag, checksum, table(tag, data.byteslice(offset, compressed), length)]
|
|
24
|
+
end
|
|
25
|
+
assemble(flavor, tables)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def table(tag, bytes, length)
|
|
29
|
+
return bytes if bytes.bytesize >= length
|
|
30
|
+
|
|
31
|
+
Zlib::Inflate.inflate(bytes)
|
|
32
|
+
rescue Zlib::Error
|
|
33
|
+
raise UnsupportedFont, "corrupt WOFF table #{tag}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def assemble(flavor, tables)
|
|
37
|
+
entry_selector = Math.log2(tables.size).floor
|
|
38
|
+
search_range = (2**entry_selector) * 16
|
|
39
|
+
directory = [flavor, tables.size, search_range, entry_selector, (tables.size * 16) - search_range]
|
|
40
|
+
.pack("a4nnnn")
|
|
41
|
+
body = String.new(encoding: Encoding::BINARY)
|
|
42
|
+
offset = 12 + (tables.size * 16)
|
|
43
|
+
|
|
44
|
+
tables.each do |tag, checksum, bytes|
|
|
45
|
+
directory << [tag, checksum, offset + body.bytesize, bytes.bytesize].pack("a4NNN")
|
|
46
|
+
body << bytes << ("\0".b * (-bytes.bytesize % 4))
|
|
47
|
+
end
|
|
48
|
+
directory << body
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Forms
|
|
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.
|
|
8
|
+
class AcroForm
|
|
9
|
+
FONTS = { Helv: :Helvetica, ZaDb: :ZapfDingbats }.freeze
|
|
10
|
+
|
|
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)
|
|
14
|
+
|
|
15
|
+
# A name segment: widgets when it is a field, children when a group.
|
|
16
|
+
Node = Struct.new(:name, :widgets, :children)
|
|
17
|
+
|
|
18
|
+
# { full name => value } for every field widget on `pages`.
|
|
19
|
+
def self.values(pages)
|
|
20
|
+
pages.flat_map(&:annotations).each_with_object({}) do |annotation, values|
|
|
21
|
+
field = annotation[:widget] or next
|
|
22
|
+
value = field.data_value
|
|
23
|
+
values[field.name] = value unless value.nil? && values.key?(field.name)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def initialize(writer)
|
|
28
|
+
@writer = writer
|
|
29
|
+
@widgets = []
|
|
30
|
+
end
|
|
31
|
+
|
|
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)
|
|
36
|
+
ref = @writer.reserve
|
|
37
|
+
@widgets << Widget.new(field, rect, page, ref, block_given? ? yield(ref) : {})
|
|
38
|
+
ref
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Writes every field; returns the /AcroForm dictionary, or nil without
|
|
42
|
+
# fields.
|
|
43
|
+
def write
|
|
44
|
+
return if @widgets.empty?
|
|
45
|
+
|
|
46
|
+
@fonts = FONTS.transform_values { |base| @writer.add(font(base)) }
|
|
47
|
+
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 } }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def font(base)
|
|
54
|
+
font = { Type: :Font, Subtype: :Type1, BaseFont: base }
|
|
55
|
+
base == :Helvetica ? font.merge(Encoding: :WinAnsiEncoding) : font
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def tree
|
|
59
|
+
root = Node.new(nil, [], {})
|
|
60
|
+
@widgets.each do |widget|
|
|
61
|
+
node = widget.field.segments.reduce(root) do |parent, segment|
|
|
62
|
+
parent.children[segment] ||= Node.new(segment, [], {})
|
|
63
|
+
end
|
|
64
|
+
node.widgets << widget
|
|
65
|
+
end
|
|
66
|
+
root.children.each_value { |node| validate(node, node.name) }
|
|
67
|
+
root
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def validate(node, path)
|
|
71
|
+
if node.widgets.any? && node.children.any?
|
|
72
|
+
raise ArgumentError, "field name #{path.inspect} is both a field and a group"
|
|
73
|
+
end
|
|
74
|
+
if node.widgets.map { |widget| widget.field.kind }.uniq.size > 1
|
|
75
|
+
raise ArgumentError, "field name #{path.inspect} is used by fields of different kinds"
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
node.children.each_value { |child| validate(child, "#{path}.#{child.name}") }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def write_node(node, parent)
|
|
82
|
+
own = { T: PDF::TextString.new(node.name) }
|
|
83
|
+
own[:Parent] = parent if parent
|
|
84
|
+
return write_field(node.widgets, own) if node.widgets.any?
|
|
85
|
+
|
|
86
|
+
ref = @writer.reserve
|
|
87
|
+
@writer.set(ref, own.merge(Kids: node.children.values.map { |child| write_node(child, ref) }))
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# One widget is merged with its field; several, or radios, become the
|
|
91
|
+
# field's kids. A radio group's value is its checked choice.
|
|
92
|
+
def write_field(widgets, own)
|
|
93
|
+
field = widgets.first.field
|
|
94
|
+
unless field.radio? || !widgets.one?
|
|
95
|
+
return @writer.set(widgets.first.ref, own.merge(field.field_entries, widget(widgets.first)))
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
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)))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def widget(widget, group_value = nil)
|
|
106
|
+
x1, y1, x2, y2 = widget.rect
|
|
107
|
+
state = group_value && (widget.field.on_state == group_value ? group_value : :Off)
|
|
108
|
+
entries = widget.field.widget_entries(x2 - x1, y2 - y1, @fonts, state:)
|
|
109
|
+
normal = entries.dig(:AP, :N)
|
|
110
|
+
normal = normal.is_a?(Hash) ? normal.transform_values { |stream| @writer.add(stream) } : @writer.add(normal)
|
|
111
|
+
entries.merge(AP: { N: normal }, Rect: widget.rect, P: widget.page, **widget.extra)
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Forms
|
|
5
|
+
# A widget's normal appearance: the form XObject(s) every viewer draws, so
|
|
6
|
+
# a form looks right without relying on the viewer to regenerate it.
|
|
7
|
+
# Variable text sits between `/Tx BMC … EMC`, the part a viewer redraws
|
|
8
|
+
# when the value changes.
|
|
9
|
+
class Appearance
|
|
10
|
+
PADDING = 2
|
|
11
|
+
LEADING = 1.15
|
|
12
|
+
CHECK = { glyph: "4", width: 0.846, middle: 0.345 }.freeze
|
|
13
|
+
DOT = 0.45
|
|
14
|
+
SIGNATURE = { rule: 14, label_size: 7, label_baseline: 4, label_gray: 0.42 }.freeze
|
|
15
|
+
|
|
16
|
+
def initialize(field, width, height, fonts)
|
|
17
|
+
@field = field
|
|
18
|
+
@width = width
|
|
19
|
+
@height = height
|
|
20
|
+
@fonts = fonts
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# One stream, or a Hash of streams by appearance state for buttons.
|
|
24
|
+
def normal
|
|
25
|
+
case @field.kind
|
|
26
|
+
when :text, :select then stream(frame + variable_text(text_lines))
|
|
27
|
+
when :checkbox then { @field.on_state => stream(frame + check), Off: stream(frame) }
|
|
28
|
+
when :radio then { @field.on_state => stream(circle + dot), Off: stream(circle) }
|
|
29
|
+
when :signature then stream(signature)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
private
|
|
34
|
+
|
|
35
|
+
def options = @field.options
|
|
36
|
+
def size = @field.font_size
|
|
37
|
+
def num(value) = PDF::Serializer.number(value.is_a?(Float) && value == value.round ? value.round : value)
|
|
38
|
+
|
|
39
|
+
def stream(content)
|
|
40
|
+
PDF::Stream.new(content, { Type: :XObject, Subtype: :Form, BBox: [0, 0, @width, @height],
|
|
41
|
+
Resources: { Font: @fonts } })
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def frame
|
|
45
|
+
return +"" unless options[:background] || options[:border]
|
|
46
|
+
|
|
47
|
+
draw do |canvas|
|
|
48
|
+
canvas.rounded_rect(0.5, 0.5, @width - 1, @height - 1,
|
|
49
|
+
radius: options[:radius], fill: options[:background], stroke: options[:border])
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# [x, baseline, WinAnsi bytes] runs in PDF space.
|
|
54
|
+
def text_lines
|
|
55
|
+
return comb_cells if options[:comb]
|
|
56
|
+
return multiline_runs if options[:multiline]
|
|
57
|
+
|
|
58
|
+
[[PADDING, (@height / 2.0) - (size * (Metrics::ASCENT + Metrics::DESCENT) / 2), Metrics.encode(@field.value)]]
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def multiline_runs
|
|
62
|
+
top = @height - PADDING - (size * Metrics::ASCENT)
|
|
63
|
+
Metrics.wrap(@field.value, @width - (2 * PADDING), size).each_with_index.map do |line, index|
|
|
64
|
+
[PADDING, top - (index * size * LEADING), line]
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def comb_cells
|
|
69
|
+
cell = @width.fdiv(@field.max_length)
|
|
70
|
+
baseline = (@height / 2.0) - (size * (Metrics::ASCENT + Metrics::DESCENT) / 2)
|
|
71
|
+
Metrics.encode(@field.value)[0, @field.max_length].chars.each_with_index.map do |char, index|
|
|
72
|
+
[(index * cell) + ((cell - Metrics.width(char, size)) / 2), baseline, char]
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def variable_text(runs)
|
|
77
|
+
ops = ["/Tx BMC", "q", "1 1 #{num(@width - 2)} #{num(@height - 2)} re W n", "0 g"]
|
|
78
|
+
runs.reject { |_, _, bytes| bytes.empty? }.each do |x, y, bytes|
|
|
79
|
+
ops.push("BT", "/#{Field::FONT} #{num(size)} Tf", "#{num(x)} #{num(y)} Td",
|
|
80
|
+
"#{PDF::Serializer.literal(bytes)} Tj", "ET")
|
|
81
|
+
end
|
|
82
|
+
ops.push("Q", "EMC").join("\n") << "\n"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def circle
|
|
86
|
+
draw { |canvas| canvas.circle(*center, radius - 0.5, fill: options[:background], stroke: options[:border]) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def dot = draw { |canvas| canvas.circle(*center, radius * DOT, fill: "#000000") }
|
|
90
|
+
def center = [@width / 2.0, @height / 2.0]
|
|
91
|
+
def radius = [@width, @height].min / 2.0
|
|
92
|
+
|
|
93
|
+
def signature
|
|
94
|
+
y = @height - SIGNATURE[:rule]
|
|
95
|
+
content = draw { |canvas| canvas.line(PADDING, y, @width - PADDING, y, color: options[:border], width: 0.75) }
|
|
96
|
+
label = Metrics.encode(options[:label])
|
|
97
|
+
return content if label.empty?
|
|
98
|
+
|
|
99
|
+
ops = ["q", "#{SIGNATURE[:label_gray]} g", "BT", "/#{Field::FONT} #{SIGNATURE[:label_size]} Tf",
|
|
100
|
+
"#{PADDING} #{SIGNATURE[:label_baseline]} Td", "#{PDF::Serializer.literal(label)} Tj", "ET", "Q"]
|
|
101
|
+
"#{content}#{ops.join("\n")}\n"
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def draw
|
|
105
|
+
page = Page.new(size: [@width, @height])
|
|
106
|
+
yield Canvas.new(page, nil)
|
|
107
|
+
page.content
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def check
|
|
111
|
+
glyph = [@width, @height].min * 0.8
|
|
112
|
+
x = (@width - (glyph * CHECK[:width])) / 2
|
|
113
|
+
y = (@height / 2.0) - (glyph * CHECK[:middle])
|
|
114
|
+
["q", "0 g", "BT", "/ZaDb #{num(glyph)} Tf", "#{num(x)} #{num(y)} Td", "(#{CHECK[:glyph]}) Tj", "ET", "Q"]
|
|
115
|
+
.join("\n") << "\n"
|
|
116
|
+
end
|
|
117
|
+
end
|
|
118
|
+
end
|
|
119
|
+
end
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Interactive form fields (AcroForm): what each one holds and how it is
|
|
5
|
+
# written, apart from where the layout puts it.
|
|
6
|
+
module Forms
|
|
7
|
+
# One form field widget: its kind, full (dotted) name, value and options.
|
|
8
|
+
# Widgets sharing a name form one field (a radio group); dotted names are
|
|
9
|
+
# grouped under parent fields.
|
|
10
|
+
class Field
|
|
11
|
+
TYPES = { text: :Tx, checkbox: :Btn, radio: :Btn, select: :Ch, signature: :Sig }.freeze
|
|
12
|
+
# Field flag bit positions (PDF 32000-1, 12.7.3.1 and 12.7.4).
|
|
13
|
+
BITS = { read_only: 1, required: 2, multiline: 13, no_toggle_to_off: 15, radio: 16, combo: 18, edit: 19,
|
|
14
|
+
comb: 25 }.freeze
|
|
15
|
+
DEFAULTS = { font_size: 10, read_only: false, required: false, border: "#9CA3AF", background: "#FFFFFF",
|
|
16
|
+
radius: 2 }.freeze
|
|
17
|
+
OPTIONS = {
|
|
18
|
+
text: %i[multiline max_length comb], checkbox: [], radio: %i[checked], select: %i[options editable],
|
|
19
|
+
signature: %i[label]
|
|
20
|
+
}.freeze
|
|
21
|
+
FONT = "Helv"
|
|
22
|
+
|
|
23
|
+
attr_reader :kind, :name, :value, :options
|
|
24
|
+
|
|
25
|
+
def initialize(kind, name, value: nil, **options)
|
|
26
|
+
@kind = kind
|
|
27
|
+
@name = validate_name(name.to_s)
|
|
28
|
+
@value = value
|
|
29
|
+
unknown = options.keys - DEFAULTS.keys - OPTIONS.fetch(kind)
|
|
30
|
+
raise ArgumentError, "unknown #{kind} field option: #{unknown.join(", ")}" if unknown.any?
|
|
31
|
+
|
|
32
|
+
@options = DEFAULTS.merge(options)
|
|
33
|
+
validate_comb
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def segments = @name.split(".")
|
|
37
|
+
def type = TYPES.fetch(@kind)
|
|
38
|
+
def font_size = @options[:font_size]
|
|
39
|
+
def default_appearance = "/#{FONT} #{PDF::Serializer.number(font_size)} Tf 0 g"
|
|
40
|
+
def max_length = @options[:comb].is_a?(Integer) ? @options[:comb] : @options[:max_length]
|
|
41
|
+
def radio? = @kind == :radio
|
|
42
|
+
def checked? = radio? ? @options[:checked] == true : @value == true
|
|
43
|
+
|
|
44
|
+
# The on-state of a button: /Yes, or a radio's export value.
|
|
45
|
+
def on_state = radio? ? @value.to_s.to_sym : :Yes
|
|
46
|
+
|
|
47
|
+
def flags
|
|
48
|
+
set = %i[read_only required].select { |key| @options[key] }
|
|
49
|
+
set += kind_flags
|
|
50
|
+
set.sum { |key| 1 << (BITS.fetch(key) - 1) }
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# The field-level entries; the widget's come from #widget_entries.
|
|
54
|
+
# `value` overrides this widget's own: a radio group's checked choice.
|
|
55
|
+
def field_entries(value = field_value)
|
|
56
|
+
entries = { FT: type, DA: default_appearance }
|
|
57
|
+
entries[:Ff] = flags if flags.positive?
|
|
58
|
+
entries[:V] = value unless value.nil?
|
|
59
|
+
entries[:MaxLen] = max_length if max_length
|
|
60
|
+
entries[:Opt] = @options[:options].map { |option| PDF::TextString.new(option.to_s) } if @kind == :select
|
|
61
|
+
entries
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
# What Document#fields reports for this field.
|
|
65
|
+
def data_value
|
|
66
|
+
case @kind
|
|
67
|
+
when :checkbox then checked?
|
|
68
|
+
when :radio then checked? ? @value : nil
|
|
69
|
+
else @value
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# `state` overrides the button's own appearance state.
|
|
74
|
+
def widget_entries(width, height, fonts, state: nil)
|
|
75
|
+
normal = Appearance.new(self, width, height, fonts).normal
|
|
76
|
+
entries = { Type: :Annot, Subtype: :Widget, F: 4, AP: { N: normal } }
|
|
77
|
+
entries[:MK] = appearance_characteristics unless @kind == :signature
|
|
78
|
+
entries[:AS] = state || (checked? ? on_state : :Off) if normal.is_a?(Hash)
|
|
79
|
+
entries
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
private
|
|
83
|
+
|
|
84
|
+
def field_value
|
|
85
|
+
case @kind
|
|
86
|
+
when :checkbox, :radio then checked? ? on_state : :Off
|
|
87
|
+
when :signature then nil
|
|
88
|
+
else @value.nil? ? nil : PDF::TextString.new(@value.to_s)
|
|
89
|
+
end
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def kind_flags
|
|
93
|
+
case @kind
|
|
94
|
+
when :text then %i[multiline comb].select { |key| @options[key] }
|
|
95
|
+
when :radio then %i[radio no_toggle_to_off]
|
|
96
|
+
when :select then @options[:editable] ? %i[combo edit] : %i[combo]
|
|
97
|
+
else []
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def appearance_characteristics
|
|
102
|
+
mk = {}
|
|
103
|
+
mk[:BG] = Color.parse(@options[:background]).components if @options[:background]
|
|
104
|
+
mk[:BC] = Color.parse(@options[:border]).components if @options[:border]
|
|
105
|
+
mk[:CA] = radio? ? "l" : "4" if type == :Btn
|
|
106
|
+
mk
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def validate_name(name)
|
|
110
|
+
return name unless name.empty? || name.split(".", -1).any?(&:empty?)
|
|
111
|
+
|
|
112
|
+
raise ArgumentError, "invalid field name #{name.inspect}: use non-empty, dot-separated segments"
|
|
113
|
+
end
|
|
114
|
+
|
|
115
|
+
def validate_comb
|
|
116
|
+
return unless @options[:comb] && !max_length
|
|
117
|
+
|
|
118
|
+
raise ArgumentError, "a comb field needs max_length: (or comb: <cells>)"
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
end
|
|
122
|
+
end
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Forms
|
|
5
|
+
# Helvetica's advance widths (the standard-14 AFM, in 1/1000 em) for the
|
|
6
|
+
# printable ASCII range, enough to lay out a field's appearance; other
|
|
7
|
+
# WinAnsi characters count as a digit's width.
|
|
8
|
+
module Metrics
|
|
9
|
+
ASCII = [
|
|
10
|
+
278, 278, 355, 556, 556, 889, 667, 191, 333, 333, 389, 584, 278, 333, 278, 278, # space - /
|
|
11
|
+
556, 556, 556, 556, 556, 556, 556, 556, 556, 556, 278, 278, 584, 584, 584, 556, # 0 - ?
|
|
12
|
+
1015, 667, 667, 722, 722, 667, 611, 778, 722, 278, 500, 667, 556, 833, 722, 778, # @ - O
|
|
13
|
+
667, 778, 722, 667, 611, 722, 667, 944, 667, 667, 611, 278, 278, 278, 469, 556, # P - _
|
|
14
|
+
333, 556, 556, 500, 556, 556, 278, 556, 556, 222, 222, 500, 222, 833, 556, 556, # ` - o
|
|
15
|
+
556, 556, 333, 500, 278, 556, 500, 722, 500, 500, 500, 334, 260, 334, 584 # p - ~
|
|
16
|
+
].freeze
|
|
17
|
+
DEFAULT = 556
|
|
18
|
+
ASCENT = 0.718
|
|
19
|
+
DESCENT = -0.207
|
|
20
|
+
|
|
21
|
+
module_function
|
|
22
|
+
|
|
23
|
+
# WinAnsi bytes for an appearance stream; unmappable characters become "?".
|
|
24
|
+
def encode(text)
|
|
25
|
+
text.to_s.encode(Encoding::Windows_1252, invalid: :replace, undef: :replace).b
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
# The width of WinAnsi `bytes` at `size`.
|
|
29
|
+
def width(bytes, size)
|
|
30
|
+
bytes.each_byte.sum { |byte| (byte in 32..126) ? ASCII[byte - 32] : DEFAULT } * size / 1000.0
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
# `text` broken into WinAnsi lines no wider than `width`, keeping its own
|
|
34
|
+
# line breaks; a word wider than a line stands alone.
|
|
35
|
+
def wrap(text, width, size)
|
|
36
|
+
encode(text).split("\n", -1).flat_map do |paragraph|
|
|
37
|
+
paragraph.split.each_with_object([+""]) do |word, lines|
|
|
38
|
+
candidate = lines.last.empty? ? word : "#{lines.last} #{word}"
|
|
39
|
+
if lines.last.empty? || width(candidate, size) <= width
|
|
40
|
+
lines[-1] = candidate
|
|
41
|
+
else
|
|
42
|
+
lines << word
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|