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
@@ -7,6 +7,7 @@ module Stationery
7
7
  class Canvas
8
8
  include Text
9
9
  include Debug
10
+ include Marking
10
11
 
11
12
  CAPS = { butt: 0, round: 1, square: 2 }.freeze
12
13
  JOINS = { miter: 0, round: 1, bevel: 2 }.freeze
@@ -14,11 +15,14 @@ module Stationery
14
15
  attr_reader :page
15
16
 
16
17
  # `template: true` records anchors apart, for canvases page templates draw on.
17
- def initialize(page, resources, template: false, debug: false)
18
+ # `tagging:` (a Tagging::Tree) marks content for a tagged PDF.
19
+ def initialize(page, resources, template: false, debug: false, tagging: nil)
18
20
  @page = page
19
21
  @resources = resources
20
22
  @template = template
21
23
  @debug = debug
24
+ @tagging = tagging
25
+ @marked = 0
22
26
  end
23
27
 
24
28
  def save
@@ -27,6 +31,32 @@ module Stationery
27
31
  emit("Q")
28
32
  end
29
33
 
34
+ # Paints the block with the affine `matrix` [a, b, c, d, e, f] applied,
35
+ # given in top-left space (x' = ax + cy + e, y' = bx + dy + f with y
36
+ # growing downwards). Annotations (`link`, `widget`) keep their
37
+ # untransformed page rectangles.
38
+ def transform(matrix)
39
+ a, b, c, d, e, f = matrix
40
+ height = @page.height
41
+ pdf_matrix = [a, -b, -c, d, (c * height) + e, height - (d * height) - f]
42
+ save do
43
+ emit("#{pdf_matrix.map { |v| num(v) }.join(" ")} cm")
44
+ yield self
45
+ end
46
+ end
47
+
48
+ # Paints the block rotated `degrees` clockwise around the page point
49
+ # `around` ([x, y]), as CSS `rotate()` turns an element. Zero just yields.
50
+ def rotate(degrees, around:, &)
51
+ return yield self if degrees.zero?
52
+
53
+ radians = degrees * Math::PI / 180
54
+ cos = Math.cos(radians)
55
+ sin = Math.sin(radians)
56
+ cx, cy = around
57
+ transform([cos, sin, -sin, cos, cx - (cx * cos) + (cy * sin), cy - (cx * sin) - (cy * cos)], &)
58
+ end
59
+
30
60
  def clip(x, y, w, h, radius: 0)
31
61
  save do
32
62
  emit(Path.new(self).rounded_rect(x, y, w, h, radius).to_s, "W n")
@@ -81,11 +111,22 @@ module Stationery
81
111
 
82
112
  # A clickable area opening `target`: a URL, or `#name` for an anchor in
83
113
  # this document. Annotation rectangles live in absolute, untransformed
84
- # page space, so they ignore clips and path transforms.
85
- def link(x, y, w, h, target)
114
+ # page space, so they ignore clips and path transforms. `tag:` is the
115
+ # Link element the annotation belongs to in a tagged PDF.
116
+ def link(x, y, w, h, target, tag: nil)
86
117
  target = target.to_s
87
118
  rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
88
- @page.annotations << (target.start_with?("#") ? { rect:, dest: target[1..] } : { rect:, url: target })
119
+ annotation = target.start_with?("#") ? { rect:, dest: target[1..] } : { rect:, url: target }
120
+ @page.annotations << annotation
121
+ own(annotation, tag) if tag
122
+ end
123
+
124
+ # An interactive form field's widget (a Forms::Field) over the rectangle.
125
+ def widget(field, x, y, w, h, tag: nil)
126
+ rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
127
+ annotation = { rect:, widget: field }
128
+ @page.annotations << annotation
129
+ adopt(annotation, tag, rect) if tag
89
130
  end
90
131
 
91
132
  # Names the point `y` on this page as a link target.
@@ -94,9 +135,10 @@ module Stationery
94
135
  end
95
136
 
96
137
  # Leaves room for the page number `anchor` lands on; Structure fills it in
97
- # and adds the `link:` area ([x, y, w, h]) when the anchor exists.
98
- def number_slot(anchor, x:, baseline:, width:, style:, link: nil)
99
- @page.slots << Page::Slot.new(anchor.to_s, x, baseline, width, style, link)
138
+ # and adds the `link:` area ([x, y, w, h]) when the anchor exists. `tags:`
139
+ # are the [link, number] elements they belong to in a tagged PDF.
140
+ def number_slot(anchor, x:, baseline:, width:, style:, link: nil, tags: nil)
141
+ @page.slots << Page::Slot.new(anchor.to_s, x, baseline, width, style, link, tags)
100
142
  end
