stationery 0.10.1 → 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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +14 -0
  3. data/README.md +247 -32
  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 +49 -2
  10. data/lib/stationery/fonts/font_book.rb +14 -5
  11. data/lib/stationery/fonts/shaped_run.rb +160 -0
  12. data/lib/stationery/fonts/shaping.rb +63 -0
  13. data/lib/stationery/forms/typeface.rb +10 -3
  14. data/lib/stationery/html/css.rb +38 -3
  15. data/lib/stationery/html/tree_builder/blocks.rb +9 -3
  16. data/lib/stationery/images/images.rb +6 -6
  17. data/lib/stationery/images/resampled.rb +3 -2
  18. data/lib/stationery/images/webp/bit_reader.rb +78 -0
  19. data/lib/stationery/images/webp/container.rb +62 -0
  20. data/lib/stationery/images/webp/image_stream.rb +168 -0
  21. data/lib/stationery/images/webp/lossless.rb +101 -0
  22. data/lib/stationery/images/webp/predictor.rb +118 -0
  23. data/lib/stationery/images/webp/prefix_code.rb +168 -0
  24. data/lib/stationery/images/webp/transforms.rb +93 -0
  25. data/lib/stationery/images/webp.rb +71 -0
  26. data/lib/stationery/layout/columns/balancer.rb +66 -0
  27. data/lib/stationery/layout/columns/pour.rb +47 -0
  28. data/lib/stationery/layout/columns.rb +191 -0
  29. data/lib/stationery/layout/floated.rb +50 -0
  30. data/lib/stationery/layout/flow/floating.rb +51 -0
  31. data/lib/stationery/layout/flow/placement.rb +147 -0
  32. data/lib/stationery/layout/flow/splitter.rb +194 -0
  33. data/lib/stationery/layout/flow.rb +20 -117
  34. data/lib/stationery/layout/image.rb +1 -1
  35. data/lib/stationery/layout/mark.rb +3 -1
  36. data/lib/stationery/layout/node.rb +14 -0
  37. data/lib/stationery/layout/paginator.rb +7 -2
  38. data/lib/stationery/layout/table.rb +52 -6
  39. data/lib/stationery/layout/text.rb +31 -12
  40. data/lib/stationery/page.rb +26 -1
  41. data/lib/stationery/page_templates.rb +1 -1
  42. data/lib/stationery/pdf/assembler.rb +20 -4
  43. data/lib/stationery/pdf/conformance.rb +11 -1
  44. data/lib/stationery/pdf/page_sealer.rb +35 -0
  45. data/lib/stationery/rich/nodes.rb +3 -2
  46. data/lib/stationery/rich/renderer/boxes.rb +25 -3
  47. data/lib/stationery/rich/renderer.rb +5 -3
  48. data/lib/stationery/rich/styles.rb +1 -1
  49. data/lib/stationery/shaper.rb +109 -0
  50. data/lib/stationery/text/exclusions.rb +32 -0
  51. data/lib/stationery/text/line.rb +7 -2
  52. data/lib/stationery/text/paragraph.rb +69 -10
  53. data/lib/stationery/text/wrapper/remainder.rb +59 -0
  54. data/lib/stationery/text/wrapper.rb +54 -10
  55. data/lib/stationery/version.rb +1 -1
  56. data/lib/stationery.rb +10 -0
  57. 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,18 +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
27
31
  REPLACEMENT = "\uFFFD"
28
32
 
29
33
  attr_reader :ttf
30
34
 
31
- 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)
32
39
  @ttf = ttf
40
+ @shaping = shaper && Shaping.new(self, shaper, path:, language:)
41
+ @loose = nil
33
42
  @used = {}
34
43
  @pairs = {}
35
44
  @glyphs = {}
36
45
  @blanks = {}
37
46
  @shapes = {}.compare_by_identity
38
47
  @metrics = {}.compare_by_identity
48
+ @memoised = 0
39
49
  @keys = { true => {}, false => {} }
40
50
  @frozen_keys = { true => {}.compare_by_identity, false => {}.compare_by_identity }
41
51
  @cid_keyed = ttf.cff? && ttf.cff.cid_keyed?
@@ -48,7 +58,14 @@ module Stationery
48
58
  # Letter spacing is added per glyph, so a ligature counts once; any
49
59
  # letter spacing turns ligatures off, as it does when drawing.
50
60
  # `features:` are OpenType feature tags applied on top of `liga`.
51
- 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
+
52
69
  ligatures &&= letter_spacing.zero?
53
70
  key = shape_key(ligatures, features)
54
71
  advance, kern = metrics(text, key)
@@ -84,6 +101,7 @@ module Stationery
84
101
  # with pair kerning between the glyphs.
85
102
  def glyph_run(text, kerning: false, ligatures: true, features: NO_FEATURES)
86
103
  gids, chars, blanks = shape(text, shape_key(ligatures, features))
104
+ gids.each { |gid| @used.delete(gid) if @loose.delete(gid) } if @loose
87
105
  gids.each_with_index { |gid, i| @used[gid] ||= blanks[i] ? " " : chars[i] }
88
106
  adjust = gids.each_with_index.map do |gid, i|
89
107
  kern = kerning && i + 1 < gids.size ? pair(gid, gids[i + 1]) : 0
@@ -92,6 +110,28 @@ module Stationery
92
110
  GlyphRun.new(font: self, gids:, adjust:, chars:)
93
111
  end
