stationery 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +100 -1
- data/README.md +143 -12
- data/lib/stationery/builder.rb +2 -1
- data/lib/stationery/canvas/debug.rb +2 -1
- data/lib/stationery/canvas/marking.rb +124 -0
- data/lib/stationery/canvas/text.rb +5 -4
- data/lib/stationery/canvas.rb +37 -8
- data/lib/stationery/document.rb +35 -10
- data/lib/stationery/elements/forms.rb +55 -0
- data/lib/stationery/elements/lists.rb +15 -5
- data/lib/stationery/elements.rb +10 -7
- data/lib/stationery/fonts/font.rb +36 -20
- data/lib/stationery/fonts/font_book.rb +3 -0
- data/lib/stationery/fonts/glyph_run.rb +4 -3
- data/lib/stationery/fonts/gpos.rb +11 -9
- data/lib/stationery/fonts/gsub/ligature_subst.rb +44 -0
- data/lib/stationery/fonts/gsub.rb +65 -0
- data/lib/stationery/fonts/ligatures.rb +16 -0
- data/lib/stationery/fonts/registry.rb +18 -9
- data/lib/stationery/fonts/to_unicode.rb +1 -1
- data/lib/stationery/fonts/true_type.rb +36 -12
- data/lib/stationery/fonts/woff.rb +52 -0
- 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 +34 -11
- data/lib/stationery/layout/field.rb +51 -0
- data/lib/stationery/layout/flow.rb +17 -9
- data/lib/stationery/layout/image.rb +4 -2
- data/lib/stationery/layout/list_item.rb +15 -6
- data/lib/stationery/layout/node.rb +2 -0
- data/lib/stationery/layout/paginator.rb +3 -2
- data/lib/stationery/layout/row.rb +1 -1
- data/lib/stationery/layout/svg.rb +9 -2
- 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 +6 -4
- data/lib/stationery/minitest.rb +2 -0
- data/lib/stationery/page.rb +5 -2
- data/lib/stationery/page_templates.rb +7 -4
- data/lib/stationery/pdf/assembler.rb +38 -5
- data/lib/stationery/pdf/encryption/aes.rb +37 -0
- data/lib/stationery/pdf/encryption/rc4.rb +33 -0
- data/lib/stationery/pdf/encryption/revision4.rb +48 -0
- data/lib/stationery/pdf/encryption/revision6.rb +56 -0
- data/lib/stationery/pdf/encryption/standard_security.rb +78 -0
- data/lib/stationery/pdf/serializer.rb +17 -9
- data/lib/stationery/pdf/writer.rb +24 -5
- data/lib/stationery/resources.rb +8 -2
- data/lib/stationery/rich/renderer.rb +3 -3
- data/lib/stationery/structure.rb +15 -9
- data/lib/stationery/svg/bounds.rb +76 -0
- data/lib/stationery/svg/document.rb +48 -18
- data/lib/stationery/svg/gradient.rb +115 -0
- data/lib/stationery/svg/painter.rb +70 -0
- data/lib/stationery/svg/parser.rb +24 -4
- data/lib/stationery/svg/selector.rb +37 -0
- data/lib/stationery/svg/shading.rb +34 -0
- data/lib/stationery/svg/style.rb +37 -20
- data/lib/stationery/svg/stylesheet.rb +47 -0
- data/lib/stationery/svg/text.rb +122 -0
- data/lib/stationery/svg/transform.rb +11 -0
- data/lib/stationery/tagging/element.rb +74 -0
- data/lib/stationery/tagging/tree.rb +43 -0
- data/lib/stationery/tagging/writer.rb +81 -0
- data/lib/stationery/testing/inspector.rb +15 -0
- data/lib/stationery/testing/marked_text.rb +60 -0
- data/lib/stationery/testing/matchers.rb +25 -0
- data/lib/stationery/testing/structure_reader.rb +71 -0
- data/lib/stationery/text/paragraph.rb +28 -12
- data/lib/stationery/text/style.rb +4 -2
- data/lib/stationery/text/wrapper.rb +2 -1
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +8 -0
- data/lib/stationery.rb +26 -0
- metadata +29 -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
|
|
@@ -58,6 +62,19 @@ module Stationery
|
|
|
58
62
|
shape(fill:, stroke:, line_width:, cap:, join:, dash:, even_odd:, opacity:, transform:, &)
|
|
59
63
|
end
|
|
60
64
|
|
|
65
|
+
# Paints `shading` inside the path the block traces. `matrix` maps the
|
|
66
|
+
# shading's coordinates into top-left page space.
|
|
67
|
+
def shade(shading, matrix:, transform: nil, even_odd: false, opacity: nil)
|
|
68
|
+
path = Path.new(self, transform:)
|
|
69
|
+
yield path
|
|
70
|
+
name = @page.use(:Shading, @resources.shading(shading))
|
|
71
|
+
a, b, c, d, e, f = matrix
|
|
72
|
+
graphics(opacity:) do |ops|
|
|
73
|
+
ops << path.to_s << (even_odd ? "W* n" : "W n")
|
|
74
|
+
ops << "#{[a, -b, c, -d, e, @page.height - f].map { |v| num(v) }.join(" ")} cm" << "/#{name} sh"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
61
78
|
def image(image, x:, y:, width:, height:, opacity: nil)
|
|
62
79
|
name = @page.use(:XObject, @resources.image(image))
|
|
63
80
|
graphics(opacity:) do |ops|
|
|
@@ -68,11 +85,22 @@ module Stationery
|
|
|
68
85
|
|
|
69
86
|
# A clickable area opening `target`: a URL, or `#name` for an anchor in
|
|
70
87
|
# this document. Annotation rectangles live in absolute, untransformed
|
|
71
|
-
# page space, so they ignore clips and path transforms.
|
|
72
|
-
|
|
88
|
+
# page space, so they ignore clips and path transforms. `tag:` is the
|
|
89
|
+
# Link element the annotation belongs to in a tagged PDF.
|
|
90
|
+
def link(x, y, w, h, target, tag: nil)
|
|
73
91
|
target = target.to_s
|
|
74
92
|
rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
|
|
75
|
-
|
|
93
|
+
annotation = target.start_with?("#") ? { rect:, dest: target[1..] } : { rect:, url: target }
|
|
94
|
+
@page.annotations << annotation
|
|
95
|
+
own(annotation, tag) if tag
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# An interactive form field's widget (a Forms::Field) over the rectangle.
|
|
99
|
+
def widget(field, x, y, w, h, tag: nil)
|
|
100
|
+
rect = [x, @page.height - y - h, x + w, @page.height - y].map { |v| num_value(v) }
|
|
101
|
+
annotation = { rect:, widget: field }
|
|
102
|
+
@page.annotations << annotation
|
|
103
|
+
adopt(annotation, tag, rect) if tag
|
|
76
104
|
end
|
|
77
105
|
|
|
78
106
|
# Names the point `y` on this page as a link target.
|
|
@@ -81,9 +109,10 @@ module Stationery
|
|
|
81
109
|
end
|
|
82
110
|
|
|
83
111
|
# Leaves room for the page number `anchor` lands on; Structure fills it in
|
|
84
|
-
# and adds the `link:` area ([x, y, w, h]) when the anchor exists.
|
|
85
|
-
|
|
86
|
-
|
|
112
|
+
# and adds the `link:` area ([x, y, w, h]) when the anchor exists. `tags:`
|
|
113
|
+
# are the [link, number] elements they belong to in a tagged PDF.
|
|
114
|
+
def number_slot(anchor, x:, baseline:, width:, style:, link: nil, tags: nil)
|
|
115
|
+
@page.slots << Page::Slot.new(anchor.to_s, x, baseline, width, style, link, tags)
|
|
87
116
|
end
|
|
88
117
|
|
|
89
118
|
def num(value) = PDF::Serializer.number(num_value(value))
|
|
@@ -128,7 +157,7 @@ module Stationery
|
|
|
128
157
|
ops = []
|
|
129
158
|
ops << "/#{@page.use(:ExtGState, @resources.opacity(opacity))} gs" if opacity && opacity < 1
|
|
130
159
|
yield ops
|
|
131
|
-
emit("q", *ops, "Q")
|
|
160
|
+
artifact { emit("q", *ops, "Q") }
|
|
132
161
|
end
|
|
133
162
|
|
|
134
163
|
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,19 @@ 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
|
+
|
|
65
|
+
# Encrypts every render with the standard security handler; see
|
|
66
|
+
# PDF::Encryption::StandardSecurity for the options.
|
|
67
|
+
def encrypt(**)
|
|
68
|
+
config[:encrypt] = PDF::Encryption::StandardSecurity.options(**)
|
|
69
|
+
end
|
|
70
|
+
|
|
58
71
|
# Runs after pagination on every page. `layer: :background` paints under
|
|
59
72
|
# the page's content.
|
|
60
73
|
def page_template(layer: :foreground, &block)
|
|
@@ -74,25 +87,29 @@ module Stationery
|
|
|
74
87
|
end
|
|
75
88
|
end
|
|
76
89
|
|
|
77
|
-
|
|
90
|
+
# `fields` is every form field's name and value from the last render.
|
|
91
|
+
attr_reader :warnings, :fields
|
|
78
92
|
|
|
79
93
|
def page_options = self.class.config[:page]
|
|
80
94
|
def metadata = self.class.config[:metadata]
|
|
81
95
|
|
|
82
|
-
def to_pdf(target = nil, strict: self.class.config[:strict], debug: false
|
|
96
|
+
def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
|
|
97
|
+
tagged: self.class.config[:tagged])
|
|
98
|
+
encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
|
|
99
|
+
tagging = Tagging::Tree.new if tagged
|
|
83
100
|
warnings = Warnings.new
|
|
84
101
|
book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
|
|
85
102
|
call(builder = Builder.new(book:, text: self.class.config[:text]))
|
|
86
103
|
resources = Resources.new
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
pages
|
|
90
|
-
PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:).apply(pages)
|
|
91
|
-
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])
|
|
92
107
|
@warnings = warnings
|
|
108
|
+
@fields = Forms::AcroForm.values(pages)
|
|
93
109
|
raise WarningsError, warnings if strict && warnings.any?
|
|
94
110
|
|
|
95
|
-
|
|
111
|
+
assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, lang: metadata[:lang])
|
|
112
|
+
write(assembler.render, target)
|
|
96
113
|
end
|
|
97
114
|
|
|
98
115
|
# Used by page templates to build nodes into their own root.
|
|
@@ -113,6 +130,14 @@ module Stationery
|
|
|
113
130
|
|
|
114
131
|
private
|
|
115
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
|
+
|
|
116
141
|
def region_measure(book)
|
|
117
142
|
page = Page.new(**page_options)
|
|
118
143
|
lambda do |region, number|
|
|
@@ -122,7 +147,7 @@ module Stationery
|
|
|
122
147
|
end
|
|
123
148
|
|
|
124
149
|
def info
|
|
125
|
-
metadata.to_h do |key, value|
|
|
150
|
+
metadata.except(:lang).to_h do |key, value|
|
|
126
151
|
[INFO_KEYS.fetch(key.to_sym) { key.to_sym }, value.is_a?(Array) ? value.join(", ") : value]
|
|
127
152
|
end
|
|
128
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
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -6,21 +6,23 @@ module Stationery
|
|
|
6
6
|
PARAGRAPH_DEFAULTS = { align: :left, leading: 0 }.freeze
|
|
7
7
|
|
|
8
8
|
# A paragraph. Plain strings are always literal; pass `markup: true` to
|
|
9
|
-
# read inline tags, or a block to build styled runs in Ruby.
|
|
9
|
+
# read inline tags, or a block to build styled runs in Ruby. `heading: 1..6`
|
|
10
|
+
# tags it as a heading in a tagged PDF.
|
|
10
11
|
def text(content = nil, markup: false, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
|
|
11
|
-
**options, &)
|
|
12
|
+
heading: nil, **options, &)
|
|
12
13
|
settings = PARAGRAPH_DEFAULTS.merge(@_builder.text_defaults.slice(:align, :leading)).merge(options)
|
|
13
14
|
style = @_builder.style(options)
|
|
14
15
|
runs = text_runs(content, style, markup, &)
|
|
15
16
|
node = Layout::Text.new(runs, context: @_builder.context(style), align: settings[:align],
|
|
16
|
-
leading: settings[:leading])
|
|
17
|
+
leading: settings[:leading], tag: Tagging::Element.new(Tagging.heading(heading)))
|
|
17
18
|
node.keep_with_next = keep_with_next
|
|
18
19
|
node.break_inside = break_inside
|
|
19
20
|
@_builder.add(mark(node, anchor, bookmark))
|
|
20
21
|
end
|
|
21
22
|
|
|
22
23
|
# A container with padding, background, border and radius. `at: [x, y]`
|
|
23
|
-
# places it at a fixed page position outside the flow.
|
|
24
|
+
# places it at a fixed page position outside the flow. `role:` (:section,
|
|
25
|
+
# :blockquote, :note, :caption, …) groups its content in a tagged PDF.
|
|
24
26
|
# `break_inside: :auto` splits it at any page break, `:avoid` never; by
|
|
25
27
|
# default it splits only when it does not fit on a page of its own.
|
|
26
28
|
def box(at: nil, align: nil, gap: 0, width: nil, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
|
|
@@ -65,15 +67,15 @@ module Stationery
|
|
|
65
67
|
end
|
|
66
68
|
|
|
67
69
|
# An SVG drawing: markup String, or a path to a .svg file. `currentColor`
|
|
68
|
-
# takes `color:`.
|
|
69
|
-
def svg(source, width: nil, height: nil, color: "#000000", align: nil)
|
|
70
|
+
# takes `color:`. `alt:` describes it in a tagged PDF (false: decorative).
|
|
71
|
+
def svg(source, width: nil, height: nil, color: "#000000", align: nil, alt: nil)
|
|
70
72
|
name = source.to_s.lstrip.start_with?("<") ? "inline" : File.basename(source.to_s)
|
|
71
73
|
source = File.read(source.to_s) unless name == "inline"
|
|
72
74
|
document = SVG::Document.parse(source)
|
|
73
75
|
if document.unsupported.any?
|
|
74
76
|
@_builder.warnings << Warnings::UnsupportedSvg.new(elements: document.unsupported, source: name)
|
|
75
77
|
end
|
|
76
|
-
node = Layout::Svg.new(document, width:, height:, color:)
|
|
78
|
+
node = Layout::Svg.new(document, width:, height:, color:, context: @_builder.context, alt:)
|
|
77
79
|
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
78
80
|
end
|
|
79
81
|
|
|
@@ -95,6 +97,7 @@ module Stationery
|
|
|
95
97
|
@_builder.add(node)
|
|
96
98
|
end
|
|
97
99
|
|
|
100
|
+
# `alt:` describes the image in a tagged PDF (false: decorative).
|
|
98
101
|
def image(source, align: nil, **)
|
|
99
102
|
node = Layout::Image.new(source, **)
|
|
100
103
|
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
@@ -17,11 +17,11 @@ module Stationery
|
|
|
17
17
|
def initialize(ttf)
|
|
18
18
|
@ttf = ttf
|
|
19
19
|
@used = {}
|
|
20
|
-
@widths = {}
|
|
21
20
|
@pairs = {}
|
|
22
21
|
@glyphs = {}
|
|
23
|
-
@
|
|
24
|
-
@
|
|
22
|
+
@shapes = { true => {}, false => {} }
|
|
23
|
+
@advances = { true => {}, false => {} }
|
|
24
|
+
@kerns = { true => {}, false => {} }
|
|
25
25
|
@cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
|
|
26
26
|
end
|
|
27
27
|
|
|
@@ -29,11 +29,14 @@ module Stationery
|
|
|
29
29
|
|
|
30
30
|
# Advances and kerning are remembered per string, so measuring the same
|
|
31
31
|
# word again (wrapping, then laying out the line) allocates nothing.
|
|
32
|
-
|
|
33
|
-
|
|
32
|
+
# Letter spacing is added per glyph, so a ligature counts once; any
|
|
33
|
+
# letter spacing turns ligatures off, as it does when drawing.
|
|
34
|
+
def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true)
|
|
35
|
+
ligatures &&= letter_spacing.zero?
|
|
36
|
+
width = scale(advance_units(text, ligatures), size) + (letter_spacing * shape(text, ligatures).first.size)
|
|
34
37
|
return width unless kerning
|
|
35
38
|
|
|
36
|
-
width + (kerning_units(text) * size / 1000.0)
|
|
39
|
+
width + (kerning_units(text, ligatures) * size / 1000.0)
|
|
37
40
|
end
|
|
38
41
|
|
|
39
42
|
def ascender(size) = scale(@ttf.ascender, size)
|
|
@@ -53,15 +56,13 @@ module Stationery
|
|
|
53
56
|
|
|
54
57
|
def encode(text) = glyph_run(text).gids.map { |gid| code(gid) }.pack("n*")
|
|
55
58
|
|
|
56
|
-
# `
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
gid
|
|
62
|
-
end
|
|
59
|
+
# `ligatures:` substitutes the font's standard ligatures; `kerning:`
|
|
60
|
+
# then fills the adjustments with pair kerning between the glyphs.
|
|
61
|
+
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] }
|
|
63
64
|
adjust = gids.each_with_index.map { |gid, i| kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0 }
|
|
64
|
-
GlyphRun.new(font: self, gids:, adjust:)
|
|
65
|
+
GlyphRun.new(font: self, gids:, adjust:, chars:)
|
|
65
66
|
end
|
|
66
67
|
|
|
67
68
|
def used?
|
|
@@ -74,7 +75,8 @@ module Stationery
|
|
|
74
75
|
@cid_keyed ? @ttf.cff.cid_for(gid) : gid
|
|
75
76
|
end
|
|
76
77
|
|
|
77
|
-
# { code =>
|
|
78
|
+
# { code => text } for every glyph drawn so far; a ligature's text is
|
|
79
|
+
# every character it stands for.
|
|
78
80
|
def used_codes
|
|
79
81
|
@used.to_h { |gid, char| [code(gid), char] }
|
|
80
82
|
end
|
|
@@ -91,13 +93,27 @@ module Stationery
|
|
|
91
93
|
|
|
92
94
|
private
|
|
93
95
|
|
|
94
|
-
|
|
95
|
-
|
|
96
|
+
# [gids, source text of each glyph], remembered per string.
|
|
97
|
+
def shape(text, ligatures)
|
|
98
|
+
@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
|
|
101
|
+
end
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def ligate(gids, text)
|
|
105
|
+
start = 0
|
|
106
|
+
glyphs = @ttf.ligatures.substitute(gids)
|
|
107
|
+
chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
|
|
108
|
+
[glyphs.map(&:first), chars].each(&:freeze).freeze
|
|
109
|
+
end
|
|
110
|
+
|
|
111
|
+
def advance_units(text, ligatures)
|
|
112
|
+
@advances[ligatures][text] ||= shape(text, ligatures).first.sum { |gid| @ttf.advance(gid) }
|
|
96
113
|
end
|
|
97
114
|
|
|
98
|
-
def kerning_units(text)
|
|
99
|
-
@kerns[text] ||= text.
|
|
100
|
-
.each_cons(2).sum { |left, right| pair(left, right) }
|
|
115
|
+
def kerning_units(text, ligatures)
|
|
116
|
+
@kerns[ligatures][text] ||= shape(text, ligatures).first.each_cons(2).sum { |left, right| pair(left, right) }
|
|
101
117
|
end
|
|
102
118
|
|
|
103
119
|
# Kerning between two glyphs in thousandths of an em.
|
|
@@ -34,6 +34,9 @@ module Stationery
|
|
|
34
34
|
end
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
+
# Whether `name` is registered, bundled or an installed pack.
|
|
38
|
+
def known?(name) = !(@families[name.to_s] || bundled(name) || pack(name)).nil?
|
|
39
|
+
|
|
37
40
|
# The runs split so every character is drawn by a font that has it. Runs
|
|
38
41
|
# this book already split come back as they are.
|
|
39
42
|
def fallback(runs)
|
|
@@ -3,16 +3,17 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Fonts
|
|
5
5
|
# Glyph ids for one run of text in one font, with an extra advance after
|
|
6
|
-
# each glyph in thousandths of the font size (positive widens)
|
|
6
|
+
# each glyph in thousandths of the font size (positive widens) and the
|
|
7
|
+
# source text of each glyph (several characters for a ligature). All-zero
|
|
7
8
|
# adjustments draw as a plain Tj string; otherwise as a TJ array. Glyphs
|
|
8
9
|
# are written as the font's character codes (see Font#code).
|
|
9
|
-
GlyphRun = Data.define(:font, :gids, :adjust) do
|
|
10
|
+
GlyphRun = Data.define(:font, :gids, :adjust, :chars) do
|
|
10
11
|
def width(size, letter_spacing: 0)
|
|
11
12
|
units = gids.sum { |gid| font.ttf.advance(gid) }
|
|
12
13
|
(units * size / font.ttf.units_per_em.to_f) + (adjust.sum * size / 1000.0) + (letter_spacing * gids.size)
|
|
13
14
|
end
|
|
14
15
|
|
|
15
|
-
def with_word_spacing(
|
|
16
|
+
def with_word_spacing(extra_points, size)
|
|
16
17
|
extra = extra_points * 1000.0 / size
|
|
17
18
|
with(adjust: adjust.each_with_index.map { |a, i| chars[i] == " " ? a + extra : a })
|
|
18
19
|
end
|
|
@@ -15,19 +15,21 @@ module Stationery
|
|
|
15
15
|
base = ttf.table_offset("GPOS")
|
|
16
16
|
return unless base && ttf.u16(base) == 1
|
|
17
17
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
list = base + ttf.u16(base + 8)
|
|
22
|
-
new(indices.map { |i| lookup(ttf, list + ttf.u16(list + 2 + (i * 2))) })
|
|
18
|
+
offsets = feature_lookups(ttf, base, "kern")
|
|
19
|
+
new(offsets.map { |offset| lookup(ttf, offset) }) if offsets.any?
|
|
23
20
|
end
|
|
24
21
|
|
|
25
|
-
|
|
22
|
+
# Offsets of the lookups every `tag` feature record of the GSUB or GPOS
|
|
23
|
+
# table at `base` names, in LookupList order.
|
|
24
|
+
def self.feature_lookups(ttf, base, tag)
|
|
25
|
+
list = base + ttf.u16(base + 6)
|
|
26
26
|
records = Array.new(ttf.u16(list)) { |i| list + 2 + (i * 6) }
|
|
27
|
-
records.select { |record| ttf.data.byteslice(record, 4) ==
|
|
27
|
+
indices = records.select { |record| ttf.data.byteslice(record, 4) == tag }.flat_map do |record|
|
|
28
28
|
feature = list + ttf.u16(record + 4)
|
|
29
29
|
Array.new(ttf.u16(feature + 2)) { |j| ttf.u16(feature + 4 + (j * 2)) }
|
|
30
|
-
end
|
|
30
|
+
end
|
|
31
|
+
lookups = base + ttf.u16(base + 8)
|
|
32
|
+
indices.uniq.sort.map { |i| lookups + ttf.u16(lookups + 2 + (i * 2)) }
|
|
31
33
|
end
|
|
32
34
|
|
|
33
35
|
# A lookup's PairPos subtables; other lookup types have none.
|
|
@@ -42,7 +44,7 @@ module Stationery
|
|
|
42
44
|
end
|
|
43
45
|
end.freeze
|
|
44
46
|
end
|
|
45
|
-
private_class_method :
|
|
47
|
+
private_class_method :lookup
|
|
46
48
|
|
|
47
49
|
def initialize(lookups)
|
|
48
50
|
@lookups = lookups.freeze
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
class Gsub
|
|
6
|
+
# LigatureSubst format 1 (GSUB lookup type 4): for each first glyph, the
|
|
7
|
+
# ligatures starting with it, longest first.
|
|
8
|
+
class LigatureSubst
|
|
9
|
+
def self.read(ttf, offset)
|
|
10
|
+
return unless ttf.u16(offset) == 1
|
|
11
|
+
|
|
12
|
+
sets = Gpos::Coverage.read(ttf, offset + ttf.u16(offset + 2)).each_with_index.to_h do |first, i|
|
|
13
|
+
set = offset + ttf.u16(offset + 6 + (i * 2))
|
|
14
|
+
[first, ligatures(ttf, set).sort_by.with_index { |(rest, _), j| [-rest.size, j] }.freeze]
|
|
15
|
+
end
|
|
16
|
+
new(sets)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
# [[component gids after the first, ligature gid], ...]
|
|
20
|
+
def self.ligatures(ttf, set)
|
|
21
|
+
Array.new(ttf.u16(set)) do |i|
|
|
22
|
+
ligature = set + ttf.u16(set + 2 + (i * 2))
|
|
23
|
+
count = ttf.u16(ligature + 2)
|
|
24
|
+
[ttf.data.byteslice(ligature + 4, (count - 1) * 2).unpack("n*").freeze, ttf.u16(ligature)]
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
private_class_method :ligatures
|
|
28
|
+
|
|
29
|
+
def initialize(sets)
|
|
30
|
+
@sets = sets.freeze
|
|
31
|
+
freeze
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# [ligature gid, glyphs consumed] for the longest ligature at `index`, or nil.
|
|
35
|
+
def match(gids, index)
|
|
36
|
+
@sets[gids[index]]&.each do |components, ligature|
|
|
37
|
+
return [ligature, components.size + 1] if gids[index + 1, components.size] == components
|
|
38
|
+
end
|
|
39
|
+
nil
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Standard ligatures from the GSUB `liga` feature: LigatureSubst lookups
|
|
6
|
+
# (format 1), directly or behind Extension lookups. Only `liga` applies;
|
|
7
|
+
# contextual (`clig`) and discretionary (`dlig`) ligatures are left out.
|
|
8
|
+
# Scripts, languages and lookup flags are not distinguished.
|
|
9
|
+
class Gsub
|
|
10
|
+
LIGATURE = 4
|
|
11
|
+
EXTENSION = 7
|
|
12
|
+
|
|
13
|
+
# nil when the font has no GSUB table or no `liga` ligatures.
|
|
14
|
+
def self.parse(ttf)
|
|
15
|
+
base = ttf.table_offset("GSUB")
|
|
16
|
+
return unless base && ttf.u16(base) == 1
|
|
17
|
+
|
|
18
|
+
lookups = Gpos.feature_lookups(ttf, base, "liga").map { |offset| lookup(ttf, offset) }.reject(&:empty?)
|
|
19
|
+
new(lookups) if lookups.any?
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
# A lookup's LigatureSubst subtables; other lookup types have none.
|
|
23
|
+
def self.lookup(ttf, offset)
|
|
24
|
+
type = ttf.u16(offset)
|
|
25
|
+
subtables = Array.new(ttf.u16(offset + 4)) { |i| offset + ttf.u16(offset + 6 + (i * 2)) }
|
|
26
|
+
subtables.filter_map do |subtable|
|
|
27
|
+
if type == LIGATURE
|
|
28
|
+
LigatureSubst.read(ttf, subtable)
|
|
29
|
+
elsif type == EXTENSION && ttf.u16(subtable + 2) == LIGATURE
|
|
30
|
+
LigatureSubst.read(ttf, subtable + ttf.u32(subtable + 4))
|
|
31
|
+
end
|
|
32
|
+
end.freeze
|
|
33
|
+
end
|
|
34
|
+
private_class_method :lookup
|
|
35
|
+
|
|
36
|
+
def initialize(lookups)
|
|
37
|
+
@lookups = lookups.freeze
|
|
38
|
+
freeze
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# [[gid, number of source glyphs it stands for], ...]. Lookups apply in
|
|
42
|
+
# order; within one, the first subtable matching at a position wins and
|
|
43
|
+
# its glyph is not matched again by that lookup.
|
|
44
|
+
def substitute(gids)
|
|
45
|
+
@lookups.reduce(gids.map { |gid| [gid, 1] }) { |glyphs, subtables| apply(subtables, glyphs) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
private
|
|
49
|
+
|
|
50
|
+
def apply(subtables, glyphs)
|
|
51
|
+
ids = glyphs.map(&:first)
|
|
52
|
+
result = []
|
|
53
|
+
i = 0
|
|
54
|
+
while i < glyphs.size
|
|
55
|
+
match = nil
|
|
56
|
+
subtables.find { |subtable| match = subtable.match(ids, i) }
|
|
57
|
+
length = match ? match.last : 1
|
|
58
|
+
result << [match ? match.first : ids[i], glyphs[i, length].sum(&:last)]
|
|
59
|
+
i += length
|
|
60
|
+
end
|
|
61
|
+
result
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# Chooses where a font's ligatures come from. Every source answers
|
|
6
|
+
# `substitute(gids)` with [[gid, source glyph count], ...].
|
|
7
|
+
module Ligatures
|
|
8
|
+
# For fonts without ligatures: every glyph stands for itself.
|
|
9
|
+
module NONE
|
|
10
|
+
def self.substitute(gids) = gids.map { |gid| [gid, 1] }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def self.for(ttf) = Gsub.parse(ttf) || NONE
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|