101
143
 
102
144
  def num(value) = PDF::Serializer.number(num_value(value))
@@ -141,7 +183,7 @@ module Stationery
141
183
  ops = []
142
184
  ops << "/#{@page.use(:ExtGState, @resources.opacity(opacity))} gs" if opacity && opacity < 1
143
185
  yield ops
144
- emit("q", *ops, "Q")
186
+ artifact { emit("q", *ops, "Q") }
145
187
  end
146
188
 
147
189
  def emit(*ops)
@@ -24,7 +24,7 @@ module Stationery
24
24
  superclass.config.transform_values(&:dup)
25
25
  else
26
26
  { page: { size: :letter, margin: 36 }, families: {}, fallbacks: [], text: {}, metadata: {},
27
- templates: [], regions: [], strict: false }
27
+ templates: [], regions: [], strict: false, tagged: false }
28
28
  end
29
29
  end
30
30
 
@@ -55,6 +55,13 @@ module Stationery
55
55
  config[:strict] = value
56
56
  end
57
57
 
58
+ # Writes a tagged (accessible) PDF: a structure tree of headings,
59
+ # paragraphs and figures, and headers and footers marked as artifacts.
60
+ # Set `metadata lang:` and give images `alt:` text.
61
+ def tagged(value = true) # rubocop:disable Style/OptionalBooleanParameter
62
+ config[:tagged] = value
63
+ end
64
+
58
65
  # Encrypts every render with the standard security handler; see
59
66
  # PDF::Encryption::StandardSecurity for the options.
60
67
  def encrypt(**)
@@ -80,26 +87,29 @@ module Stationery
80
87
  end
81
88
  end
82
89
 
83
- attr_reader :warnings
90
+ # `fields` is every form field's name and value from the last render.
91
+ attr_reader :warnings, :fields
84
92
 
85
93
  def page_options = self.class.config[:page]
86
94
  def metadata = self.class.config[:metadata]
87
95
 
88
- def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt])
96
+ def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
97
+ tagged: self.class.config[:tagged])
89
98
  encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
99
+ tagging = Tagging::Tree.new if tagged
90
100
  warnings = Warnings.new
91
101
  book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
92
102
  call(builder = Builder.new(book:, text: self.class.config[:text]))
93
103
  resources = Resources.new
94
- regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
95
- paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:)
96
- pages = paginator.paginate(builder.root)
97
- PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:).apply(pages)
98
- outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:))
104
+ pages = paginate(builder.root, book:, resources:, warnings:, debug:, tagging:)
105
+ outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:, tagging:))
106
+ tagging&.audit(pages, warnings, lang: metadata[:lang])
99
107
  @warnings = warnings
108
+ @fields = Forms::AcroForm.values(pages)
100
109
  raise WarningsError, warnings if strict && warnings.any?
101
110
 
102
- write(PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:).render, target)
111
+ assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, lang: metadata[:lang])
112
+ write(assembler.render, target)
103
113
  end
104
114
 
105
115
  # Used by page templates to build nodes into their own root.
@@ -120,6 +130,14 @@ module Stationery
120
130
 
121
131
  private
122
132
 
133
+ def paginate(root, book:, resources:, warnings:, debug:, tagging:)
134
+ regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
135
+ paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:, tagging:)
136
+ paginator.paginate(root).tap do |pages|
137
+ PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:, tagging:).apply(pages)
138
+ end
139
+ end
140
+
123
141
  def region_measure(book)
124
142
  page = Page.new(**page_options)
125
143
  lambda do |region, number|
@@ -129,7 +147,7 @@ module Stationery
129
147
  end
130
148
 
131
149
  def info
132
- metadata.to_h do |key, value|
150
+ metadata.except(:lang).to_h do |key, value|
133
151
  [INFO_KEYS.fetch(key.to_sym) { key.to_sym }, value.is_a?(Array) ? value.join(", ") : value]
134
152
  end
