stationery 0.3.0 → 0.5.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 (56) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/README.md +136 -14
  4. data/lib/stationery/builder.rb +12 -0
  5. data/lib/stationery/canvas/debug.rb +2 -1
  6. data/lib/stationery/canvas/marking.rb +124 -0
  7. data/lib/stationery/canvas.rb +50 -8
  8. data/lib/stationery/document.rb +28 -10
  9. data/lib/stationery/elements/forms.rb +55 -0
  10. data/lib/stationery/elements/lists.rb +15 -5
  11. data/lib/stationery/elements/rich.rb +7 -5
  12. data/lib/stationery/elements.rb +34 -7
  13. data/lib/stationery/fonts/fallback.rb +6 -4
  14. data/lib/stationery/fonts/font.rb +51 -8
  15. data/lib/stationery/forms/acro_form.rb +115 -0
  16. data/lib/stationery/forms/appearance.rb +119 -0
  17. data/lib/stationery/forms/field.rb +122 -0
  18. data/lib/stationery/forms/metrics.rb +49 -0
  19. data/lib/stationery/layout/box.rb +64 -12
  20. data/lib/stationery/layout/field.rb +51 -0
  21. data/lib/stationery/layout/flow.rb +17 -9
  22. data/lib/stationery/layout/image.rb +35 -4
  23. data/lib/stationery/layout/list_item.rb +15 -6
  24. data/lib/stationery/layout/node.rb +2 -0
  25. data/lib/stationery/layout/paginator.rb +3 -2
  26. data/lib/stationery/layout/stack.rb +81 -0
  27. data/lib/stationery/layout/svg.rb +7 -3
  28. data/lib/stationery/layout/table/cell.rb +10 -2
  29. data/lib/stationery/layout/table.rb +42 -10
  30. data/lib/stationery/layout/table_of_contents/entry.rb +17 -9
  31. data/lib/stationery/layout/table_of_contents.rb +1 -1
  32. data/lib/stationery/layout/text.rb +4 -3
  33. data/lib/stationery/minitest.rb +3 -0
  34. data/lib/stationery/page.rb +5 -2
  35. data/lib/stationery/page_templates.rb +7 -4
  36. data/lib/stationery/pdf/assembler.rb +36 -4
  37. data/lib/stationery/preview.rb +23 -2
  38. data/lib/stationery/rails/previews_controller.rb +2 -7
  39. data/lib/stationery/rich/renderer/inlines.rb +10 -2
  40. data/lib/stationery/rich/renderer/links.rb +28 -0
  41. data/lib/stationery/rich/renderer.rb +9 -6
  42. data/lib/stationery/rich/styles.rb +2 -0
  43. data/lib/stationery/rspec.rb +4 -0
  44. data/lib/stationery/structure.rb +15 -9
  45. data/lib/stationery/tagging/element.rb +74 -0
  46. data/lib/stationery/tagging/tree.rb +43 -0
  47. data/lib/stationery/tagging/writer.rb +81 -0
  48. data/lib/stationery/testing/inspector.rb +22 -1
  49. data/lib/stationery/testing/marked_text.rb +60 -0
  50. data/lib/stationery/testing/matchers.rb +35 -0
  51. data/lib/stationery/testing/structure_reader.rb +71 -0
  52. data/lib/stationery/text/paragraph.rb +27 -12
  53. data/lib/stationery/version.rb +1 -1
  54. data/lib/stationery/warnings.rb +12 -0
  55. data/lib/stationery.rb +11 -0
  56. metadata +15 -1
@@ -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
@@ -10,22 +10,29 @@ module Stationery
10
10
  # fixed-height box can truncate or shrink text that does not fit
11
11
  # (`overflow:`) and never splits. `min_height:` is a floor that does split:
12
12
  # the first fragment keeps as much of it as the page holds and the next
13
- # one carries only what is left of it.
13
+ # one carries only what is left of it. `rotate:` turns the painted box
14
+ # around its centre without moving its layout rectangle (a rotated box
15
+ # never splits); `shadow:` paints a soft drop shadow under it, taking no
16
+ # space; `overflow: :hidden` clips the content to the rounded outline.
14
17
  class Box < Node
15
18
  BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }.freeze
