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.
Files changed (45) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +79 -0
  3. data/README.md +98 -4
  4. data/lib/stationery/canvas/debug.rb +2 -1
  5. data/lib/stationery/canvas/marking.rb +124 -0
  6. data/lib/stationery/canvas.rb +24 -8
  7. data/lib/stationery/document.rb +28 -10
  8. data/lib/stationery/elements/forms.rb +55 -0
  9. data/lib/stationery/elements/lists.rb +15 -5
  10. data/lib/stationery/elements.rb +10 -7
  11. data/lib/stationery/forms/acro_form.rb +115 -0
  12. data/lib/stationery/forms/appearance.rb +119 -0
  13. data/lib/stationery/forms/field.rb +122 -0
  14. data/lib/stationery/forms/metrics.rb +49 -0
  15. data/lib/stationery/layout/box.rb +11 -5
  16. data/lib/stationery/layout/field.rb +51 -0
  17. data/lib/stationery/layout/flow.rb +17 -9
  18. data/lib/stationery/layout/image.rb +4 -2
  19. data/lib/stationery/layout/list_item.rb +15 -6
  20. data/lib/stationery/layout/node.rb +2 -0
  21. data/lib/stationery/layout/paginator.rb +3 -2
  22. data/lib/stationery/layout/svg.rb +7 -3
  23. data/lib/stationery/layout/table/cell.rb +10 -2
  24. data/lib/stationery/layout/table.rb +42 -10
  25. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  26. data/lib/stationery/layout/table_of_contents.rb +1 -1
  27. data/lib/stationery/layout/text.rb +4 -3
  28. data/lib/stationery/minitest.rb +2 -0
  29. data/lib/stationery/page.rb +5 -2
  30. data/lib/stationery/page_templates.rb +7 -4
  31. data/lib/stationery/pdf/assembler.rb +36 -4
  32. data/lib/stationery/rich/renderer.rb +3 -3
  33. data/lib/stationery/structure.rb +15 -9
  34. data/lib/stationery/tagging/element.rb +74 -0
  35. data/lib/stationery/tagging/tree.rb +43 -0
  36. data/lib/stationery/tagging/writer.rb +81 -0
  37. data/lib/stationery/testing/inspector.rb +15 -0
  38. data/lib/stationery/testing/marked_text.rb +60 -0
  39. data/lib/stationery/testing/matchers.rb +25 -0
  40. data/lib/stationery/testing/structure_reader.rb +71 -0
  41. data/lib/stationery/text/paragraph.rb +27 -12
  42. data/lib/stationery/version.rb +1 -1
  43. data/lib/stationery/warnings.rb +8 -0
  44. data/lib/stationery.rb +10 -0
  45. 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
@@ -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
- paint_background(canvas, x, y, width, height)
85
- paint_border(canvas, x, y, width, height)
86
- paint_content(canvas, x, y, width, height, valign || @valign)
87
- canvas.link(x, y, width, height, @link) if @link
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
- def initialize(children = [], gap: 0, align: :left)
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
- def initialize(source, width: nil, height: nil, fit: nil, opacity: nil)
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