stationery 0.3.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 +79 -0
- data/README.md +98 -4
- data/lib/stationery/canvas/debug.rb +2 -1
- data/lib/stationery/canvas/marking.rb +124 -0
- data/lib/stationery/canvas.rb +24 -8
- data/lib/stationery/document.rb +28 -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/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 +11 -5
- 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/svg.rb +7 -3
- 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 +4 -3
- 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 +36 -4
- data/lib/stationery/rich/renderer.rb +3 -3
- data/lib/stationery/structure.rb +15 -9
- 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 +27 -12
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +10 -0
- metadata +13 -1
|
@@ -4,6 +4,9 @@ module Stationery
|
|
|
4
4
|
# Bulleted and numbered lists.
|
|
5
5
|
module Elements
|
|
6
6
|
BULLETS = %i[disc circle square].freeze
|
|
7
|
+
# ListNumbering for tagged PDF, by bullet shape and number format.
|
|
8
|
+
NUMBERING = { disc: :Disc, circle: :Circle, square: :Square, decimal: :Decimal, alpha: :LowerAlpha,
|
|
9
|
+
upper_alpha: :UpperAlpha, roman: :LowerRoman, upper_roman: :UpperRoman }.freeze
|
|
7
10
|
|
|
8
11
|
# A bulleted list. `style:` is :disc, :circle, :square, :dash or any
|
|
9
12
|
# String; unstyled nested lists cycle disc → circle → square. Every node
|
|
@@ -11,7 +14,8 @@ module Stationery
|
|
|
11
14
|
def ul(style: nil, gap: 4, indent: nil, marker_gap: 6, marker_color: nil, &)
|
|
12
15
|
shape = style || BULLETS[@_builder.list_depth % BULLETS.size]
|
|
13
16
|
marker_style = @_builder.style({ color: marker_color }.compact)
|
|
14
|
-
list(marker_style, gap:, indent:, marker_gap:, marker: ->(_) { list_bullet(shape, marker_style) },
|
|
17
|
+
list(marker_style, gap:, indent:, marker_gap:, marker: ->(_) { list_bullet(shape, marker_style) },
|
|
18
|
+
numbering: shape, &)
|
|
15
19
|
end
|
|
16
20
|
|
|
17
21
|
# A numbered list. `format:` is :decimal, :alpha, :upper_alpha, :roman,
|
|
@@ -19,7 +23,7 @@ module Stationery
|
|
|
19
23
|
def ol(format: :decimal, start: 1, suffix: ".", gap: 4, indent: nil, marker_gap: 6, marker_color: nil, &)
|
|
20
24
|
marker_style = @_builder.style({ color: marker_color }.compact)
|
|
21
25
|
label = ->(index) { list_label(ListMarkers.label(format, start + index, suffix), marker_style) }
|
|
22
|
-
list(marker_style, gap:, indent:, marker_gap:, marker: label, &)
|
|
26
|
+
list(marker_style, gap:, indent:, marker_gap:, marker: label, numbering: format, &)
|
|
23
27
|
end
|
|
24
28
|
|
|
25
29
|
# A list item: a String becomes a paragraph (taking every text option),
|
|
@@ -32,7 +36,7 @@ module Stationery
|
|
|
32
36
|
|
|
33
37
|
private
|
|
34
38
|
|
|
35
|
-
def list(marker_style, gap:, indent:, marker_gap:, marker:, &)
|
|
39
|
+
def list(marker_style, gap:, indent:, marker_gap:, marker:, numbering:, &)
|
|
36
40
|
items = Builder::Items.new
|
|
37
41
|
@_builder.nested_list { @_builder.within(items) { yield_content(&) } }
|
|
38
42
|
markers = items.nodes.each_index.map(&marker)
|
|
@@ -40,7 +44,12 @@ module Stationery
|
|
|
40
44
|
entries = items.nodes.zip(markers).map do |node, mark|
|
|
41
45
|
Layout::ListItem.new(mark, node.is_a?(Layout::Flow) ? node : Layout::Flow.new([node]), indent:, marker_gap:)
|
|
42
46
|
end
|
|
43
|
-
@_builder.add(Layout::Flow.new(entries, gap:))
|
|
47
|
+
@_builder.add(Layout::Flow.new(entries, gap:, tag: list_tag(numbering)))
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def list_tag(numbering)
|
|
51
|
+
numbering = NUMBERING[numbering] if numbering.is_a?(Symbol)
|
|
52
|
+
Tagging::Element.new(:L, attributes: numbering ? { List: { ListNumbering: numbering } } : {})
|
|
44
53
|
end
|
|
45
54
|
|
|
46
55
|
def list_bullet(shape, style)
|
|
@@ -52,7 +61,8 @@ module Stationery
|
|
|
52
61
|
end
|
|
53
62
|
|
|
54
63
|
def list_label(label, style)
|
|
55
|
-
Layout::Text.new([Text::Run.new(label, style)], context: @_builder.context(style), align: :right
|
|
64
|
+
Layout::Text.new([Text::Run.new(label, style)], context: @_builder.context(style), align: :right,
|
|
65
|
+
tag: Tagging::Element.new(:Lbl))
|
|
56
66
|
end
|
|
57
67
|
end
|
|
58
68
|
end
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -6,21 +6,23 @@ module Stationery
|
|
|
6
6
|
PARAGRAPH_DEFAULTS = { align: :left, leading: 0 }.freeze
|
|
7
7
|
|
|
8
8
|
# A paragraph. Plain strings are always literal; pass `markup: true` to
|
|
9
|
-
# read inline tags, or a block to build styled runs in Ruby.
|
|
9
|
+
# read inline tags, or a block to build styled runs in Ruby. `heading: 1..6`
|
|
10
|
+
# tags it as a heading in a tagged PDF.
|
|
10
11
|
def text(content = nil, markup: false, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
|
|
11
|
-
**options, &)
|
|
12
|
+
heading: nil, **options, &)
|
|
12
13
|
settings = PARAGRAPH_DEFAULTS.merge(@_builder.text_defaults.slice(:align, :leading)).merge(options)
|
|
13
14
|
style = @_builder.style(options)
|
|
14
15
|
runs = text_runs(content, style, markup, &)
|
|
15
16
|
node = Layout::Text.new(runs, context: @_builder.context(style), align: settings[:align],
|
|
16
|
-
leading: settings[:leading])
|
|
17
|
+
leading: settings[:leading], tag: Tagging::Element.new(Tagging.heading(heading)))
|
|
17
18
|
node.keep_with_next = keep_with_next
|
|
18
19
|
node.break_inside = break_inside
|
|
19
20
|
@_builder.add(mark(node, anchor, bookmark))
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
# A container with padding, background, border and radius. `at: [x, y]`
|
|
23
|
-
# places it at a fixed page position outside the flow.
|
|
24
|
+
# places it at a fixed page position outside the flow. `role:` (:section,
|
|
25
|
+
# :blockquote, :note, :caption, …) groups its content in a tagged PDF.
|
|
24
26
|
# `break_inside: :auto` splits it at any page break, `:avoid` never; by
|
|
25
27
|
# default it splits only when it does not fit on a page of its own.
|
|
26
28
|
def box(at: nil, align: nil, gap: 0, width: nil, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
|
|
@@ -65,15 +67,15 @@ module Stationery
|
|
|
65
67
|
end
|
|
66
68
|
|
|
67
69
|
# An SVG drawing: markup String, or a path to a .svg file. `currentColor`
|
|
68
|
-
# takes `color:`.
|
|
69
|
-
def svg(source, width: nil, height: nil, color: "#000000", align: nil)
|
|
70
|
+
# takes `color:`. `alt:` describes it in a tagged PDF (false: decorative).
|
|
71
|
+
def svg(source, width: nil, height: nil, color: "#000000", align: nil, alt: nil)
|
|
70
72
|
name = source.to_s.lstrip.start_with?("<") ? "inline" : File.basename(source.to_s)
|
|
71
73
|
source = File.read(source.to_s) unless name == "inline"
|
|
72
74
|
document = SVG::Document.parse(source)
|
|
73
75
|
if document.unsupported.any?
|
|
74
76
|
@_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
|
|
75
77
|
end
|
|
76
|
-
node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context)
|
|
78
|
+
node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context, alt:)
|
|
77
79
|
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
78
80
|
end
|
|
79
81
|
|
|
@@ -95,6 +97,7 @@ module Stationery
|
|
|
95
97
|
@_builder.add(node)
|
|
96
98
|
end
|
|
97
99
|
|
|
100
|
+
# `alt:` describes the image in a tagged PDF (false: decorative).
|
|
98
101
|
def image(source, align: nil, **)
|
|
99
102
|
node = Layout::Image.new(source, **)
|
|
100
103
|
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
@@ -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
|
|
@@ -18,14 +18,16 @@ module Stationery
|
|
|
18
18
|
|
|
19
19
|
def initialize(content = Flow.new, padding: 0, background: nil, border: nil, radius: 0, width: nil,
|
|
20
20
|
height: nil, min_height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
|
|
21
|
-
open: [], decoration: :slice)
|
|
21
|
+
open: [], decoration: :slice, role: nil)
|
|
22
22
|
raise ArgumentError, "pass height: or min_height:, not both" if height && min_height
|
|
23
23
|
|
|
24
24
|
super()
|
|
25
|
+
@tag = role && Tagging::Element.new(Tagging.role(role))
|
|
25
26
|
@min_height = min_height
|
|
26
27
|
@open = open
|
|
27
28
|
@decoration = decoration
|
|
28
29
|
@link = link
|
|
30
|
+
@link_tag = link && Tagging::Element.new(:Link)
|
|
29
31
|
@outset = Geometry.box(outset)
|
|
30
32
|
@content = content
|
|
31
33
|
@padding = Geometry.box(padding)
|
|
@@ -81,10 +83,14 @@ module Stationery
|
|
|
81
83
|
|
|
82
84
|
def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
|
|
83
85
|
height ||= measure(width)
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
86
|
+
canvas.structure(@tag) do
|
|
87
|
+
canvas.structure(@link_tag) do
|
|
88
|
+
paint_background(canvas, x, y, width, height)
|
|
89
|
+
paint_border(canvas, x, y, width, height)
|
|
90
|
+
paint_content(canvas, x, y, width, height, valign || @valign)
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
canvas.link(x, y, width, height, @link, tag: @link_tag) if @link
|
|
88
94
|
paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
|
|
89
95
|
end
|
|
90
96
|
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Layout
|
|
5
|
+
# An interactive form field: a widget `height` tall that fills the width
|
|
6
|
+
# (`width: :full`) or takes `width` points. Its look comes from the
|
|
7
|
+
# widget's own appearance, so it paints nothing into the page but an
|
|
8
|
+
# optional `label` node to the right of the widget.
|
|
9
|
+
class Field < Node
|
|
10
|
+
LABEL_GAP = 6
|
|
11
|
+
|
|
12
|
+
def initialize(field, height:, width: :full, label: nil)
|
|
13
|
+
super()
|
|
14
|
+
@field = field
|
|
15
|
+
@height = height
|
|
16
|
+
@width = width
|
|
17
|
+
@label = label
|
|
18
|
+
@tag = Tagging::Element.new(:Form, kind: :field)
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def measure(width)
|
|
22
|
+
@label ? [@height, @label.measure(label_width(width))].max : @height
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def natural_width = @label ? label_offset + @label.natural_width : fixed_points || 0
|
|
26
|
+
def min_width = @label ? label_offset + @label.min_width : fixed_points || 0
|
|
27
|
+
|
|
28
|
+
def fixed_width(available)
|
|
29
|
+
return [natural_width, available].min if @label
|
|
30
|
+
|
|
31
|
+
fixed_points && [fixed_points, available].min
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def paint(canvas, x, y, width, _height = nil, **)
|
|
35
|
+
own = @label ? @width : width
|
|
36
|
+
total = measure(width)
|
|
37
|
+
top = y + ((total - @height) / 2.0)
|
|
38
|
+
canvas.widget(@field, x, top, own, @height, tag: @tag)
|
|
39
|
+
@label&.paint(canvas, x + label_offset, y + ((total - @label.measure(label_width(width))) / 2.0),
|
|
40
|
+
label_width(width))
|
|
41
|
+
canvas.debug_rect(x, top, own, @height, :field) if canvas.debug?
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
private
|
|
45
|
+
|
|
46
|
+
def fixed_points = @width.is_a?(Numeric) ? @width : nil
|
|
47
|
+
def label_offset = @width + LABEL_GAP
|
|
48
|
+
def label_width(width) = [width - label_offset, 0].max
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -10,11 +10,13 @@ module Stationery
|
|
|
10
10
|
class Flow < Node
|
|
11
11
|
attr_reader :children, :gap, :align
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
# `tag` groups the children in a tagged PDF (a list's L).
|
|
14
|
+
def initialize(children = [], gap: 0, align: :left, tag: nil)
|
|
14
15
|
super()
|
|
15
16
|
@children = children
|
|
16
17
|
@gap = gap
|
|
17
18
|
@align = align
|
|
19
|
+
@tag = tag
|
|
18
20
|
end
|
|
19
21
|
|
|
20
22
|
def <<(child)
|
|
@@ -35,6 +37,20 @@ module Stationery
|
|
|
35
37
|
end
|
|
36
38
|
|
|
37
39
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
40
|
+
canvas.structure(@tag) { paint_children(canvas, x, y, width) }
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def split(width, height, fresh: false)
|
|
44
|
+
Splitter.new(self, width, height, fresh).call
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def with_children(children)
|
|
48
|
+
self.class.new(children, gap: @gap, align: @align, tag: @tag)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def paint_children(canvas, x, y, width)
|
|
38
54
|
cursor = y
|
|
39
55
|
@children.reject(&:page_break?).each_with_index do |child, index|
|
|
40
56
|
cursor += @gap unless index.zero?
|
|
@@ -46,14 +62,6 @@ module Stationery
|
|
|
46
62
|
cursor += height
|
|
47
63
|
end
|
|
48
64
|
end
|
|
49
|
-
|
|
50
|
-
def split(width, height, fresh: false)
|
|
51
|
-
Splitter.new(self, width, height, fresh).call
|
|
52
|
-
end
|
|
53
|
-
|
|
54
|
-
def with_children(children)
|
|
55
|
-
self.class.new(children, gap: @gap, align: @align)
|
|
56
|
-
end
|
|
57
65
|
end
|
|
58
66
|
|
|
59
67
|
# One pass of Flow#split, kept apart so the rules read top to bottom.
|
|
@@ -6,8 +6,10 @@ module Stationery
|
|
|
6
6
|
# preserved), never wider than the space it is given. One pixel is one
|
|
7
7
|
# point when no size is given.
|
|
8
8
|
class Image < Node
|
|
9
|
-
|
|
9
|
+
# `alt:` describes the image in a tagged PDF; `alt: false` marks it decorative.
|
|
10
|
+
def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil)
|
|
10
11
|
super()
|
|
12
|
+
@tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
|
|
11
13
|
@image = source.respond_to?(:build) ? source : Images.load(source)
|
|
12
14
|
@width = width
|
|
13
15
|
@height = height
|
|
@@ -29,7 +31,7 @@ module Stationery
|
|
|
29
31
|
|
|
30
32
|
def paint(canvas, x, y, width, _height = nil, **)
|
|
31
33
|
w, h = size(width)
|
|
32
|
-
canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity)
|
|
34
|
+
canvas.tag(@tag, bbox: [x, y, w, h]) { canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity) }
|
|
33
35
|
canvas.debug_rect(x, y, w, h, :image)
|
|
34
36
|
end
|
|
35
37
|
|