135
153
  end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Interactive form fields. Each takes a name (dotted names group fields,
5
+ # "address.city") and lays out like a box, or at a fixed page position with
6
+ # `at: [x, y]`.
7
+ module Elements
8
+ # A text input. `width:` is :full or points; `multiline:`, `max_length:`,
9
+ # `comb:` (a cell count, or true with max_length:), `read_only:`,
10
+ # `required:`, `font_size:`, `border:`, `background:` and `radius:`.
11
+ def text_field(name, value: "", width: :full, height: 22, at: nil, **)
12
+ field_node(Forms::Field.new(:text, name, value: value.to_s, **), width:, height:, at:)
13
+ end
14
+
15
+ # A check box `size` points square, with an optional label to its right.
16
+ def checkbox(name, checked: false, size: 12, label: nil, at: nil, **)
17
+ field = Forms::Field.new(:checkbox, name, value: checked == true, **)
18
+ field_node(field, width: size, height: size, at:, label:)
19
+ end
20
+
21
+ # One choice of the radio group `name`; its `value` becomes the group's
22
+ # value when checked.
23
+ def radio(name, value, checked: false, size: 12, label: nil, at: nil, **)
24
+ field = Forms::Field.new(:radio, name, value: value.to_s, checked:, **)
25
+ field_node(field, width: size, height: size, at:, label:)
26
+ end
27
+
28
+ # A drop-down (combo box) of `options`; `editable: true` also accepts
29
+ # typed values.
30
+ def select(name, options:, value: nil, width: :full, height: 22, at: nil, **)
31
+ field = Forms::Field.new(:select, name, value: value&.to_s, options: options.map(&:to_s), **)
32
+ field_node(field, width:, height:, at:)
33
+ end
34
+
35
+ # An empty signature field for the signer to fill, drawn as a rule over
36
+ # the label.
37
+ def signature_field(name, width: :full, height: 40, label: "Signature", at: nil, **)
38
+ field = Forms::Field.new(:signature, name, label: label.to_s, **)
39
+ field_node(field, width:, height:, at:)
40
+ end
41
+
42
+ private
43
+
44
+ def field_node(field, width:, height:, at:, label: nil)
45
+ label &&= field_label(label)
46
+ node = Layout::Field.new(field, height:, width:, label:)
47
+ @_builder.add(at ? Layout::Positioned.new(node, x: at[0], y: at[1]) : node)
48
+ end
49
+
50
+ def field_label(label)
51
+ style = @_builder.style
52
+ Layout::Text.new([Text::Run.new(label.to_s, style)], context: @_builder.context(style))
53
+ end
54
+ end
55
+ end
@@ -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
@@ -5,16 +5,18 @@ module Stationery
5
5
  module Elements
6
6
  # HTML such as ActionText's `record.body.to_s`. `images:` resolves an
7
7
  # image src to a path or IO (nil skips it); otherwise it is read from
8
- # under `base_path:`. Remote images are never fetched.
9
- def html(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false)
8
+ # under `base_path:`. Remote images are never fetched. `links:` lists the
9
+ # URL schemes written as links (default http, https, mailto and tel;
10
+ # `#anchor` always is); `:all` keeps every href for trusted sources.
11
+ def html(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
10
12
  require_relative "../html/document"
11
- rich(HTML.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:)
13
+ rich(HTML.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:, links:)
12
14
  end
13
15
 
14
16
  # CommonMark (plus GFM tables and strikethrough); options as for html.
15
- def markdown(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false)
17
+ def markdown(source, styles: {}, gap: 6, images: nil, base_path: nil, bookmarks: false, links: nil)
16
18
  require_relative "../markdown/document"
17
- rich(Markdown.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:)
19
+ rich(Markdown.parse(source.to_s), styles:, gap:, images:, base_path:, bookmarks:, links:)
18
20
  end
19
21
 
20
22
  private
@@ -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,
@@ -49,6 +51,29 @@ module Stationery
49
51
  @_builder.add(node)
50
52
  end
51
53
 
54
+ # A base with layers painted over it: every ordinary child is part of the
55
+ # base (which sets the height), every `layer` floats over it relative to
56
+ # the stack's rectangle, taking no space. The stack moves to the next
57
+ # page whole. Layers may overhang; wrap the stack in `box(padding:)` to
58
+ # keep them inside the page.
59
+ def stack(gap: 0, align: nil, &)
60
+ flow = Layout::Flow.new([], gap:, align: align || :left)
61
+ @_builder.stack(flow) { align ? @_builder.with_text(align:) { yield_content(&) } : yield_content(&) }
62
+ layers, base = flow.children.partition { |child| child.is_a?(Layout::Layer) }
63
+ @_builder.add(Layout::Stack.new(flow.with_children(base), layers))
64
+ end
65
+
66
+ # A box placed over the enclosing `stack` by insets from its edges:
67
+ # points, or a fraction (`0.4`, `1/3r`) of the stack's width or height;
68
+ # negative values overhang. Takes every box option (`rotate:`, `shadow:`,
69
+ # `padding:`, `background:`, `radius:`, `height:`, …).
70
+ def layer(top: nil, right: nil, bottom: nil, left: nil, width: nil, height: nil, align: nil, gap: 0, **, &)
71
+ raise ArgumentError, "layer must be inside a stack" unless @_builder.in_stack?
72
+
73
+ box = Layout::Box.new(container(align:, gap:, &), **)
74
+ @_builder.add(Layout::Layer.new(box, top:, right:, bottom:, left:, width:, height:))
75
+ end
76
+
52
77
  # Children kept in one vertical group; `keep_together: true` moves the
53
78
  # whole group to the next page rather than splitting it.
54
79
  def group(gap: 0, align: nil, keep_together: false, keep_with_next: nil, anchor: nil, bookmark: nil, &)
@@ -65,15 +90,15 @@ module Stationery
65
90
  end
66
91
 
67
92
  # 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)
93
+ # takes `color:`. `alt:` describes it in a tagged PDF (false: decorative).
94
+ def svg(source, width: nil, height: nil, color: "#000000", align: nil, alt: nil)
70
95
  name = source.to_s.lstrip.start_with?("<") ? "inline" : File.basename(source.to_s)
71
96
  source = File.read(source.to_s) unless name == "inline"
72
97
  document = SVG::Document.parse(source)
73
98
  if document.unsupported.any?
74
99
  @_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
75
100
  end
76
- node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context)
101
+ node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context, alt:)
77
102
  @_builder.add(align ? Layout::Flow.new([node], align:) : node)