19
+ SHADOW = { offset: [0, 4], blur: 8, color: "#000000", opacity: 0.15 }.freeze
20
+ SHADOW_LAYERS = 8
16
21
 
17
22
  attr_reader :content, :width_spec
18
23
 
19
24
  def initialize(content = Flow.new, padding: 0, background: nil, border: nil, radius: 0, width: nil,
20
25
  height: nil, min_height: nil, overflow: :visible, valign: :top, opacity: nil, link: nil, outset: 0,
21
- open: [], decoration: :slice)
26
+ open: [], decoration: :slice, role: nil, rotate: 0, shadow: nil)
22
27
  raise ArgumentError, "pass height: or min_height:, not both" if height && min_height
23
28
 
24
29
  super()
30
+ @tag = role && Tagging::Element.new(Tagging.role(role))
25
31
  @min_height = min_height
26
32
  @open = open
27
33
  @decoration = decoration
28
34
  @link = link
35
+ @link_tag = link && Tagging::Element.new(:Link)
29
36
  @outset = Geometry.box(outset)
30
37
  @content = content
31
38
  @padding = Geometry.box(padding)
@@ -37,6 +44,8 @@ module Stationery
37
44
  @overflow = overflow
38
45
  @valign = valign
39
46
  @opacity = opacity
47
+ @rotate = rotate
48
+ @shadow = shadow && SHADOW.merge(shadow == true ? {} : shadow)
40
49
  end
41
50
 
42
51
  def natural_width
@@ -53,7 +62,10 @@ module Stationery
53
62
  end
54
63
  end
55
64
 
56
- def splittable? = @height.nil? && @overflow == :visible && @content.splittable?
65
+ def splittable?
66
+ @height.nil? && @rotate.zero? && %i[visible hidden].include?(@overflow) && @content.splittable?
67
+ end
68
+
57
69
  def prefer_whole? = break_inside.nil? && splittable?
58
70
 
59
71
  def split(width, height, fresh: false)
@@ -81,11 +93,18 @@ module Stationery
81
93
 
82
94
  def paint(canvas, x, y, width, height = nil, valign: nil, debug_kind: :box, **)
83
95
  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
88
- paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
96
+ canvas.rotate(@rotate, around: [x + (width / 2.0), y + (height / 2.0)]) do
97
+ canvas.structure(@tag) do
98
+ canvas.structure(@link_tag) do
99
+ paint_shadow(canvas, x, y, width, height)
100
+ paint_background(canvas, x, y, width, height)
101
+ paint_border(canvas, x, y, width, height)
102
+ paint_content(canvas, x, y, width, height, valign || @valign)
103
+ end
104
+ end
105
+ paint_debug(canvas, Rect.new(x, y, width, height), debug_kind) if canvas.debug?
106
+ end
107
+ canvas.link(x, y, width, height, @link, tag: @link_tag) if @link
89
108
  end
90
109
 
91
110
  protected
@@ -119,6 +138,27 @@ module Stationery
119
138
  def vertical(open = @open) = insets(open).values_at(0, 2).sum
120
139
  def inner_width(width) = [width - horizontal, 0].max
121
140
 
141
+ # Stacked rounded rectangles under the box, the outermost grown by the
142
+ # blur and each fainter, so the centre sums to the opacity and the edge
143
+ # fades. An artifact: decoration, not content. Skipped on a fragment
144
+ # with cut sides, whose corners are square anyway.
145
+ def paint_shadow(canvas, x, y, width, height)
146
+ return unless @shadow && @open.empty?
147
+
148
+ blur = @shadow[:blur].to_f
149
+ layers = blur.positive? ? [(blur / 2).ceil, SHADOW_LAYERS].min : 1
150
+ dx, dy = @shadow[:offset]
151
+ canvas.artifact do
152
+ layers.downto(1) do |layer|
153
+ grow = blur * layer / layers
154
+ canvas.rounded_rect(x + dx - grow, y + dy - grow, width + (2 * grow), height + (2 * grow),
155
+ radius: grown_radius(grow), fill: @shadow[:color], opacity: @shadow[:opacity] / layers)
156
+ end
157
+ end
158
+ end
159
+
160
+ def grown_radius(grow) = @radius.is_a?(Array) ? @radius.map { |r| r + grow } : @radius + grow
161
+
122
162
  # The outset paints the background past the box's own edges (a band that
