stationery 0.5.0 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: dab8be06e23e27d74ddd2ec627bc34923a79a9c0cb5fe40da55f43bdcb81e337
4
- data.tar.gz: a7f90eadef2004318933e8a65cd1f86841c5b9bfc4cba8ff6191b2f96946b8e5
3
+ metadata.gz: bfd8cada05438bcc43cc85da2535da95b55c2dbf6020bf95607bd8ebb65c8346
4
+ data.tar.gz: 1115be789042eebfdd48a036fc433994694236c4c133ede893a554478b6174bf
5
5
  SHA512:
6
- metadata.gz: ce40fc76233a4c6ba0ca455c9c80ac3a651832ba86ab7003c8f907b2abd8291cbe0a7421302b4a6762e876df284ebe98ed862017eaed7cb4854d609e3fa557ec
7
- data.tar.gz: b1c1928f47210a8b29586f73881b8430978c644a2ea7489fab251796b7192213057a414c3145c47502fa267e3c857d5b581f91d8bf2bca394e61384844fc21d8
6
+ metadata.gz: 2e55c39993d7c6c7c66373f5097a8796ac655b46eabe34eb148808cca220823112a728d4c26b971c54f163dc2b6c45f50f3b6481c050089871daf5964a4011d6
7
+ data.tar.gz: 456ca535dc230cbc747dac22322d7a0886ea8da7f70b159bb59224a2afe211dd1cb1a6e79a2a1a7c1f7b0d2007701b465fb49434484953db010a869ae6e4ad97
data/CHANGELOG.md CHANGED
@@ -1,5 +1,12 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0 (2026-09-28)
4
+
5
+ Instrumentation, so a render shows up in AppSignal and friends, and a faster text pipeline.
6
+
7
+ - Instrumentation: `Stationery.instrument` emits `render.stationery` (the whole `to_pdf`: `document:`, `pages:`, `bytes:`, `warnings:`), `build.stationery`, `paginate.stationery`, `write.stationery` (serialise, subset, deflate), `image.stationery` (each decode on a cache miss: `format:`, `width:`, `height:`, `bytes:`), `font.stationery` (`action: :parse` on a cache miss, `action: :subset` per embedded font with `glyphs:`) and `parse.stationery` (`format: :html | :markdown`). `Stationery.instrumenter` is `ActiveSupport::Notifications` when it is loaded (the Railtie pins it), so AppSignal, Skylight and log subscribers pick the events up with no setup; otherwise a no-op. Assign any object responding to `instrument(name, payload, &block)` to plug something else in. Output is unchanged.
8
+ - `FontBook#resolve` memoises by family, weight and style instead of hashing the whole 13-field `Text::Style` on every measurement: the lookup is 4x faster and a photo-heavy 4-page document renders 22% faster.
9
+
3
10
  ## 0.5.0 (2026-09-27)
4
11
 
5
12
  Rotated, overlapping, cropped photos (the collage look), and fixes from the first weeks of use.
data/README.md CHANGED
@@ -606,6 +606,41 @@ between the children, as for a `P` holding a `Link`) or `[type]` when empty; a
606
606
  a browser per worker; stray processes and memory are the price.
607
607
  - **Typst** is excellent but a native extension and a second template language.
608
608
 
