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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +100 -1
  3. data/README.md +143 -12
  4. data/lib/stationery/builder.rb +2 -1
  5. data/lib/stationery/canvas/debug.rb +2 -1
  6. data/lib/stationery/canvas/marking.rb +124 -0
  7. data/lib/stationery/canvas/text.rb +5 -4
  8. data/lib/stationery/canvas.rb +37 -8
  9. data/lib/stationery/document.rb +35 -10
  10. data/lib/stationery/elements/forms.rb +55 -0
  11. data/lib/stationery/elements/lists.rb +15 -5
  12. data/lib/stationery/elements.rb +10 -7
  13. data/lib/stationery/fonts/font.rb +36 -20
  14. data/lib/stationery/fonts/font_book.rb +3 -0
  15. data/lib/stationery/fonts/glyph_run.rb +4 -3
  16. data/lib/stationery/fonts/gpos.rb +11 -9
  17. data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
  18. data/lib/stationery/fonts/gsub.rb +65 -0
  19. data/lib/stationery/fonts/ligatures.rb +16 -0
  20. data/lib/stationery/fonts/registry.rb +18 -9
  21. data/lib/stationery/fonts/to_unicode.rb +1 -1
  22. data/lib/stationery/fonts/true_type.rb +36 -12
  23. data/lib/stationery/fonts/woff.rb +52 -0
  24. data/lib/stationery/forms/acro_form.rb +115 -0
  25. data/lib/stationery/forms/appearance.rb +119 -0
  26. data/lib/stationery/forms/field.rb +122 -0
  27. data/lib/stationery/forms/metrics.rb +49 -0
  28. data/lib/stationery/layout/box.rb +34 -11
  29. data/lib/stationery/layout/field.rb +51 -0
  30. data/lib/stationery/layout/flow.rb +17 -9
  31. data/lib/stationery/layout/image.rb +4 -2
  32. data/lib/stationery/layout/list_item.rb +15 -6
  33. data/lib/stationery/layout/node.rb +2 -0
  34. data/lib/stationery/layout/paginator.rb +3 -2
  35. data/lib/stationery/layout/row.rb +1 -1
  36. data/lib/stationery/layout/svg.rb +9 -2
  37. data/lib/stationery/layout/table/cell.rb +10 -2
  38. data/lib/stationery/layout/table.rb +42 -10
  39. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  40. data/lib/stationery/layout/table_of_contents.rb +1 -1
  41. data/lib/stationery/layout/text.rb +6 -4
  42. data/lib/stationery/minitest.rb +2 -0
  43. data/lib/stationery/page.rb +5 -2
  44. data/lib/stationery/page_templates.rb +7 -4
  45. data/lib/stationery/pdf/assembler.rb +38 -5
  46. data/lib/stationery/pdf/encryption/aes.rb +37 -0
  47. data/lib/stationery/pdf/encryption/rc4.rb +33 -0
  48. data/lib/stationery/pdf/encryption/revision4.rb +48 -0
  49. data/lib/stationery/pdf/encryption/revision6.rb +56 -0
  50. data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
  51. data/lib/stationery/pdf/serializer.rb +17 -9
  52. data/lib/stationery/pdf/writer.rb +24 -5
  53. data/lib/stationery/resources.rb +8 -2
  54. data/lib/stationery/rich/renderer.rb +3 -3
  55. data/lib/stationery/structure.rb +15 -9
  56. data/lib/stationery/svg/bounds.rb +76 -0
  57. data/lib/stationery/svg/document.rb +48 -18
  58. data/lib/stationery/svg/gradient.rb +115 -0
  59. data/lib/stationery/svg/painter.rb +70 -0
  60. data/lib/stationery/svg/parser.rb +24 -4
  61. data/lib/stationery/svg/selector.rb +37 -0
  62. data/lib/stationery/svg/shading.rb +34 -0
  63. data/lib/stationery/svg/style.rb +37 -20
  64. data/lib/stationery/svg/stylesheet.rb +47 -0
  65. data/lib/stationery/svg/text.rb +122 -0
  66. data/lib/stationery/svg/transform.rb +11 -0
  67. data/lib/stationery/tagging/element.rb +74 -0
  68. data/lib/stationery/tagging/tree.rb +43 -0
  69. data/lib/stationery/tagging/writer.rb +81 -0
  70. data/lib/stationery/testing/inspector.rb +15 -0
  71. data/lib/stationery/testing/marked_text.rb +60 -0
  72. data/lib/stationery/testing/matchers.rb +25 -0
  73. data/lib/stationery/testing/structure_reader.rb +71 -0
  74. data/lib/stationery/text/paragraph.rb +28 -12
  75. data/lib/stationery/text/style.rb +4 -2
  76. data/lib/stationery/text/wrapper.rb +2 -1
  77. data/lib/stationery/version.rb +1 -1
  78. data/lib/stationery/warnings.rb +8 -0
  79. data/lib/stationery.rb +26 -0
  80. 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 mtime so an
6
- # edited font is re-read. Parsing is the expensive part; the per-document
7
- # Font objects that track used glyphs are cheap wrappers around a parse.
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 = File.expand_path(path.to_s)
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 fetch(path, mtime)
29
- cached_mtime, ttf = @cache.delete(path)
30
- ttf = TrueType.new(File.binread(path)) unless cached_mtime == mtime
31
- @cache[path] = [mtime, ttf]
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 => character }.
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). Variable CFF2
7
- # fonts, font collections and web font wrappers are rejected with a named
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
- "ttcf" => "TrueType collections (.ttc) are not supported, use a single TrueType (.ttf) font",
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
- def initialize(data, cmap: true)
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(0, 4)
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(0, 4), GLYF_TABLES)
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