stationery 0.10.0 → 0.11.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 +18 -0
- data/README.md +247 -30
- data/lib/stationery/builder.rb +9 -0
- data/lib/stationery/canvas/text.rb +8 -2
- data/lib/stationery/document.rb +56 -14
- data/lib/stationery/elements.rb +31 -3
- data/lib/stationery/errors.rb +5 -1
- data/lib/stationery/fonts/font.rb +54 -4
- data/lib/stationery/fonts/font_book.rb +14 -5
- data/lib/stationery/fonts/glyph_run.rb +35 -5
- data/lib/stationery/fonts/shaped_run.rb +160 -0
- data/lib/stationery/fonts/shaping.rb +63 -0
- data/lib/stationery/forms/typeface.rb +10 -3
- data/lib/stationery/html/css.rb +38 -3
- data/lib/stationery/html/tree_builder/blocks.rb +9 -3
- data/lib/stationery/images/images.rb +6 -6
- data/lib/stationery/images/resampled.rb +3 -2
- data/lib/stationery/images/webp/bit_reader.rb +78 -0
- data/lib/stationery/images/webp/container.rb +62 -0
- data/lib/stationery/images/webp/image_stream.rb +168 -0
- data/lib/stationery/images/webp/lossless.rb +101 -0
- data/lib/stationery/images/webp/predictor.rb +118 -0
- data/lib/stationery/images/webp/prefix_code.rb +168 -0
- data/lib/stationery/images/webp/transforms.rb +93 -0
- data/lib/stationery/images/webp.rb +71 -0
- data/lib/stationery/layout/columns/balancer.rb +66 -0
- data/lib/stationery/layout/columns/pour.rb +47 -0
- data/lib/stationery/layout/columns.rb +191 -0
- data/lib/stationery/layout/floated.rb +50 -0
- data/lib/stationery/layout/flow/floating.rb +51 -0
- data/lib/stationery/layout/flow/placement.rb +147 -0
- data/lib/stationery/layout/flow/splitter.rb +194 -0
- data/lib/stationery/layout/flow.rb +20 -117
- data/lib/stationery/layout/image.rb +1 -1
- data/lib/stationery/layout/mark.rb +3 -1
- data/lib/stationery/layout/node.rb +14 -0
- data/lib/stationery/layout/paginator.rb +7 -2
- data/lib/stationery/layout/table.rb +52 -6
- data/lib/stationery/layout/text.rb +31 -12
- data/lib/stationery/page.rb +26 -1
- data/lib/stationery/page_templates.rb +1 -1
- data/lib/stationery/pdf/assembler.rb +20 -4
- data/lib/stationery/pdf/conformance.rb +11 -1
- data/lib/stationery/pdf/page_sealer.rb +35 -0
- data/lib/stationery/rich/nodes.rb +3 -2
- data/lib/stationery/rich/renderer/boxes.rb +25 -3
- data/lib/stationery/rich/renderer.rb +5 -3
- data/lib/stationery/rich/styles.rb +1 -1
- data/lib/stationery/shaper.rb +109 -0
- data/lib/stationery/testing/marked_text.rb +34 -4
- data/lib/stationery/text/exclusions.rb +32 -0
- data/lib/stationery/text/line.rb +7 -2
- data/lib/stationery/text/paragraph.rb +69 -10
- data/lib/stationery/text/wrapper/remainder.rb +59 -0
- data/lib/stationery/text/wrapper.rb +54 -10
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +10 -0
- metadata +22 -1
data/lib/stationery/document.rb
CHANGED
|
@@ -24,7 +24,8 @@ 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, tagged: false,
|
|
27
|
+
templates: [], regions: [], strict: false, tagged: false, incremental: false, images: {},
|
|
28
|
+
attachments: [], shaping: {} }
|
|
28
29
|
end
|
|
29
30
|
end
|
|
30
31
|
|
|
@@ -42,6 +43,14 @@ module Stationery
|
|
|
42
43
|
config[:fallbacks] = names.map(&:to_s)
|
|
43
44
|
end
|
|
44
45
|
|
|
46
|
+
# Hands every text to `shaper` to place its glyphs: the hook for
|
|
47
|
+
# complex scripts (Arabic, Indic, Thai, …) and right-to-left text, which
|
|
48
|
+
# stationery does not shape itself. Anything answering `call(text, font,
|
|
49
|
+
# **options)`; see Shaper. `shaper nil` takes an inherited one away.
|
|
50
|
+
def shaper(shaper)
|
|
51
|
+
config[:shaping] = { shaper: Shaper.check(shaper) }
|
|
52
|
+
end
|
|
53
|
+
|
|
45
54
|
def default_text(**options)
|
|
46
55
|
config[:text] = config[:text].merge(options)
|
|
47
56
|
end
|
|
@@ -68,7 +77,7 @@ module Stationery
|
|
|
68
77
|
|
|
69
78
|
# Bitmap defaults: `max_ppi:` (300; nil disables) is the resolution
|
|
70
79
|
# above twice of which a drawn image is reported as oversized, and
|
|
71
|
-
# `downscale: true` resamples PNGs to it. See Layout::Image.
|
|
80
|
+
# `downscale: true` resamples PNGs and WebPs to it. See Layout::Image.
|
|
72
81
|
def images(**options)
|
|
73
82
|
config[:images] = config[:images].merge(options)
|
|
74
83
|
end
|
|
@@ -78,6 +87,13 @@ module Stationery
|
|
|
78
87
|
config[:strict] = value
|
|
79
88
|
end
|
|
80
89
|
|
|
90
|
+
# Writes each page as soon as it is painted and lets go of it, so a long
|
|
91
|
+
# document peaks far lower: see #to_pdf for what the file looks like and
|
|
92
|
+
# which renders take the usual path all the same.
|
|
93
|
+
def incremental(value = true) # rubocop:disable Style/OptionalBooleanParameter
|
|
94
|
+
config[:incremental] = value
|
|
95
|
+
end
|
|
96
|
+
|
|
81
97
|
# Writes a tagged (accessible) PDF: a structure tree of headings,
|
|
82
98
|
# paragraphs and figures, and headers and footers marked as artifacts.
|
|
83
99
|
# Set `metadata lang:` and give images `alt:` text.
|
|
@@ -158,11 +174,20 @@ module Stationery
|
|
|
158
174
|
# path or an IO. With a block the file is streamed to it in pieces as it
|
|
159
175
|
# is written and the call answers the number of bytes: the first bytes
|
|
160
176
|
# leave before the last page is assembled and no output buffer is built.
|
|
161
|
-
#
|
|
177
|
+
#
|
|
178
|
+
# `incremental: true` writes each page's body as soon as the page is
|
|
179
|
+
# painted and lets go of its operators, to a block before the next page is
|
|
180
|
+
# painted. The file is the same document with its objects in another
|
|
181
|
+
# order, and what is painted once every page is known (headers, footers,
|
|
182
|
+
# page templates, contents page numbers) is a content stream of its own.
|
|
183
|
+
# A render that must be checked before anything is written takes the
|
|
184
|
+
# usual path instead: one with `conformance:` or `sign:`, and a `strict`
|
|
185
|
+
# one that goes to a block.
|
|
162
186
|
def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
|
|
163
187
|
tagged: self.class.config[:tagged], page_labels: self.class.config[:page_labels], attachments: [],
|
|
164
188
|
xmp: metadata[:xmp] != false, conformance: self.class.config[:conformance],
|
|
165
|
-
factur_x: self.class.config[:factur_x], sign: self.class.config[:sign],
|
|
189
|
+
factur_x: self.class.config[:factur_x], sign: self.class.config[:sign],
|
|
190
|
+
shaper: self.class.config[:shaping][:shaper], incremental: self.class.config[:incremental], &block)
|
|
166
191
|
invoice = PDF::FacturX.for(factur_x, self)
|
|
167
192
|
attachments = PDF::Attachments.merge(self.class.config[:attachments], attachments, invoice&.attachment)
|
|
168
193
|
conformance = PDF::Conformance.for(invoice ? invoice.conformance(conformance) : conformance)
|
|
@@ -173,7 +198,8 @@ module Stationery
|
|
|
173
198
|
raise ArgumentError, "pass a target or a block, not both" if target && block
|
|
174
199
|
|
|
175
200
|
options = { strict:, debug:, encrypt:, tagged: tagged || conformance&.pdf_ua?, page_labels:, attachments:,
|
|
176
|
-
xmp: xmp || !conformance.nil?, conformance:, invoice:, signature:, sink: block
|
|
201
|
+
xmp: xmp || !conformance.nil?, conformance:, invoice:, signature:, sink: block, shaper:,
|
|
202
|
+
incremental: incremental && !conformance && !signature && !(strict && block) }
|
|
177
203
|
Stationery.instrument("render.stationery", document: self.class.name) do |event|
|
|
178
204
|
block ? render_pdf(event, **options) : write(render_pdf(event, **options), target)
|
|
179
205
|
end
|
|
@@ -200,14 +226,16 @@ module Stationery
|
|
|
200
226
|
def builder_for(book) = Builder.new(book:, text: self.class.config[:text], images: self.class.config[:images])
|
|
201
227
|
|
|
202
228
|
# The PDF bytes; `event` is the render.stationery payload it fills in.
|
|
203
|
-
def render_pdf(event, strict:, debug:, tagged:, conformance:, **assembly)
|
|
229
|
+
def render_pdf(event, strict:, debug:, tagged:, conformance:, shaper:, incremental:, **assembly)
|
|
204
230
|
tagging = Tagging::Tree.new if tagged
|
|
205
231
|
warnings = Warnings.new
|
|
206
|
-
book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings
|
|
232
|
+
book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:,
|
|
233
|
+
shaper:, language: metadata[:lang])
|
|
207
234
|
builder = builder_for(book)
|
|
208
235
|
Stationery.instrument("build.stationery", document: self.class.name) { call(builder) }
|
|
209
236
|
resources = Resources.new
|
|
210
|
-
|
|
237
|
+
sealer = sealer_for(incremental, conformance, **assembly)
|
|
238
|
+
pages = paginate(builder, sealer, book:, resources:, warnings:, debug:, tagging:)
|
|
211
239
|
outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:, tagging:))
|
|
212
240
|
tagging&.audit(pages, warnings, lang: metadata[:lang])
|
|
213
241
|
@warnings = warnings
|
|
@@ -217,15 +245,15 @@ module Stationery
|
|
|
217
245
|
event[:warnings] = warnings.size
|
|
218
246
|
raise WarningsError, warnings if strict && warnings.any?
|
|
219
247
|
|
|
220
|
-
pdf = assemble(pages, resources, outline, tagging:, conformance:, **assembly)
|
|
248
|
+
pdf = assemble(pages, resources, outline, tagging:, conformance:, writer: sealer.writer, **assembly)
|
|
221
249
|
event[:bytes] = byte_count(pdf)
|
|
222
250
|
pdf
|
|
223
251
|
end
|
|
224
252
|
|
|
225
253
|
def assemble(pages, resources, outline, encrypt:, tagging:, page_labels:, attachments:, xmp:, conformance:,
|
|
226
|
-
invoice:, signature:, sink:)
|
|
227
|
-
|
|
228
|
-
|
|
254
|
+
invoice:, signature:, sink:, writer:)
|
|
255
|
+
assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, tagging:, xmp:, writer:,
|
|
256
|
+
encryption: writer ? nil : encryption(encrypt),
|
|
229
257
|
lang: metadata[:lang], page_labels: PDF::PageLabels.entries(page_labels),
|
|
230
258
|
attachments:, conformance:, xmp_extensions: invoice&.xmp_extensions || {},
|
|
231
259
|
xmp_schemas: [invoice&.xmp_schema].compact, signature:, sink:)
|
|
@@ -235,12 +263,26 @@ module Stationery
|
|
|
235
263
|
end
|
|
236
264
|
|
|
237
265
|
def byte_count(pdf) = pdf.is_a?(String) ? pdf.bytesize : pdf
|
|
266
|
+
def encryption(options) = options && PDF::Encryption::StandardSecurity.new(**options)
|
|
267
|
+
|
|
268
|
+
# What closes each page as it is painted. An incremental render writes
|
|
269
|
+
# every body at once, to the writer the rest of the file follows on; any
|
|
270
|
+
# other seals the pages nothing paints on again (no header, footer or
|
|
271
|
+
# page template, and no conformance audit to read them).
|
|
272
|
+
def sealer_for(incremental, conformance, encrypt:, sink:, **)
|
|
273
|
+
return PDF::PageSealer.new(writer: PDF::Writer.new(encryption: encryption(encrypt), sink:)) if incremental
|
|
274
|
+
|
|
275
|
+
config = self.class.config
|
|
276
|
+
PDF::PageSealer.new(final: conformance.nil? && config[:templates].empty? && config[:regions].empty?)
|
|
277
|
+
end
|
|
238
278
|
|
|
239
|
-
|
|
279
|
+
# Takes the root from the builder as it hands it to the paginator, so
|
|
280
|
+
# nothing here keeps the nodes of a page that has been painted.
|
|
281
|
+
def paginate(builder, sealer, book:, resources:, warnings:, debug:, tagging:)
|
|
240
282
|
Stationery.instrument("paginate.stationery", document: self.class.name) do |event|
|
|
241
283
|
regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
|
|
242
284
|
paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:, tagging:)
|
|
243
|
-
paginator.paginate(
|
|
285
|
+
paginator.paginate(builder.release) { |page| sealer.call(page) }.tap do |pages|
|
|
244
286
|
PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:, tagging:).apply(pages)
|
|
245
287
|
event[:pages] = pages.size
|
|
246
288
|
end
|
data/lib/stationery/elements.rb
CHANGED
|
@@ -28,12 +28,16 @@ module Stationery
|
|
|
28
28
|
# :blockquote, :note, :caption, …) groups its content in a tagged PDF.
|
|
29
29
|
# `break_inside: :auto` splits it at any page break, `:avoid` never; by
|
|
30
30
|
# default it splits only when it does not fit on a page of its own.
|
|
31
|
+
# `float: :left` or `:right` takes it to that side of the flow it is in,
|
|
32
|
+
# `margin:` away from the text that wraps beside it; it needs a `width:`.
|
|
31
33
|
def box(at: nil, align: nil, gap: 0, width: nil, keep_with_next: nil, break_inside: nil, anchor: nil, bookmark: nil,
|
|
32
|
-
**, &)
|
|
34
|
+
float: nil, margin: nil, **, &)
|
|
33
35
|
node = Layout::Box.new(container(align:, gap:, &), width:, **)
|
|
34
36
|
node.keep_with_next = keep_with_next
|
|
35
37
|
node.break_inside = break_inside
|
|
36
38
|
node = mark(node, anchor, bookmark)
|
|
39
|
+
return @_builder.add(floated(node, float, margin, at:, width:)) if float || margin
|
|
40
|
+
|
|
37
41
|
@_builder.add(at ? Layout::Positioned.new(node, x: at[0], y: at[1], width:) : node)
|
|
38
42
|
end
|
|
39
43
|
|
|
@@ -54,6 +58,16 @@ module Stationery
|
|
|
54
58
|
@_builder.add(node)
|
|
55
59
|
end
|
|
56
60
|
|
|
61
|
+
# One flow poured through `count` columns, newspaper style (a `row` is
|
|
62
|
+
# columns side by side with content of their own). `gap:` is the space
|
|
63
|
+
# between columns; `balance: true` ends them at nearly the same height
|
|
64
|
+
# where the content ends, `false` fills each before the next starts;
|
|
65
|
+
# `rule: true | { color:, width: }` draws a line between them. Continues
|
|
66
|
+
# across pages; may hold another `columns`.
|
|
67
|
+
def columns(count: 2, gap: 12, balance: true, rule: nil, align: nil, &)
|
|
68
|
+
@_builder.add(Layout::Columns.new(container(align:, &), count:, gap:, balance:, rule:))
|
|
69
|
+
end
|
|
70
|
+
|
|
57
71
|
# A base with layers painted over it: every ordinary child is part of the
|
|
58
72
|
# base (which sets the height), every `layer` floats over it relative to
|
|
59
73
|
# the stack's rectangle, taking no space. The stack moves to the next
|
|
@@ -122,9 +136,13 @@ module Stationery
|
|
|
122
136
|
end
|
|
123
137
|
|
|
124
138
|
# `alt:` describes the image in a tagged PDF (false: decorative). Sizing,
|
|
125
|
-
# `fit: :cover`, `radius:` and `rotate:` as for Layout::Image.
|
|
126
|
-
|
|
139
|
+
# `fit: :cover`, `radius:` and `rotate:` as for Layout::Image. `float:
|
|
140
|
+
# :left` or `:right` takes it to that side of the flow it is in, `margin:`
|
|
141
|
+
# away from the text that wraps beside it (`align:` is then not read).
|
|
142
|
+
def image(source, align: nil, float: nil, margin: nil, **)
|
|
127
143
|
node = Layout::Image.new(source, **@_builder.images, **)
|
|
144
|
+
return @_builder.add(floated(node, float, margin)) if float || margin
|
|
145
|
+
|
|
128
146
|
@_builder.add(align ? Layout::Flow.new([node], align:) : node)
|
|
129
147
|
end
|
|
130
148
|
|
|
@@ -155,6 +173,16 @@ module Stationery
|
|
|
155
173
|
|
|
156
174
|
private
|
|
157
175
|
|
|
176
|
+
# `margin:` is a number (kept on the sides that face the text) or names
|
|
177
|
+
# the sides as `padding:` does.
|
|
178
|
+
def floated(node, side, margin, at: nil, width: true)
|
|
179
|
+
raise ArgumentError, "margin: is the space around a float: pass float: :left or :right" unless side
|
|
180
|
+
raise ArgumentError, "a box is placed by float: or by at:, not both" if at
|
|
181
|
+
raise ArgumentError, "a floated box needs a width: points, a fraction or :auto" unless width
|
|
182
|
+
|
|
183
|
+
Layout::Floated.new(node, side:, margin: margin || 0)
|
|
184
|
+
end
|
|
185
|
+
|
|
158
186
|
# What the drawing uses that is not drawn, and nesting that was flattened.
|
|
159
187
|
def svg_warnings(document, name)
|
|
160
188
|
if document.unsupported.any?
|
data/lib/stationery/errors.rb
CHANGED
|
@@ -6,9 +6,13 @@ module Stationery
|
|
|
6
6
|
# A font file stationery cannot read or embed (CFF2, collections, WOFF, …).
|
|
7
7
|
class UnsupportedFont < Error; end
|
|
8
8
|
|
|
9
|
-
# An image format stationery cannot embed (
|
|
9
|
+
# An image format stationery cannot embed (GIF, lossy WebP, interlaced PNG, …).
|
|
10
10
|
class UnsupportedImage < Error; end
|
|
11
11
|
|
|
12
|
+
# An answer of a shaper (see Shaper) that cannot be drawn: not glyphs, a
|
|
13
|
+
# glyph the font does not have, a cluster outside the text.
|
|
14
|
+
class ShaperError < Error; end
|
|
15
|
+
|
|
12
16
|
# Raised by a strict render (`to_pdf(strict: true)`) that produced warnings.
|
|
13
17
|
class WarningsError < Error
|
|
14
18
|
attr_reader :warnings
|
|
@@ -24,17 +24,28 @@ module Stationery
|
|
|
24
24
|
0x3000 => 1.0 }.freeze
|
|
25
25
|
SPACE_LIKE = { 0x2007 => "0", 0x2008 => "." }.freeze
|
|
26
26
|
NO_FEATURES = [].freeze
|
|
27
|
+
# The bytes of text the shape and metrics memos hold before they start
|
|
28
|
+
# over. They exist for the words and lines a document repeats; without a
|
|
29
|
+
# limit a long document would keep every line it ever drew.
|
|
30
|
+
MEMO_BYTES = 1 << 16
|
|
31
|
+
REPLACEMENT = "\uFFFD"
|
|
27
32
|
|
|
28
33
|
attr_reader :ttf
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
# `shaper:` places this font's glyphs instead of the font itself (see
|
|
36
|
+
# Shaper); `path:` is the file the font was read from and `language:`
|
|
37
|
+
# the document's, both for the shaper.
|
|
38
|
+
def initialize(ttf, shaper: nil, path: nil, language: nil)
|
|
31
39
|
@ttf = ttf
|
|
40
|
+
@shaping = shaper && Shaping.new(self, shaper, path:, language:)
|
|
41
|
+
@loose = nil
|
|
32
42
|
@used = {}
|
|
33
43
|
@pairs = {}
|
|
34
44
|
@glyphs = {}
|
|
35
45
|
@blanks = {}
|
|
36
46
|
@shapes = {}.compare_by_identity
|
|
37
47
|
@metrics = {}.compare_by_identity
|
|
48
|
+
@memoised = 0
|
|
38
49
|
@keys = { true => {}, false => {} }
|
|
39
50
|
@frozen_keys = { true => {}.compare_by_identity, false => {}.compare_by_identity }
|
|
40
51
|
@cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
|
|
@@ -47,7 +58,14 @@ module Stationery
|
|
|
47
58
|
# Letter spacing is added per glyph, so a ligature counts once; any
|
|
48
59
|
# letter spacing turns ligatures off, as it does when drawing.
|
|
49
60
|
# `features:` are OpenType feature tags applied on top of `liga`.
|
|
50
|
-
|
|
61
|
+
# With a shaper the width is its glyphs'; `shape: false` measures what
|
|
62
|
+
# #glyph_run draws.
|
|
63
|
+
def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true, features: NO_FEATURES,
|
|
64
|
+
shape: true)
|
|
65
|
+
if @shaping && shape && (run = shaped(text, size, letter_spacing:, kerning:, ligatures:, features:))
|
|
66
|
+
return run.width
|
|
67
|
+
end
|
|
68
|
+
|
|
51
69
|
ligatures &&= letter_spacing.zero?
|
|
52
70
|
key = shape_key(ligatures, features)
|
|
53
71
|
advance, kern = metrics(text, key)
|
|
@@ -83,6 +101,7 @@ module Stationery
|
|
|
83
101
|
# with pair kerning between the glyphs.
|
|
84
102
|
def glyph_run(text, kerning: false, ligatures: true, features: NO_FEATURES)
|
|
85
103
|
gids, chars, blanks = shape(text, shape_key(ligatures, features))
|
|
104
|
+
gids.each { |gid| @used.delete(gid) if @loose.delete(gid) } if @loose
|
|
86
105
|
gids.each_with_index { |gid, i| @used[gid] ||= blanks[i] ? " " : chars[i] }
|
|
87
106
|
adjust = gids.each_with_index.map do |gid, i|
|
|
88
107
|
kern = kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0
|
|
@@ -91,6 +110,28 @@ module Stationery
|
|
|
91
110
|
GlyphRun.new(font: self, gids:, adjust:, chars:)
|
|
92
111
|
end
|
|
93
112
|
|
|
113
|
+
# The text as the document's shaper placed it, a ShapedRun with its
|
|
114
|
+
# letter spacing; nil without a shaper and when the shaper declines.
|
|
115
|
+
def shaped(text, size, letter_spacing: 0, kerning: false, ligatures: true, features: NO_FEATURES)
|
|
116
|
+
return unless @shaping
|
|
117
|
+
|
|
118
|
+
@shaping.run(text, size, kerning:, ligatures: ligatures && letter_spacing.zero?, features:)
|
|
119
|
+
&.with_letter_spacing(letter_spacing)
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Remembers a glyph a shaper placed and the text it stands for, and
|
|
123
|
+
# answers the text the ToUnicode map gives it: the first it stood for.
|
|
124
|
+
# `loosely:` is for a glyph that shares its text with others (it is
|
|
125
|
+
# shown inside a Span): the next text the glyph stands for replaces it.
|
|
126
|
+
def use(gid, text, loosely: false)
|
|
127
|
+
if @used.key?(gid)
|
|
128
|
+
return @used[gid] if loosely || !@loose&.delete(gid)
|
|
129
|
+
elsif loosely
|
|
130
|
+
(@loose ||= {})[gid] = true
|
|
131
|
+
end
|
|
132
|
+
@used[gid] = text
|
|
133
|
+
end
|
|
134
|
+
|
|
94
135
|
# Whether a character the font lacks is drawn as a blank (see WHITESPACE).
|
|
95
136
|
def blank?(char)
|
|
96
137
|
@blanks.fetch(char) { @blanks[char] = WHITESPACE.match?(char) && !glyph?(char) && glyph?(" ") }
|
|
@@ -107,9 +148,11 @@ module Stationery
|
|
|
107
148
|
end
|
|
108
149
|
|
|
109
150
|
# { code => text } for every glyph drawn so far; a ligature's text is
|
|
110
|
-
# every character it stands for.
|
|
151
|
+
# every character it stands for. Glyph 0, .notdef, stands for every
|
|
152
|
+
# character the font lacks, so it maps to the replacement character
|
|
153
|
+
# and the run's ActualText carries the real ones (see GlyphRun).
|
|
111
154
|
def used_codes
|
|
112
|
-
@used.to_h { |gid, char| [code(gid), char] }
|
|
155
|
+
@used.to_h { |gid, char| [code(gid), gid.zero? ? REPLACEMENT : char] }
|
|
113
156
|
end
|
|
114
157
|
|
|
115
158
|
def build(writer)
|
|
@@ -151,12 +194,19 @@ module Stationery
|
|
|
151
194
|
# each blank or nil], remembered per string and feature tags.
|
|
152
195
|
def shape(text, tags)
|
|
153
196
|
(@shapes[tags] ||= {})[text] ||= begin
|
|
197
|
+
forget if (@memoised += text.bytesize) > MEMO_BYTES
|
|
154
198
|
gids = text.each_char.map { |char| glyph_for(char) }
|
|
155
199
|
gids, chars = tags.empty? ? [gids, text.chars] : substitute(gids, text, tags)
|
|
156
200
|
[gids, chars, chars.map { |char| blank_units(char) }].each(&:freeze).freeze
|
|
157
201
|
end
|
|
158
202
|
end
|
|
159
203
|
|
|
204
|
+
def forget
|
|
205
|
+
@shapes.each_value(&:clear)
|
|
206
|
+
@metrics.each_value(&:clear)
|
|
207
|
+
@memoised = 0
|
|
208
|
+
end
|
|
209
|
+
|
|
160
210
|
def substitute(gids, text, tags)
|
|
161
211
|
start = 0
|
|
162
212
|
glyphs = @ttf.ligatures.substitute(gids, tags)
|
|
@@ -6,17 +6,20 @@ module Stationery
|
|
|
6
6
|
# document gets its own book so glyph usage (and so subsetting) is per
|
|
7
7
|
# document, while the parsed TrueType data is shared through the Registry.
|
|
8
8
|
class FontBook
|
|
9
|
-
attr_reader :families, :fallbacks, :warnings
|
|
9
|
+
attr_reader :families, :fallbacks, :warnings, :shaper
|
|
10
10
|
|
|
11
11
|
# `fallbacks:` names the families tried, in order, for a character the
|
|
12
|
-
# run's own family has no glyph for.
|
|
13
|
-
|
|
12
|
+
# run's own family has no glyph for. `shaper:` places the glyphs of
|
|
13
|
+
# every font of the book (see Shaper), told the text is in `language:`.
|
|
14
|
+
def initialize(families = {}, fallbacks: [], warnings: Warnings.new, shaper: nil, language: nil)
|
|
15
|
+
@shaper = Shaper.check(shaper)
|
|
16
|
+
@language = language
|
|
14
17
|
@families = families.dup
|
|
15
18
|
@fallbacks = fallbacks.map(&:to_s).freeze
|
|
16
19
|
@warnings = warnings
|
|
17
20
|
@fonts = {}
|
|
18
21
|
@resolved = {}
|
|
19
|
-
@split =
|
|
22
|
+
@split = ObjectSpace::WeakMap.new
|
|
20
23
|
end
|
|
21
24
|
|
|
22
25
|
def inspect = "#<#{self.class} families=#{@families.keys.inspect}>"
|
|
@@ -35,7 +38,7 @@ module Stationery
|
|
|
35
38
|
styles = (weights[style.weight] ||= {})
|
|
36
39
|
styles[style.style] ||= begin
|
|
37
40
|
face = family(style.family).face(weight: style.weight, style: style.style)
|
|
38
|
-
[@fonts[face.path] ||=
|
|
41
|
+
[@fonts[face.path] ||= font(face.path), face].freeze
|
|
39
42
|
end
|
|
40
43
|
end
|
|
41
44
|
|
|
@@ -52,6 +55,12 @@ module Stationery
|
|
|
52
55
|
|
|
53
56
|
private
|
|
54
57
|
|
|
58
|
+
def font(path)
|
|
59
|
+
return Font.new(Registry.load(path)) unless @shaper
|
|
60
|
+
|
|
61
|
+
Font.new(Registry.load(path), shaper: @shaper, path:, language: @language)
|
|
62
|
+
end
|
|
63
|
+
|
|
55
64
|
# Registered by name, bundled by name, an installed pack by name, then
|
|
56
65
|
# the first registered family, then the bundled default.
|
|
57
66
|
def family(name)
|
|
@@ -7,6 +7,11 @@ module Stationery
|
|
|
7
7
|
# source text of each glyph (several characters for a ligature). All-zero
|
|
8
8
|
# adjustments draw as a plain Tj string; otherwise as a TJ array. Glyphs
|
|
9
9
|
# are written as the font's character codes (see Font#code).
|
|
10
|
+
#
|
|
11
|
+
# A character no font has is glyph 0, .notdef, which stands for no
|
|
12
|
+
# character in particular: each stretch of them is shown inside a Span
|
|
13
|
+
# whose ActualText is the characters, so the text still extracts, copies
|
|
14
|
+
# and reads aloud as written.
|
|
10
15
|
GlyphRun = Data.define(:font, :gids, :adjust, :chars) do
|
|
11
16
|
def width(size, letter_spacing: 0)
|
|
12
17
|
units = gids.sum { |gid| font.ttf.advance(gid) }
|
|
@@ -18,20 +23,45 @@ module Stationery
|
|
|
18
23
|
with(adjust: adjust.each_with_index.map { |a, i| chars[i] == " " ? a + extra : a })
|
|
19
24
|
end
|
|
20
25
|
|
|
26
|
+
def missing? = gids.include?(0)
|
|
27
|
+
|
|
21
28
|
def to_operator
|
|
22
|
-
return
|
|
29
|
+
return show unless missing?
|
|
30
|
+
|
|
31
|
+
last = gids.size - 1
|
|
32
|
+
gids.each_index.chunk_while { |a, b| gids[a].zero? == gids[b].zero? }.map do |indices|
|
|
33
|
+
piece = with(gids: gids.values_at(*indices), adjust: adjust.values_at(*indices),
|
|
34
|
+
chars: chars.values_at(*indices))
|
|
35
|
+
piece.marked(trailing: indices.last < last)
|
|
36
|
+
end.join("\n")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
protected
|
|
40
|
+
|
|
41
|
+
# One stretch of a run: .notdef glyphs inside their Span. `trailing`
|
|
42
|
+
# keeps the adjustment after the last glyph, which a stretch followed
|
|
43
|
+
# by another needs and the end of a run does not.
|
|
44
|
+
def marked(trailing:)
|
|
45
|
+
return show(trailing:) unless missing?
|
|
46
|
+
|
|
47
|
+
"/Span <</ActualText <FEFF#{chars.join.encode(Encoding::UTF_16BE).unpack1("H*").upcase}>>> BDC\n" \
|
|
48
|
+
"#{show(trailing:)}\nEMC"
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def show(trailing: false)
|
|
54
|
+
return "<#{hex(gids)}> Tj" unless adjusted?(trailing)
|
|
23
55
|
|
|
24
56
|
parts = gids.each_index.slice_after { |i| !adjust[i].zero? }.flat_map do |indices|
|
|
25
57
|
last = indices.last
|
|
26
58
|
chunk = ["<#{hex(indices.map { |i| gids[i] })}>"]
|
|
27
|
-
last == gids.size - 1 || adjust[last].zero? ? chunk : chunk << PDF::Serializer.number(-adjust[last])
|
|
59
|
+
(last == gids.size - 1 && !trailing) || adjust[last].zero? ? chunk : chunk << PDF::Serializer.number(-adjust[last])
|
|
28
60
|
end
|
|
29
61
|
"[#{parts.join(" ")}] TJ"
|
|
30
62
|
end
|
|
31
63
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def adjusted? = adjust[0...-1].any? { |a| !a.zero? }
|
|
64
|
+
def adjusted?(trailing) = (trailing ? adjust : adjust[0...-1]).any? { |a| !a.zero? }
|
|
35
65
|
|
|
36
66
|
def hex(ids) = ids.map { |gid| font.code(gid) }.pack("n*").unpack1("H*").upcase
|
|
37
67
|
end
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
module Fonts
|
|
5
|
+
# One stretch of text as a shaper placed it (see Shaper): its glyphs in
|
|
6
|
+
# visual order at one size, the source text of each cluster, and the
|
|
7
|
+
# extra points after each glyph that letter and word spacing add. It
|
|
8
|
+
# answers what a GlyphRun does, so the canvas draws either.
|
|
9
|
+
#
|
|
10
|
+
# Drawn as Tj/TJ like any run. The PDF's widths stay the font's own, so
|
|
11
|
+
# an advance the shaper changed is a TJ adjustment; an x offset moves the
|
|
12
|
+
# pen before its glyph and back after it, a y offset is a text rise (Ts)
|
|
13
|
+
# around it. Both are text space, so they follow a synthetic oblique's
|
|
14
|
+
# shear and leave the text matrix the line was positioned with alone.
|
|
15
|
+
#
|
|
16
|
+
# A glyph can only map to one text in the font's ToUnicode, and a reader
|
|
17
|
+
# takes glyphs in the order drawn. Wherever that cannot give the source
|
|
18
|
+
# text (glyphs out of logical order, several glyphs for one cluster, a
|
|
19
|
+
# glyph already standing for another text, .notdef) the glyphs are shown
|
|
20
|
+
# inside a Span whose ActualText is the characters in logical order.
|
|
21
|
+
ShapedRun = Data.define(:font, :size, :glyphs, :texts, :extra, :rise) do
|
|
22
|
+
def self.build(font, size, text, glyphs)
|
|
23
|
+
starts = glyphs.map(&:cluster).uniq.sort
|
|
24
|
+
texts = starts.each_with_index.to_h do |start, index|
|
|
25
|
+
[start, text[(index.zero? ? 0 : start)...(starts[index + 1] || text.length)]]
|
|
26
|
+
end
|
|
27
|
+
new(font:, size:, glyphs: glyphs.freeze, texts: texts.freeze, extra: Array.new(glyphs.size, 0).freeze, rise: 0)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def gids = glyphs.map(&:gid)
|
|
31
|
+
|
|
32
|
+
# The spacing is part of the run, whatever is asked for here.
|
|
33
|
+
def width(*, **) = (glyphs.sum(&:advance) * size / units_per_em) + extra.sum
|
|
34
|
+
|
|
35
|
+
# Letter spacing follows each cluster, not each glyph: a mark stays on
|
|
36
|
+
# its base and a ligature counts once.
|
|
37
|
+
def with_letter_spacing(points)
|
|
38
|
+
points.zero? ? self : widened { points }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# Widens every U+0020 space, as justification asks.
|
|
42
|
+
def with_word_spacing(points, _size = size)
|
|
43
|
+
widened { |text| points * text.count(" ") }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# The characters the shaper found no glyph for.
|
|
47
|
+
def missing
|
|
48
|
+
glyphs.select { |glyph| glyph.gid.zero? }.map(&:cluster).uniq.flat_map { |cluster| texts[cluster].chars }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def to_operator
|
|
52
|
+
groups = stretches.chunk_while { |(left, _), (right, _)| left == right }
|
|
53
|
+
.map { |group| [group.first.first, group.flat_map(&:last)] }
|
|
54
|
+
groups.each_with_index.map do |(plain, indices), index|
|
|
55
|
+
trailing = index < groups.size - 1
|
|
56
|
+
plain ? show(indices, trailing) : marked(indices, trailing)
|
|
57
|
+
end.join("\n")
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
private
|
|
61
|
+
|
|
62
|
+
def units_per_em = font.ttf.units_per_em.to_f
|
|
63
|
+
|
|
64
|
+
def widened
|
|
65
|
+
last = glyphs.size - 1
|
|
66
|
+
with(extra: glyphs.each_with_index.map do |glyph, index|
|
|
67
|
+
closes = index == last || glyphs[index + 1].cluster != glyph.cluster
|
|
68
|
+
closes ? extra[index] + yield(texts[glyph.cluster]) : extra[index]
|
|
69
|
+
end)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
# [[plain, glyph indexes], …]: the run cut wherever everything drawn so
|
|
73
|
+
# far comes before everything still to draw in the text. A piece is
|
|
74
|
+
# plain when the font's ToUnicode gives its text.
|
|
75
|
+
def stretches
|
|
76
|
+
counts = glyphs.map(&:cluster).tally
|
|
77
|
+
own = glyphs.map { |glyph| own?(glyph, counts[glyph.cluster] == 1) }
|
|
78
|
+
lows = lowest_ahead
|
|
79
|
+
high = -1
|
|
80
|
+
glyphs.each_index.slice_when { |index, following| (high = [high, glyphs[index].cluster].max) < lows[following] }
|
|
81
|
+
.map { |indices| [indices.size == 1 && own[indices.first], indices] }
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
# The lowest cluster from each glyph to the end of the run.
|
|
85
|
+
def lowest_ahead
|
|
86
|
+
low = nil
|
|
87
|
+
glyphs.reverse_each.map { |glyph| low = [low || glyph.cluster, glyph.cluster].min }.reverse
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
# Marks the glyph as used, and answers whether the font's ToUnicode maps
|
|
91
|
+
# it to its cluster's text. A glyph sharing its cluster holds the text
|
|
92
|
+
# only until a glyph drawn on its own claims another.
|
|
93
|
+
def own?(glyph, alone)
|
|
94
|
+
text = texts[glyph.cluster]
|
|
95
|
+
return font.use(glyph.gid, text) == text && glyph.gid.positive? if alone
|
|
96
|
+
|
|
97
|
+
font.use(glyph.gid, text, loosely: true)
|
|
98
|
+
false
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def marked(indices, trailing)
|
|
102
|
+
text = indices.map { |index| glyphs[index].cluster }.uniq.sort.map { |cluster| texts[cluster] }.join
|
|
103
|
+
"/Span <</ActualText <FEFF#{text.encode(Encoding::UTF_16BE).unpack1("H*").upcase}>>> BDC\n" \
|
|
104
|
+
"#{show(indices, trailing)}\nEMC"
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
# The operators showing these glyphs. `trailing` keeps the movement
|
|
108
|
+
# after the last glyph, which glyphs that follow need.
|
|
109
|
+
def show(indices, trailing)
|
|
110
|
+
ops = []
|
|
111
|
+
parts = []
|
|
112
|
+
pending = 0.0
|
|
113
|
+
lift = 0
|
|
114
|
+
indices.each do |index|
|
|
115
|
+
glyph = glyphs[index]
|
|
116
|
+
pending += thousandths(glyph.x_offset)
|
|
117
|
+
lift = lifted(ops, parts, glyph) unless glyph.y_offset == lift
|
|
118
|
+
parts << pending unless pending.round(4).zero?
|
|
119
|
+
parts << glyph.gid
|
|
120
|
+
pending = after(index)
|
|
121
|
+
end
|
|
122
|
+
parts << pending if trailing && !pending.round(4).zero?
|
|
123
|
+
flush(ops, parts)
|
|
124
|
+
ops << "#{PDF::Serializer.number(rise)} Ts" unless lift.zero?
|
|
125
|
+
ops.join("\n")
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Ends the string being shown and sets the rise of the glyph; answers
|
|
129
|
+
# how far it is now lifted.
|
|
130
|
+
def lifted(ops, parts, glyph)
|
|
131
|
+
flush(ops, parts)
|
|
132
|
+
ops << "#{PDF::Serializer.number(rise + (glyph.y_offset * size / units_per_em))} Ts"
|
|
133
|
+
glyph.y_offset
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
# How far the pen moves after a glyph beyond the font's own advance, in
|
|
137
|
+
# thousandths of the size: the shaper's advance, the spacing, and back
|
|
138
|
+
# from the glyph's x offset.
|
|
139
|
+
def after(index)
|
|
140
|
+
glyph = glyphs[index]
|
|
141
|
+
thousandths(glyph.advance - font.ttf.advance(glyph.gid) - glyph.x_offset) + (extra[index] * 1000.0 / size)
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def thousandths(units) = units * 1000.0 / units_per_em
|
|
145
|
+
|
|
146
|
+
# Glyph ids (Integers) and movements (Floats) as one Tj or TJ.
|
|
147
|
+
def flush(ops, parts)
|
|
148
|
+
return if parts.empty?
|
|
149
|
+
|
|
150
|
+
strings = parts.chunk_while { |left, right| left.is_a?(Integer) && right.is_a?(Integer) }.map do |chunk|
|
|
151
|
+
chunk.first.is_a?(Integer) ? "<#{hex(chunk)}>" : PDF::Serializer.number(-chunk.first)
|
|
152
|
+
end
|
|
153
|
+
ops << (strings.size == 1 ? "#{strings.first} Tj" : "[#{strings.join(" ")}] TJ")
|
|
154
|
+
parts.clear
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def hex(ids) = ids.map { |gid| font.code(gid) }.pack("n*").unpack1("H*").upcase
|
|
158
|
+
end
|
|
159
|
+
end
|
|
160
|
+
end
|