609
+ ## Instrumentation
610
+
611
+ Every render reports its phases as events named `<phase>.stationery`, so an
612
+ APM that subscribes to `ActiveSupport::Notifications` (AppSignal, Skylight,
613
+ Rails' own log subscribers) shows where a slow PDF spends its time with no
614
+ setup: AppSignal groups them under "stationery" in the event tree. Outside
615
+ Rails the events go to a null instrumenter that costs one method call.
616
+
617
+ | Event | Around | Payload |
618
+ |---|---|---|
619
+ | `render.stationery` | the whole `to_pdf` | `document:`, `pages:`, `bytes:`, `warnings:` (count) |
620
+ | `build.stationery` | building the component tree | `document:` |
621
+ | `paginate.stationery` | layout, page breaks and page templates | `document:`, `pages:` |
622
+ | `write.stationery` | serialising, subsetting and deflating | `document:`, `bytes:` |
623
+ | `image.stationery` | decoding one image (cache misses only) | `format:`, `width:`, `height:`, `bytes:` |
624
+ | `font.stationery` | parsing a font file (`action: :parse`, cache misses only) or subsetting one for a document (`action: :subset`, `glyphs:`) | `path:` or `font:`, `action:` |
625
+ | `parse.stationery` | parsing an `html` or `markdown` source | `format:`, `bytes:` |
626
+
627
+ Payload values known only afterwards (`pages:`, `bytes:`) are filled in
628
+ before the event finishes, so subscribers always see them. Log every phase
629
+ slower than 100 ms:
630
+
631
+ ```ruby
632
+ ActiveSupport::Notifications.subscribe(/\.stationery\z/) do |name, start, finish, _id, payload|
633
+ ms = (finish - start) * 1000
634
+ Rails.logger.info("#{name} #{ms.round}ms #{payload.inspect}") if ms > 100
635
+ end
636
+ ```
637
+
638
+ Any object answering `instrument(name, payload) { |payload| }` can take the
639
+ events instead (`Stationery.instrumenter = MyInstrumenter.new`), and
640
+ `Stationery.instrument("custom.stationery", key: value) { … }` adds your own
641
+ spans inside a document. When you hit a slow render, a trace with these
642
+ events attached is the most useful thing to put in an issue.
643
+
609
644
  ## Performance
610
645
 
611
646
  `bundle exec rake bench` renders two documents with Stationery and with Prawn
@@ -95,21 +95,9 @@ module Stationery
95
95
 
96
96
  def to_pdf(target = nil, strict: self.class.config[:strict], debug: false, encrypt: self.class.config[:encrypt],
97
97
  tagged: self.class.config[:tagged])
98
- encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
99
- tagging = Tagging::Tree.new if tagged
100
- warnings = Warnings.new
101
- book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
102
- call(builder = Builder.new(book:, text: self.class.config[:text]))
103
- resources = Resources.new
104
- pages = paginate(builder.root, book:, resources:, warnings:, debug:, tagging:)
105
- outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:, tagging:))
106
- tagging&.audit(pages, warnings, lang: metadata[:lang])
107
- @warnings = warnings
108
- @fields = Forms::AcroForm.values(pages)
109
- raise WarningsError, warnings if strict && warnings.any?
110
-
111
- assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:, lang: metadata[:lang])
112
- write(assembler.render, target)
98
+ Stationery.instrument("render.stationery", document: self.class.name) do |event|
99
+ write(render_pdf(event, strict:, debug:, encrypt:, tagged:), target)
100
+ end
113
101
  end
114
102
 
115
103
  # Used by page templates to build nodes into their own root.
@@ -130,11 +118,43 @@ module Stationery
130
118
 
131
119
  private
132
120
 
121
+ # The PDF bytes; `event` is the render.stationery payload it fills in.
122
+ def render_pdf(event, strict:, debug:, encrypt:, tagged:)
123
+ tagging = Tagging::Tree.new if tagged
124
+ warnings = Warnings.new
125
+ book = Fonts::FontBook.new(self.class.config[:families], fallbacks: self.class.config[:fallbacks], warnings:)
126
+ builder = Builder.new(book:, text: self.class.config[:text])
127
+ Stationery.instrument("build.stationery", document: self.class.name) { call(builder) }
128
+ resources = Resources.new
129
+ pages = paginate(builder.root, book:, resources:, warnings:, debug:, tagging:)
130
+ outline = builder.outline.resolve(Structure.resolve(pages, warnings:, resources:, book:, tagging:))
131
+ tagging&.audit(pages, warnings, lang: metadata[:lang])
132
+ @warnings = warnings
133
+ @fields = Forms::AcroForm.values(pages)
134
+ event[:pages] = pages.size
135
+ event[:warnings] = warnings.size
136
+ raise WarningsError, warnings if strict && warnings.any?
137
+
138
+ assemble(pages, resources, outline, encrypt:, tagging:).tap { |pdf| event[:bytes] = pdf.bytesize }
139
+ end
140
+
141
+ def assemble(pages, resources, outline, encrypt:, tagging:)
142
+ encryption = encrypt && PDF::Encryption::StandardSecurity.new(**encrypt)
143
+ assembler = PDF::Assembler.new(pages:, resources:, info:, outline:, encryption:, tagging:,
144
+ lang: metadata[:lang])
145
+ Stationery.instrument("write.stationery", document: self.class.name) do |event|
146
+ assembler.render.tap { |pdf| event[:bytes] = pdf.bytesize }
147
+ end
148
+ end
149
+
133
150
  def paginate(root, book:, resources:, warnings:, debug:, tagging:)