123
163
  # bleeds into the page margins) without moving the content.
124
164
  def paint_background(canvas, x, y, width, height)
@@ -184,15 +224,27 @@ module Stationery
184
224
  end,
185
225
  inner.height, used)
186
226
  paint_inner = -> { content.paint(canvas, x + left, y + top + [offset, 0].max, inner.width) }
187
- if @overflow == :visible
188
- paint_inner.call
189
- else
190
- canvas.clip(inner.x, inner.y, inner.width, inner.height) do
227
+ clip_outline(canvas, Rect.new(x, y, width, height)) do
228
+ if @overflow == :visible || (@overflow == :hidden && @height.nil?)
191
229
  paint_inner.call
230
+ else
231
+ canvas.clip(inner.x, inner.y, inner.width, inner.height) do
232
+ paint_inner.call
233
+ end
192
234
  end
193
235
  end
194
236
  end
195
237
 
238
+ # `overflow: :hidden` keeps the content inside the rounded outline; cut
239
+ # sides stay square like the background's.
240
+ def clip_outline(canvas, area, &)
241
+ return yield unless @overflow == :hidden && @radius.positive?
242
+
243
+ cut(canvas, area, area) do |shape|
244
+ canvas.clip(shape.x, shape.y, shape.width, shape.height, radius: @radius, &)
245
+ end
246
+ end
247
+
196
248
  def paint_debug(canvas, rect, kind)
197
249
  canvas.debug_rect(rect.x, rect.y, rect.width, rect.height, kind)
198
250
  return if insets.all?(&:zero?)
@@ -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.
@@ -4,15 +4,25 @@ module Stationery
4
4
  module Layout
5
5
  # An image sized by width, height, both, or a box to fit into (aspect
6
6
  # preserved), never wider than the space it is given. One pixel is one
7
- # point when no size is given.
7
+ # point when no size is given. `fit: :cover` fills `width:` × `height:`
8
+ # instead, scaling up and clipping the excess around the centre.
9
+ # `radius:` clips to rounded corners and `rotate:` (degrees, clockwise)
10
+ # turns the painted image around the centre of its rectangle; neither
11
+ # changes the space the image takes up.
8
12
  class Image < Node
9
- def initialize(source, width: nil, height: nil, fit: nil, opacity: nil)
13
+ # `alt:` describes the image in a tagged PDF; `alt: false` marks it decorative.
14
+ def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil, radius: 0, rotate: 0)
15
+ raise ArgumentError, "fit: :cover needs width: and height:" if fit == :cover && !(width && height)
16
+
10
17
  super()
18
+ @tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
11
19
  @image = source.respond_to?(:build) ? source : Images.load(source)
12
20
  @width = width
13
21
  @height = height
14
22
  @fit = fit
15
23
  @opacity = opacity
24
+ @radius = radius
25
+ @rotate = rotate
16
26
  end
17
27
 
18
28
  def size(available)
@@ -29,16 +39,37 @@ module Stationery
29
39
 
30
40
  def paint(canvas, x, y, width, _height = nil, **)
31
41
  w, h = size(width)
32
- canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity)
42
+ canvas.tag(@tag, bbox: [x, y, w, h]) do
43
+ canvas.rotate(@rotate, around: [x + (w / 2.0), y + (h / 2.0)]) { paint_clipped(canvas, x, y, w, h) }
44
+ end
33
45
  canvas.debug_rect(x, y, w, h, :image)
34
46
  end
35
47
 
36
48
  private
37
49
 
50
+ def paint_clipped(canvas, x, y, w, h)
51
+ return paint_image(canvas, x, y, w, h) unless @fit == :cover || @radius.positive?
52
+
53
+ canvas.clip(x, y, w, h, radius: @radius) { paint_image(canvas, x, y, w, h) }
54
+ end
55
+
56
+ def paint_image(canvas, x, y, w, h)
57
+ if @fit == :cover
58
+ scale = [w / @image.width.to_f, h / @image.height.to_f].max
59
+ cw = tidy(@image.width * scale)
60
+ ch = tidy(@image.height * scale)
61
+ x = tidy(x - ((cw - w) / 2.0))
62
+ y = tidy(y - ((ch - h) / 2.0))
63
+ w = cw
64
+ h = ch
65
+ end
66
+ canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity)
67
+ end
68
+
38
69
  def requested