94
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
+
95
135
  # Whether a character the font lacks is drawn as a blank (see WHITESPACE).
96
136
  def blank?(char)
97
137
  @blanks.fetch(char) { @blanks[char] = WHITESPACE.match?(char) && !glyph?(char) && glyph?(" ") }
@@ -154,12 +194,19 @@ module Stationery
154
194
  # each blank or nil], remembered per string and feature tags.
155
195
  def shape(text, tags)
156
196
  (@shapes[tags] ||= {})[text] ||= begin
197
+ forget if (@memoised += text.bytesize) > MEMO_BYTES
157
198
  gids = text.each_char.map { |char| glyph_for(char) }
158
199
  gids, chars = tags.empty? ? [gids, text.chars] : substitute(gids, text, tags)
159
200
  [gids, chars, chars.map { |char| blank_units(char) }].each(&:freeze).freeze
160
201
  end
161
202
  end
162
203
 
204
+ def forget
205
+ @shapes.each_value(&:clear)
206
+ @metrics.each_value(&:clear)
207
+ @memoised = 0
208
+ end
209
+
163
210
  def substitute(gids, text, tags)
164
211
  start = 0
165
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)
@@ -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
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ module Fonts
5
+ # One font's side of the shaper hook (see Shaper): asks the document's
6
+ # shaper to place a text, checks the answer and remembers it per size,
7
+ # features and text, so measuring a stretch and then drawing it asks once.
8
+ # Like the font's own memos, what is remembered starts over after
9
+ # Font::MEMO_BYTES of text, so a long document does not keep every line.
10
+ class Shaping
11
+ def initialize(font, shaper, path: nil, language: nil)
12
+ @font = font
13
+ @shaper = shaper
14
+ @language = language
15
+ @path = path
16
+ @features = {}
17
+ @runs = {}
18
+ @memoised = 0
19
+ end
20
+
21
+ # The ShapedRun of `text`, or nil when the shaper declines it.
22
+ def run(text, size, kerning:, ligatures:, features:)
23
+ features = switches(kerning ? true : false, ligatures ? true : false, features)
24
+ runs = ((@runs[size] ||= {}.compare_by_identity)[features] ||= {})
25
+ runs.fetch(text) do
26
+ forget if (@memoised += text.bytesize) > Font::MEMO_BYTES
27
+ runs[text] = shape(text, size, features)
28
+ end
29
+ end
30
+
31
+ # What the shaper is told about the font.
32
+ def face
33
+ @face ||= begin
34
+ ttf = @font.ttf
35
+ path, index = Registry::FACE.match(@path.to_s)&.captures || [@path, 0]
36
+ Shaper::Face.new(path: path && File.expand_path(path.to_s), index: index.to_i, data: ttf.data,
37
+ units_per_em: ttf.units_per_em, postscript_name: ttf.postscript_name,
38
+ glyph_count: ttf.num_glyphs)
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ def forget
45
+ @runs.each_value { |sizes| sizes.each_value(&:clear) }
46
+ @memoised = 0
47
+ end
48
+
49
+ # { tag => on }, one frozen Hash per set of switches so the runs are
50
+ # remembered by its identity.
51
+ def switches(kerning, ligatures, features)
52
+ tags = Text::Style.features(features)
53
+ @features[[kerning, ligatures, tags]] ||=
54
+ { "kern" => kerning, "liga" => ligatures }.merge(tags.to_h { |tag| [tag, true] }).freeze
55
+ end
56
+
57
+ def shape(text, size, features)
58
+ answer = @shaper.call(text, face, size:, features:, language: @language)
59
+ answer && ShapedRun.build(@font, size, text, Shaper.glyphs(answer, text, face))
60
+ end
61
+ end
62
+ end
63
+ end
@@ -30,7 +30,12 @@ module Stationery
30
30
  def embedded? = true
31
31
  def ascent(size) = primary.ascender(size)
32
32
  def descent(size) = primary.descender(size)
33
- def width(text, size) = runs(text, size).sum { |font, run| font.width_of(run.text, size, ligatures: false) }
33
+
34
+ # A field is not shaped (see Shaper): a viewer that redraws it after an
35
+ # edit would not shape it either.
36
+ def width(text, size)
37
+ runs(text, size).sum { |font, run| font.width_of(run.text, size, ligatures: false, shape: false) }
38
+ end
34
39
 
35
40
  # The resource names of the fonts drawn or kept so far.
36
41
  def names = @names.values
@@ -40,7 +45,8 @@ module Stationery
40
45
 
41
46
  # The operators showing `text` at the current text position: a font
42
47
  # selection and a string per run of one font. Characters no font has
43
- # draw as .notdef and are reported, as in any other text.
48
+ # draw as .notdef inside a Span with their ActualText (see
49
+ # Fonts::GlyphRun) and are reported, as in any other text.
44
50
  def show(text, size)
45
51
  runs(text, size).flat_map do |font, run|
46
52
  report(font, run)
@@ -86,7 +92,8 @@ module Stationery
86
92
  end
87
93
 
88
94
  # The standard Helvetica, not embedded: text is Windows-1252 bytes and
89
- # characters outside it become "?".
95
+ # characters outside it become "?". That is a glyph of its own, not
96
+ # .notdef, so it is shown without ActualText; the field's /V has the value.
90
97
  class Standard
91
98
  NAME = :Helv
92
99
  FONT = { Type: :Font, Subtype: :Type1, BaseFont: :Helvetica, Encoding: :WinAnsiEncoding }.freeze