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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +93 -0
- data/README.md +136 -14
- data/lib/stationery/builder.rb +12 -0
- data/lib/stationery/canvas/debug.rb +2 -1
- data/lib/stationery/canvas/marking.rb +124 -0
- data/lib/stationery/canvas.rb +50 -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/rich.rb +7 -5
- data/lib/stationery/elements.rb +34 -7
- data/lib/stationery/fonts/fallback.rb +6 -4
- data/lib/stationery/fonts/font.rb +51 -8
- 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 +64 -12
- data/lib/stationery/layout/field.rb +51 -0
- data/lib/stationery/layout/flow.rb +17 -9
- data/lib/stationery/layout/image.rb +35 -4
- 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/stack.rb +81 -0
- 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 +3 -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/preview.rb +23 -2
- data/lib/stationery/rails/previews_controller.rb +2 -7
- data/lib/stationery/rich/renderer/inlines.rb +10 -2
- data/lib/stationery/rich/renderer/links.rb +28 -0
- data/lib/stationery/rich/renderer.rb +9 -6
- data/lib/stationery/rich/styles.rb +2 -0
- data/lib/stationery/rspec.rb +4 -0
- 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 +22 -1
- data/lib/stationery/testing/marked_text.rb +60 -0
- data/lib/stationery/testing/matchers.rb +35 -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 +12 -0
- data/lib/stationery.rb +11 -0
- metadata +15 -1
data/lib/stationery/canvas.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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)
|
data/lib/stationery/document.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
95
|
-
|
|
96
|
-
pages
|
|
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
|
-
|
|
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
|
-
|
|
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
|
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,
|
|
@@ -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
|
|
10
|
-
# family of the character before them (or
|
|
11
|
-
# run) so words are not chopped and marks
|
|
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 = /[\
|
|
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
|
|
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
|
|
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|
|
|
100
|
-
ligatures ? ligate(gids, text) : [gids, text.chars]
|
|
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]
|
|
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] ||=
|
|
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
|