78
103
  end
79
104
 
@@ -95,6 +120,8 @@ module Stationery
95
120
  @_builder.add(node)
96
121
  end
97
122
 
123
+ # `alt:` describes the image in a tagged PDF (false: decorative). Sizing,
124
+ # `fit: :cover`, `radius:` and `rotate:` as for Layout::Image.
98
125
  def image(source, align: nil, **)
99
126
  node = Layout::Image.new(source, **)
100
127
  @_builder.add(align ? Layout::Flow.new([node], align:) : node)
@@ -6,11 +6,13 @@ module Stationery
6
6
  # run's family, then the book's fallbacks in order, then bundled Inter. A
7
7
  # glyph no font has stays in the run's family and draws as .notdef.
8
8
  #
9
- # Whitespace, joiners, variation selectors and combining marks carry the
10
- # family of the character before them (or after them at the start of a
11
- # run) so words are not chopped and marks stay with their base.
9
+ # Whitespace (any Unicode White_Space), joiners, variation selectors and
10
+ # combining marks carry the family of the character before them (or
11
+ # after them at the start of a run) so words are not chopped and marks
12
+ # stay with their base; whitespace a font lacks draws as a blank of the
13
+ # right width (see Font::WHITESPACE).
12
14
  class Fallback
13
- CARRIED = /[\s‌‍︀-️\p{M}]/
15
+ CARRIED = /[\p{Space}‌‍︀-️\p{M}]/
14
16
 
15
17
  def self.carried?(char) = CARRIED.match?(char)
16
18
 
@@ -12,6 +12,18 @@ module Stationery
12
12
  class Font
13
13
  OBLIQUE_SKEW = Math.tan(12 * Math::PI / 180)
14
14
 
15
+ # Whitespace the font lacks draws as its space glyph advanced to the
16
+ # character's conventional width: a fraction of the em for the fixed
17
+ # spaces, a digit's or a period's advance for the figure and
18
+ # punctuation spaces, the space's own width for the rest (no-break,
19
+ # line and paragraph separators, ogham mark, …). Nothing is painted,
20
+ # nothing is .notdef.
21
+ WHITESPACE = /\A\p{Space}\z/
22
+ SPACE_FRACTIONS = { 0x2000 => 0.5, 0x2001 => 1.0, 0x2002 => 0.5, 0x2003 => 1.0, 0x2004 => 1.0 / 3,
23
+ 0x2005 => 0.25, 0x2006 => 1.0 / 6, 0x2009 => 0.2, 0x200A => 0.125, 0x205F => 4.0 / 18,
24
+ 0x3000 => 1.0 }.freeze
25
+ SPACE_LIKE = { 0x2007 => "0", 0x2008 => "." }.freeze
26
+
15
27
  attr_reader :ttf
