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.
Files changed (59) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +18 -0
  3. data/README.md +247 -30
  4. data/lib/stationery/builder.rb +9 -0
  5. data/lib/stationery/canvas/text.rb +8 -2
  6. data/lib/stationery/document.rb +56 -14
  7. data/lib/stationery/elements.rb +31 -3
  8. data/lib/stationery/errors.rb +5 -1
  9. data/lib/stationery/fonts/font.rb +54 -4
  10. data/lib/stationery/fonts/font_book.rb +14 -5
  11. data/lib/stationery/fonts/glyph_run.rb +35 -5
  12. data/lib/stationery/fonts/shaped_run.rb +160 -0
  13. data/lib/stationery/fonts/shaping.rb +63 -0
  14. data/lib/stationery/forms/typeface.rb +10 -3
  15. data/lib/stationery/html/css.rb +38 -3
  16. data/lib/stationery/html/tree_builder/blocks.rb +9 -3
  17. data/lib/stationery/images/images.rb +6 -6
  18. data/lib/stationery/images/resampled.rb +3 -2
  19. data/lib/stationery/images/webp/bit_reader.rb +78 -0
  20. data/lib/stationery/images/webp/container.rb +62 -0
  21. data/lib/stationery/images/webp/image_stream.rb +168 -0
  22. data/lib/stationery/images/webp/lossless.rb +101 -0
  23. data/lib/stationery/images/webp/predictor.rb +118 -0
  24. data/lib/stationery/images/webp/prefix_code.rb +168 -0
  25. data/lib/stationery/images/webp/transforms.rb +93 -0
  26. data/lib/stationery/images/webp.rb +71 -0
  27. data/lib/stationery/layout/columns/balancer.rb +66 -0
  28. data/lib/stationery/layout/columns/pour.rb +47 -0
  29. data/lib/stationery/layout/columns.rb +191 -0
  30. data/lib/stationery/layout/floated.rb +50 -0
  31. data/lib/stationery/layout/flow/floating.rb +51 -0
  32. data/lib/stationery/layout/flow/placement.rb +147 -0
  33. data/lib/stationery/layout/flow/splitter.rb +194 -0
  34. data/lib/stationery/layout/flow.rb +20 -117
  35. data/lib/stationery/layout/image.rb +1 -1
  36. data/lib/stationery/layout/mark.rb +3 -1
  37. data/lib/stationery/layout/node.rb +14 -0
  38. data/lib/stationery/layout/paginator.rb +7 -2
  39. data/lib/stationery/layout/table.rb +52 -6
  40. data/lib/stationery/layout/text.rb +31 -12
  41. data/lib/stationery/page.rb +26 -1
  42. data/lib/stationery/page_templates.rb +1 -1
  43. data/lib/stationery/pdf/assembler.rb +20 -4
  44. data/lib/stationery/pdf/conformance.rb +11 -1
  45. data/lib/stationery/pdf/page_sealer.rb +35 -0
  46. data/lib/stationery/rich/nodes.rb +3 -2
  47. data/lib/stationery/rich/renderer/boxes.rb +25 -3
  48. data/lib/stationery/rich/renderer.rb +5 -3
  49. data/lib/stationery/rich/styles.rb +1 -1
  50. data/lib/stationery/shaper.rb +109 -0
  51. data/lib/stationery/testing/marked_text.rb +34 -4
  52. data/lib/stationery/text/exclusions.rb +32 -0
  53. data/lib/stationery/text/line.rb +7 -2
  54. data/lib/stationery/text/paragraph.rb +69 -10
  55. data/lib/stationery/text/wrapper/remainder.rb +59 -0
  56. data/lib/stationery/text/wrapper.rb +54 -10
  57. data/lib/stationery/version.rb +1 -1
  58. data/lib/stationery.rb +10 -0
  59. metadata +22 -1
@@ -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, images: {}, attachments: [] }
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
- # Layout runs in full first either way, so peak memory does not drop.
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], &block)
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
- pages = paginate(builder.root, book:, resources:, warnings:, debug:, tagging:)
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
- encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
228
- assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, xmp:,
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
- def paginate(root, book:, resources:, warnings:, debug:, tagging:)
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(root).tap do |pages|
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
@@ -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
- def image(source, align: nil, **)
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?
@@ -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 (WebP, GIF, interlaced PNG, …).
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
- def initialize(ttf)
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
- def width_of(text, size, letter_spacing: 0, kerning: false, ligatures: true, features: NO_FEATURES)
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
- def initialize(families = {}, fallbacks: [], warnings: Warnings.new)
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 = {}.compare_by_identity
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] ||= Font.new(Registry.load(face.path)), face].freeze
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 "<#{hex(gids)}> Tj" unless adjusted?
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
- private
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