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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +35 -0
- data/lib/stationery/document.rb +39 -19
- data/lib/stationery/fonts/font.rb +5 -1
- data/lib/stationery/fonts/font_book.rb +7 -2
- data/lib/stationery/fonts/registry.rb +7 -1
- data/lib/stationery/html/document.rb +5 -1
- data/lib/stationery/images/images.rb +9 -1
- data/lib/stationery/instrumentation.rb +41 -0
- data/lib/stationery/markdown/document.rb +4 -2
- data/lib/stationery/railtie.rb +5 -0
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +1 -0
- metadata +2 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: bfd8cada05438bcc43cc85da2535da95b55c2dbf6020bf95607bd8ebb65c8346
|
|
4
|
+
data.tar.gz: 1115be789042eebfdd48a036fc433994694236c4c133ede893a554478b6174bf
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/stationery/document.rb
CHANGED
|
@@ -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
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
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
|
|
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] ||=
|
|
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 =
|
|
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)
|
|
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)
|
|
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
|
-
|
|
11
|
-
|
|
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.
|
data/lib/stationery/railtie.rb
CHANGED
|
@@ -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
|
data/lib/stationery/version.rb
CHANGED
data/lib/stationery.rb
CHANGED
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.
|
|
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
|