134
- regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
135
- paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:, tagging:)
136
- paginator.paginate(root).tap do |pages|
137
- PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:, tagging:).apply(pages)
151
+ Stationery.instrument("paginate.stationery", document: self.class.name) do |event|
152
+ regions = Regions.new(self.class.config[:regions], measure: region_measure(book))
153
+ paginator = Layout::Paginator.new(resources:, page: page_options, warnings:, debug:, regions:, tagging:)
154
+ paginator.paginate(root).tap do |pages|
155
+ PageTemplates.new(self, book:, resources:, debug:, regions:, warnings:, tagging:).apply(pages)
156
+ event[:pages] = pages.size
157
+ end
138
158
  end
139
159
  end
140
160
 
@@ -104,7 +104,11 @@ module Stationery
104
104
 
105
105
  def build(writer)
106
106
  embedding = (@ttf.cff? ? Embedding::CFF : Embedding::TrueType).new(self)
107
- name, cid_font = embedding.build(writer, @used.keys.sort)
107
+ gids = @used.keys.sort
108
+ name, cid_font = Stationery.instrument("font.stationery", font: @ttf.postscript_name, action: :subset,
109
+ glyphs: gids.size) do
110
+ embedding.build(writer, gids)
111
+ end
108
112
 
109
113
  writer.add(
110
114
  Type: :Font, Subtype: :Type0, BaseFont: name, Encoding: :"Identity-H",
@@ -26,9 +26,14 @@ module Stationery
26
26
  @families[name.to_s] = Family.build(name, **paths)
27
27
  end
28
28
 
29
- # Returns [Font, Family::Face] for a Text::Style, memoised per style.
29
+ # Returns [Font, Family::Face] for a Text::Style, memoised by the three
30
+ # fields that pick a face (family, weight, style) in nested hashes: a
31
+ # style's size, colour and the rest do not matter here, and hashing a
32
+ # 13-field Data on every text measurement did.
30
33
  def resolve(style)
31
- @resolved[style] ||= begin
34
+ weights = (@resolved[style.family] ||= {})
35
+ styles = (weights[style.weight] ||= {})
36
+ styles[style.style] ||= begin
32
37
  face = family(style.family).face(weight: style.weight, style: style.style)
33
38
  [@fonts[face.path] ||= Font.new(Registry.load(face.path)), face].freeze
34
39
  end
@@ -36,11 +36,17 @@ module Stationery
36
36
  def fetch(path, index, mtime)
37
37
  key = [path, index]
38
38
  cached_mtime, ttf = @cache.delete(key)
39
- ttf = TrueType.new(File.binread(path), index:) unless cached_mtime == mtime
39
+ ttf = parse(path, index) unless cached_mtime == mtime
40
40
  @cache[key] = [mtime, ttf]
41
41
  @cache.shift while @cache.size > SIZE
42
42
  ttf
43
43
  end
44
+
45
+ def parse(path, index)
46
+ Stationery.instrument("font.stationery", path: File.basename(path), action: :parse) do
47
+ TrueType.new(File.binread(path), index:)
48
+ end
49
+ end
44
50
  end
45
51
  end
46
52
  end
@@ -6,6 +6,10 @@ require_relative "tree_builder"
6
6
  module Stationery
7
7
  # Lenient HTML to rich-text blocks (see Stationery::Rich).
8
8
  module HTML
9
- def self.parse(source) = TreeBuilder.parse(Tokenizer.tokenize(source))
9
+ def self.parse(source)
10
+ Stationery.instrument("parse.stationery", format: :html, bytes: source.bytesize) do
11
+ TreeBuilder.parse(Tokenizer.tokenize(source))
12
+ end
13
+ end
10
14
  end
11
15
  end
@@ -14,7 +14,15 @@ module Stationery
14
14
  # Accepts a path, a Pathname or an IO-like object (read from its start).
15
15
  def load(source)
16
16
  data = read(source)
17
- Cache.fetch(data) { parse(data) }
17
+ Cache.fetch(data) do
18
+ Stationery.instrument("image.stationery", bytes: data.bytesize) do |event|
19
+ parse(data).tap do |image|
20
+ event[:format] = image.is_a?(JPEG) ? "JPEG" : "PNG"
21
+ event[:width] = image.width
22
+ event[:height] = image.height
23
+ end
24
+ end
25
+ end
18
26
  end
19
27
 
20
28
  def read(source)
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Events a render emits, named `<phase>.stationery` so tools that group by
5
+ # the last segment (AppSignal, Skylight, Rails log subscribers) file them
6
+ # together. With ActiveSupport::Notifications loaded they go there and
7
+ # subscribers see the same payload the block filled in; otherwise a Null
8
+ # instrumenter yields and costs one method call.
9
+ module Instrumentation
10
+ class Null
11
+ def instrument(_name, payload = {})
12
+ yield payload
13
+ end
14
+ end
15
+
16
+ class << self
17
+ # Any object answering `instrument(name, payload) { |payload| }`; set
18
+ # once at boot, so a plain module attribute is enough.
19
+ attr_writer :instrumenter # rubocop:disable ThreadSafety/ClassAndModuleAttributes
20
+
21
+ def instrumenter
22
+ @instrumenter ||= defined?(::ActiveSupport::Notifications) ? ::ActiveSupport::Notifications : Null.new # rubocop:disable ThreadSafety/ClassInstanceVariable
23
+ end
24
+
25
+ def instrument(name, payload = {}, &)
26
+ instrumenter.instrument(name, payload, &)
27
+ end
28
+ end
29
+ end
30
+
31
+ class << self
32
+ def instrumenter = Instrumentation.instrumenter
33
+
34
+ def instrumenter=(value)
35
+ Instrumentation.instrumenter = value
36
+ end
37
+
38
+ # Runs the block as one `name` event; the block may add to `payload`.
39
+ def instrument(name, payload = {}, &) = Instrumentation.instrument(name, payload, &)
40
+ end
41
+ end
@@ -7,8 +7,10 @@ module Stationery
7
7
  # A CommonMark subset (plus GFM tables and strikethrough) to rich-text blocks (see Stationery::Rich).
8
8
  module Markdown
9
9
  def self.parse(source)
10
- refs = {}
11
- Resolver.new(refs).blocks(BlockParser.parse(source, refs))
10
+ Stationery.instrument("parse.stationery", format: :markdown, bytes: source.bytesize) do
11
+ refs = {}
12
+ Resolver.new(refs).blocks(BlockParser.parse(source, refs))
13
+ end
12
14
  end
13
15
 
14
16
  # Runs the raw text the block parser left in paragraphs, headings and cells through the inline parser.
@@ -23,6 +23,11 @@ module Stationery
23
23
  Railtie.add_renderer if app.config.stationery.renderer
24
24
  end
25
25
 
26
+ # Whatever the require order, a Rails app reports through ActiveSupport::Notifications.
27
+ initializer "stationery.instrumentation" do
28
+ Stationery.instrumenter = ActiveSupport::Notifications
29
+ end
30
+
26
31
  initializer "stationery.previews" do |app|
27
32
  Railtie.add_previews(app.routes) if app.config.stationery.show_previews
28
33
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
data/lib/stationery.rb CHANGED
@@ -2,6 +2,7 @@
2
2
 
3
3
  require_relative "stationery/version"
4
4
  require_relative "stationery/errors"
5
+ require_relative "stationery/instrumentation"
5
6
  require_relative "stationery/pdf/types"
6
7
  require_relative "stationery/pdf/serializer"
7
8
  require_relative "stationery/pdf/stream"
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stationery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.0
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -99,6 +99,7 @@ files:
99
99
  - lib/stationery/images/jpeg.rb
100
100
  - lib/stationery/images/png.rb
101
101
  - lib/stationery/images/scanlines.rb
102
+ - lib/stationery/instrumentation.rb
102
103
  - lib/stationery/layout/box.rb
103
104
  - lib/stationery/layout/field.rb
104
105
  - lib/stationery/layout/flow.rb