16
28
 
17
29
  def initialize(ttf)
@@ -19,6 +31,7 @@ module Stationery
19
31
  @used = {}
20
32
  @pairs = {}
21
33
  @glyphs = {}
34
+ @blanks = {}
22
35
  @shapes = { true => {}, false => {} }
23
36
  @advances = { true => {}, false => {} }
24
37
  @kerns = { true => {}, false => {} }
@@ -59,12 +72,20 @@ module Stationery
59
72
  # `ligatures:` substitutes the font's standard ligatures; `kerning:`
60
73
  # then fills the adjustments with pair kerning between the glyphs.
61
74
  def glyph_run(text, kerning: false, ligatures: true)
62
- gids, chars = shape(text, ligatures)
63
- gids.each_with_index { |gid, i| @used[gid] ||= chars[i] }
64
- adjust = gids.each_with_index.map { |gid, i| kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0 }
75
+ gids, chars, blanks = shape(text, ligatures)
76
+ gids.each_with_index { |gid, i| @used[gid] ||= blanks[i] ? " " : chars[i] }
77
+ adjust = gids.each_with_index.map do |gid, i|
78
+ kern = kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0
79
+ blanks[i] ? kern + (blanks[i] * 1000.0 / @ttf.units_per_em) : kern
80
+ end
65
81
  GlyphRun.new(font: self, gids:, adjust:, chars:)
66
82
  end
67
83
 
84
+ # Whether a character the font lacks is drawn as a blank (see WHITESPACE).
85
+ def blank?(char)
86
+ @blanks.fetch(char) { @blanks[char] = WHITESPACE.match?(char) && !glyph?(char) && glyph?(" ") }
87
+ end
88
+
68
89
  def used?
69
90
  @used.any?
70
91
  end
@@ -93,11 +114,13 @@ module Stationery
93
114
 
94
115
  private
95
116
 
96
- # [gids, source text of each glyph], remembered per string.
117
+ # [gids, source text of each glyph, extra advance in font units after
118
+ # each blank or nil], remembered per string.
97
119
  def shape(text, ligatures)
98
120
  @shapes[ligatures][text] ||= begin
99
- gids = text.each_char.map { |char| @ttf.glyph_id(char.ord) }
100
- ligatures ? ligate(gids, text) : [gids, text.chars].each(&:freeze).freeze
121
+ gids = text.each_char.map { |char| glyph_for(char) }
122
+ gids, chars = ligatures ? ligate(gids, text) : [gids, text.chars]
123
+ [gids, chars, chars.map { |char| blank_units(char) }].each(&:freeze).freeze
101
124
  end
102
125
  end
103
126
 
@@ -105,11 +128,31 @@ module Stationery
105
128
  start = 0
106
129
  glyphs = @ttf.ligatures.substitute(gids)
107
130
  chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
108
- [glyphs.map(&:first), chars].each(&:freeze).freeze
131
+ [glyphs.map(&:first), chars]
132
+ end
133
+
134
+ def glyph_for(char)
135
+ blank?(char) ? @ttf.glyph_id(" ".ord) : @ttf.glyph_id(char.ord)
136
+ end
137
+
138
+ # How much wider (or narrower) than a space a blank's advance is.
139
+ def blank_units(char)
140
+ return unless blank?(char)
141
+
142
+ space = @ttf.advance(@ttf.glyph_id(" ".ord))
143
+ like = SPACE_LIKE[char.ord]
144
+ width = if (fraction = SPACE_FRACTIONS[char.ord]) then @ttf.units_per_em * fraction
145
+ elsif like && glyph?(like) then @ttf.advance(@ttf.glyph_id(like.ord))
146
+ else space
147
+ end
148
+ width - space
109
149
  end
110
150
 
111
151
  def advance_units(text, ligatures)
112
- @advances[ligatures][text] ||= shape(text, ligatures).first.sum { |gid| @ttf.advance(gid) }
152
+ @advances[ligatures][text] ||= begin
153
+ gids, _, blanks = shape(text, ligatures)
154
+ gids.sum { |gid| @ttf.advance(gid) } + blanks.sum { |units| units || 0 }
155
+ end
113
156
  end
114
157
 
115
158
  def kerning_units(text, ligatures)
@@ -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