39
70
  iw = @image.width.to_f
40
71
  ih = @image.height.to_f
41
- if @fit
72
+ if @fit.is_a?(Array)
42
73
  scale = [@fit[0] / iw, @fit[1] / ih].min
43
74
  [iw * scale, ih * scale].map { |v| tidy(v) }
44
75
  elsif @width && @height then [@width, @height]
@@ -9,8 +9,12 @@ module Stationery
9
9
  class ListItem < Node
10
10
  attr_reader :marker, :body, :indent, :marker_gap
11
11
 
12
- def initialize(marker, body, indent:, marker_gap:)
12
+ # `tag` and `body_tag` are the item's LI and LBody; a Text marker carries its own Lbl.
13
+ def initialize(marker, body, indent:, marker_gap:, tag: Tagging::Element.new(:LI),
14
+ body_tag: Tagging::Element.new(:LBody))
13
15
  super()
16
+ @tag = tag
17
+ @body_tag = body_tag
14
18
  @marker = marker
15
19
  @body = body
16
20
  @indent = indent
@@ -31,11 +35,13 @@ module Stationery
31
35
  end
32
36
 
33
37
  def paint(canvas, x, y, width, _height = nil, **)
34
- if @marker
35
- own = @marker.width_in(column)
36
- @marker.paint(canvas, x + column - own, y, own)
38
+ canvas.structure(@tag) do
39
+ if @marker
40
+ own = @marker.width_in(column)
41
+ @marker.paint(canvas, x + column - own, y, own)
42
+ end
43
+ canvas.structure(@body_tag) { @body.paint(canvas, x + @indent, y, body_width(width)) }
37
44
  end
38
- @body.paint(canvas, x + @indent, y, body_width(width))
39
45
  end
40
46
 
41
47
  def split(width, height, fresh: false)
@@ -51,7 +57,10 @@ module Stationery
51
57
 
52
58
  def column = [@indent - @marker_gap, 0].max
53
59
  def body_width(width) = [width - @indent, 0].max
54
- def with(marker, body) = self.class.new(marker, body, indent: @indent, marker_gap: @marker_gap)
60
+
61
+ def with(marker, body)
62
+ self.class.new(marker, body, indent: @indent, marker_gap: @marker_gap, tag: @tag, body_tag: @body_tag)
63
+ end
55
64
  end
56
65
 
57
66
  # A drawn bullet (:disc, :circle or :square) sized to the text style and
@@ -26,6 +26,8 @@ module Stationery
26
26
  # - fixed_width(available) → its own width, or nil when it fills the width
27
27
  class Node
28
28
  attr_accessor :keep_with_next, :break_inside
29
+ # The Tagging::Element this node paints into, shared by its fragments.
30
+ attr_reader :tag
29
31
 
30
32
  def measure(_width) = raise(NotImplementedError, "#{self.class} must implement measure")
31
33
  def paint(_canvas, _x, _y, _width, _height = nil, **) = raise(NotImplementedError, "#{self.class}#paint")
@@ -7,8 +7,9 @@ module Stationery
7
7
  class Paginator
8
8
  attr_reader :warnings
9
9
 
10
- def initialize(resources:, page: {}, warnings: Warnings.new, debug: false, regions: nil)
10
+ def initialize(resources:, page: {}, warnings: Warnings.new, debug: false, regions: nil, tagging: nil)
11
11
  @resources = resources
12
+ @tagging = tagging
12
13
  @debug = debug
13
14
  @page_options = page
14
15
  @regions = regions
@@ -61,7 +62,7 @@ module Stationery
61
62
  box = page.content_box
62
63
  height = head.measure(box.width)
63
64
  @warnings << Overflow.new(page: number, height:, available: box.height) if height > box.height + EPSILON
64
- canvas = Canvas.new(page, @resources, debug: @debug)
65
+ canvas = Canvas.new(page, @resources, debug: @debug, tagging: @tagging)
65
66
  head.paint(canvas, box.x, box.y, box.width)
66
67
  canvas.debug_rect(box.x, box.y, box.width, box.height, :page)
67
68
  end