stationery 0.12.2 → 0.13.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 +4 -2
- data/README.md +74 -6
- data/examples/price_card.rb +10 -8
- data/lib/stationery/barcode/data_matrix.rb +2 -2
- data/lib/stationery/barcode/data_matrix_placement.rb +1 -1
- data/lib/stationery/barcode/ean13.rb +2 -2
- data/lib/stationery/barcode/qr.rb +4 -4
- data/lib/stationery/barcode/qr_matrix.rb +1 -1
- data/lib/stationery/barcode/reed_solomon.rb +7 -6
- data/lib/stationery/canvas/debug.rb +1 -1
- data/lib/stationery/canvas/interface.rb +1 -1
- data/lib/stationery/canvas/marking.rb +1 -1
- data/lib/stationery/cli.rb +7 -2
- data/lib/stationery/color.rb +5 -15
- data/lib/stationery/css/stylesheet.rb +1 -1
- data/lib/stationery/document.rb +32 -8
- data/lib/stationery/elements/forms.rb +2 -2
- data/lib/stationery/examples.rb +1 -1
- data/lib/stationery/fonts/bundled.rb +3 -3
- data/lib/stationery/fonts/catalog.rb +4 -3
- data/lib/stationery/fonts/cff.rb +6 -7
- data/lib/stationery/fonts/cff_subset.rb +1 -1
- data/lib/stationery/fonts/family.rb +5 -2
- data/lib/stationery/fonts/font.rb +4 -1
- data/lib/stationery/fonts/gsub.rb +12 -8
- data/lib/stationery/fonts/installer.rb +2 -2
- data/lib/stationery/fonts/ligatures.rb +5 -3
- data/lib/stationery/fonts/registry.rb +9 -19
- data/lib/stationery/fonts/true_type.rb +14 -17
- data/lib/stationery/forms/appearance.rb +6 -5
- data/lib/stationery/forms/field.rb +9 -5
- data/lib/stationery/html/tree_builder/blocks.rb +2 -2
- data/lib/stationery/html/tree_builder.rb +2 -2
- data/lib/stationery/hyphenation.rb +4 -6
- data/lib/stationery/images/cache.rb +10 -15
- data/lib/stationery/images/images.rb +2 -2
- data/lib/stationery/images/jpeg/bit_reader.rb +1 -1
- data/lib/stationery/images/jpeg/scan.rb +1 -1
- data/lib/stationery/images/jpeg.rb +7 -4
- data/lib/stationery/images/png.rb +4 -3
- data/lib/stationery/images/webp/prefix_code.rb +1 -1
- data/lib/stationery/images/webp.rb +4 -3
- data/lib/stationery/instrumentation.rb +24 -4
- data/lib/stationery/layout/box.rb +2 -2
- data/lib/stationery/layout/table/cell.rb +4 -5
- data/lib/stationery/layout/table.rb +2 -2
- data/lib/stationery/layout/table_of_contents/entry.rb +1 -1
- data/lib/stationery/lru.rb +31 -0
- data/lib/stationery/memo.rb +33 -0
- data/lib/stationery/monochrome.rb +2 -2
- data/lib/stationery/page.rb +2 -2
- data/lib/stationery/pdf/conformance.rb +10 -6
- data/lib/stationery/pdf/encryption/aes.rb +1 -1
- data/lib/stationery/pdf/encryption/standard_security.rb +2 -2
- data/lib/stationery/pdf/factur_x.rb +2 -2
- data/lib/stationery/pdf/serializer.rb +1 -1
- data/lib/stationery/pdf/writer.rb +1 -1
- data/lib/stationery/pdf/xmp.rb +1 -1
- data/lib/stationery/preview.rb +15 -5
- data/lib/stationery/railtie.rb +3 -0
- data/lib/stationery/raster/png.rb +2 -2
- data/lib/stationery/raster/render.rb +3 -3
- data/lib/stationery/rich/renderer/inlines.rb +4 -4
- data/lib/stationery/rich/styles.rb +2 -2
- data/lib/stationery/shareable.rb +41 -0
- data/lib/stationery/skill.rb +2 -2
- data/lib/stationery/store.rb +42 -0
- data/lib/stationery/svg/region.rb +1 -1
- data/lib/stationery/testing/inspector.rb +1 -1
- data/lib/stationery/testing/marked_text.rb +1 -1
- data/lib/stationery/testing/page_reader.rb +1 -1
- data/lib/stationery/text/markup.rb +2 -2
- data/lib/stationery/verify/allowlist.rb +1 -1
- data/lib/stationery/verify/engines/pdfium.rb +1 -1
- data/lib/stationery/verify/engines/pdfjs.rb +1 -1
- data/lib/stationery/verify/engines/pdfkit.rb +1 -1
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery.rb +35 -1
- metadata +5 -1
|
@@ -56,6 +56,7 @@ module Stationery
|
|
|
56
56
|
@blanks = {}
|
|
57
57
|
@shapes = {}.compare_by_identity
|
|
58
58
|
@metrics = {}.compare_by_identity
|
|
59
|
+
@plans = {}.compare_by_identity
|
|
59
60
|
@memoised = 0
|
|
60
61
|
@keys = { true => {}, false => {} }
|
|
61
62
|
@frozen_keys = { true => {}.compare_by_identity, false => {}.compare_by_identity }
|
|
@@ -256,7 +257,9 @@ module Stationery
|
|
|
256
257
|
|
|
257
258
|
def substitute(gids, text, tags)
|
|
258
259
|
start = 0
|
|
259
|
-
|
|
260
|
+
ligatures = @ttf.ligatures
|
|
261
|
+
plan = @plans.fetch(tags) { @plans[tags] = ligatures.plan(tags) }
|
|
262
|
+
glyphs = ligatures.substitute(gids, tags, plan:)
|
|
260
263
|
chars = glyphs.map { |_gid, count| text[start, count].tap { start += count } }
|
|
261
264
|
[glyphs.map(&:first), chars]
|
|
262
265
|
end
|
|
@@ -69,7 +69,7 @@ module Stationery
|
|
|
69
69
|
|
|
70
70
|
def initialize(features)
|
|
71
71
|
@features = features.freeze
|
|
72
|
-
@
|
|
72
|
+
@liga = plan_for(LIGA)
|
|
73
73
|
freeze
|
|
74
74
|
end
|
|
75
75
|
|
|
@@ -79,17 +79,21 @@ module Stationery
|
|
|
79
79
|
# [[gid, number of source glyphs it stands for], ...] after applying the
|
|
80
80
|
# lookups of `tags` (feature tag Strings) in LookupList order, each once.
|
|
81
81
|
# Within a lookup the first subtable matching at a position wins and its
|
|
82
|
-
# glyph is not matched again by that lookup.
|
|
83
|
-
|
|
84
|
-
|
|
82
|
+
# glyph is not matched again by that lookup. `plan:` is #plan of the
|
|
83
|
+
# tags, for a caller that keeps it.
|
|
84
|
+
def substitute(gids, tags = LIGA, plan: plan(tags))
|
|
85
|
+
plan.reduce(gids.map { |gid| [gid, 1] }) { |glyphs, subtables| apply(subtables, glyphs) }
|
|
85
86
|
end
|
|
86
87
|
|
|
88
|
+
# The lookups the tags name, merged and ordered: those of the default
|
|
89
|
+
# tags made with the parse, so it never changes once built, any other
|
|
90
|
+
# list's when asked (each Font keeps the plans it uses).
|
|
91
|
+
def plan(tags) = tags == LIGA ? @liga : plan_for(tags)
|
|
92
|
+
|
|
87
93
|
private
|
|
88
94
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
@plans[tags] ||= tags.flat_map { |tag| @features[tag] || [] }.uniq(&:first).sort_by(&:first)
|
|
92
|
-
.map(&:last).freeze
|
|
95
|
+
def plan_for(tags)
|
|
96
|
+
tags.flat_map { |tag| @features[tag] || [] }.uniq(&:first).sort_by(&:first).map(&:last).freeze
|
|
93
97
|
end
|
|
94
98
|
|
|
95
99
|
def apply(subtables, glyphs)
|
|
@@ -9,7 +9,7 @@ module Stationery
|
|
|
9
9
|
# `force:`. Development-time only; documents never download fonts.
|
|
10
10
|
class Installer
|
|
11
11
|
# Fetches an https URL, following up to five redirects.
|
|
12
|
-
HTTP = lambda do |url, redirects = 5|
|
|
12
|
+
HTTP = Stationery.shareable(lambda do |url, redirects = 5|
|
|
13
13
|
require "net/http"
|
|
14
14
|
uri = URI(url)
|
|
15
15
|
raise Error, "refusing to download over #{uri.scheme}: #{url} (https only)" unless uri.scheme == "https"
|
|
@@ -23,7 +23,7 @@ module Stationery
|
|
|
23
23
|
HTTP.call(uri.merge(response["location"]).to_s, redirects - 1)
|
|
24
24
|
else raise Error, "HTTP #{response.code} fetching #{url}"
|
|
25
25
|
end
|
|
26
|
-
end
|
|
26
|
+
end)
|
|
27
27
|
|
|
28
28
|
attr_reader :into
|
|
29
29
|
|
|
@@ -3,12 +3,14 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Fonts
|
|
5
5
|
# Chooses where a font's glyph substitutions come from. Every source
|
|
6
|
-
# answers `substitute(gids, feature_tags)` with
|
|
7
|
-
# count], ...] and `features` with the tags it can
|
|
6
|
+
# answers `substitute(gids, feature_tags, plan: plan(feature_tags))` with
|
|
7
|
+
# [[gid, source glyph count], ...] and `features` with the tags it can
|
|
8
|
+
# apply.
|
|
8
9
|
module Ligatures
|
|
9
10
|
# For fonts without a usable GSUB table: every glyph stands for itself.
|
|
10
11
|
module NONE
|
|
11
|
-
def self.substitute(gids, _tags = Gsub::LIGA) = gids.map { |gid| [gid, 1] }
|
|
12
|
+
def self.substitute(gids, _tags = Gsub::LIGA, **) = gids.map { |gid| [gid, 1] }
|
|
13
|
+
def self.plan(_tags) = nil
|
|
12
14
|
def self.features = []
|
|
13
15
|
end
|
|
14
16
|
|
|
@@ -2,15 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
module Stationery
|
|
4
4
|
module Fonts
|
|
5
|
-
#
|
|
6
|
-
#
|
|
7
|
-
#
|
|
8
|
-
#
|
|
5
|
+
# The parsed TrueType files of one Ractor (see Store), the SIZE most
|
|
6
|
+
# recently used, keyed by path and face and read again when the file's
|
|
7
|
+
# mtime changes. Parsing is the expensive part; the per-document Font
|
|
8
|
+
# objects that track used glyphs are cheap wrappers around a parse. A
|
|
9
|
+
# `#N` suffix on the path picks face N of a collection.
|
|
9
10
|
module Registry
|
|
10
11
|
SIZE = 16
|
|
11
12
|
FACE = /\A(.+)#(\d+)\z/
|
|
12
|
-
@cache = {}
|
|
13
|
-
@mutex = Mutex.new
|
|
14
13
|
|
|
15
14
|
class << self
|
|
16
15
|
def load(path)
|
|
@@ -19,29 +18,20 @@ module Stationery
|
|
|
19
18
|
raise UnsupportedFont, "font file not found: #{path}" unless File.file?(path)
|
|
20
19
|
|
|
21
20
|
mtime = File.mtime(path)
|
|
22
|
-
|
|
21
|
+
lru.fetch([path, index]) { |held| held&.first == mtime ? held : [mtime, parse(path, index)] }.last
|
|
23
22
|
end
|
|
24
23
|
|
|
25
|
-
def clear
|
|
26
|
-
@mutex.synchronize { @cache.clear }
|
|
27
|
-
end
|
|
24
|
+
def clear = lru.clear
|
|
28
25
|
|
|
29
26
|
private
|
|
30
27
|
|
|
28
|
+
def lru = Store.fetch(:stationery_fonts) { LRU.new(SIZE) }
|
|
29
|
+
|
|
31
30
|
def split(path)
|
|
32
31
|
match = FACE.match(path)
|
|
33
32
|
match ? [match[1], match[2].to_i] : [path, 0]
|
|
34
33
|
end
|
|
35
34
|
|
|
36
|
-
def fetch(path, index, mtime)
|
|
37
|
-
key = [path, index]
|
|
38
|
-
cached_mtime, ttf = @cache.delete(key)
|
|
39
|
-
ttf = parse(path, index) unless cached_mtime == mtime
|
|
40
|
-
@cache[key] = [mtime, ttf]
|
|
41
|
-
@cache.shift while @cache.size > SIZE
|
|
42
|
-
ttf
|
|
43
|
-
end
|
|
44
|
-
|
|
45
35
|
def parse(path, index)
|
|
46
36
|
Stationery.instrument("font.stationery", path: File.basename(path), action: :parse) do
|
|
47
37
|
TrueType.new(File.binread(path), index:)
|
|
@@ -10,9 +10,9 @@ module Stationery
|
|
|
10
10
|
include SfntMetrics
|
|
11
11
|
|
|
12
12
|
REQUIRED_TABLES = %w[head hhea maxp hmtx].freeze
|
|
13
|
-
OUTLINE_TABLES = { "OTTO" => ["CFF "] }
|
|
13
|
+
OUTLINE_TABLES = Stationery.shareable({ "OTTO" => ["CFF "] })
|
|
14
14
|
GLYF_TABLES = %w[loca glyf].freeze
|
|
15
|
-
SFNT_VERSIONS = ["\x00\x01\x00\x00".b, "true", "OTTO"]
|
|
15
|
+
SFNT_VERSIONS = Stationery.shareable(["\x00\x01\x00\x00".b, "true", "OTTO"])
|
|
16
16
|
COLLECTION = "ttcf"
|
|
17
17
|
SIGNATURES = {
|
|
18
18
|
"wOF2" => "WOFF2 needs Brotli; convert to .ttf or .woff"
|
|
@@ -40,6 +40,7 @@ module Stationery
|
|
|
40
40
|
parse_post
|
|
41
41
|
parse_os2
|
|
42
42
|
@postscript_name = NameTable.postscript_name(self)
|
|
43
|
+
read_layout
|
|
43
44
|
end
|
|
44
45
|
|
|
45
46
|
def self.collection?(data) = data.byteslice(0, 4) == COLLECTION
|
|
@@ -70,26 +71,16 @@ module Stationery
|
|
|
70
71
|
end
|
|
71
72
|
end
|
|
72
73
|
|
|
73
|
-
# Pair kerning
|
|
74
|
-
#
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
# Standard ligatures, read on first use and shared like #kerning.
|
|
80
|
-
def ligatures
|
|
81
|
-
@ligatures ||= Ligatures.for(self)
|
|
82
|
-
end
|
|
74
|
+
# Pair kerning (see Kerning), standard ligatures (see Ligatures) and
|
|
75
|
+
# the CFF table, nil for TrueType outlines: read with the rest of the
|
|
76
|
+
# parse, so it never changes once built and is shared as it is by every
|
|
77
|
+
# document and every thread using it, and can be made Ractor-shareable.
|
|
78
|
+
attr_reader :kerning, :ligatures, :cff
|
|
83
79
|
|
|
84
80
|
def cff?
|
|
85
81
|
@tables.key?("CFF ")
|
|
86
82
|
end
|
|
87
83
|
|
|
88
|
-
# The CFF table, read on first use; nil for TrueType outlines.
|
|
89
|
-
def cff
|
|
90
|
-
@cff ||= CFF.new(table_data("CFF ")) if cff?
|
|
91
|
-
end
|
|
92
|
-
|
|
93
84
|
# The contours of a glyph in font units, y up (an Outline), from the
|
|
94
85
|
# `glyf` table or the CFF charstrings. Read afresh on every call;
|
|
95
86
|
# Font#outline remembers them for a document.
|
|
@@ -126,6 +117,12 @@ module Stationery
|
|
|
126
117
|
|
|
127
118
|
private
|
|
128
119
|
|
|
120
|
+
def read_layout
|
|
121
|
+
@cff = CFF.new(table_data("CFF ")) if cff?
|
|
122
|
+
@kerning = Kerning.for(self)
|
|
123
|
+
@ligatures = Ligatures.for(self)
|
|
124
|
+
end
|
|
125
|
+
|
|
129
126
|
# Table offsets in a collection are already absolute, so a face is read
|
|
130
127
|
# from the shared data at its own table directory.
|
|
131
128
|
def face_offset(index)
|
|
@@ -14,14 +14,14 @@ module Stationery
|
|
|
14
14
|
PADDING = 2
|
|
15
15
|
LEADING = 1.15
|
|
16
16
|
# The check mark's corners in the unit square (y down) and its stroke.
|
|
17
|
-
CHECK = { points: [[0.22, 0.52], [0.42, 0.72], [0.78, 0.28]], stroke: 0.12 }
|
|
17
|
+
CHECK = Stationery.shareable({ points: [[0.22, 0.52], [0.42, 0.72], [0.78, 0.28]], stroke: 0.12 })
|
|
18
18
|
DOT = 0.45
|
|
19
19
|
# What /DA and the appearance say when the value is black.
|
|
20
20
|
BLACK = "0 g"
|
|
21
21
|
SIGNATURE = { rule: 14, label_size: 7, label_baseline: 4, label_gray: 0.42 }.freeze
|
|
22
22
|
|
|
23
23
|
# The size the value is drawn at: the field's, or what `font_size:
|
|
24
|
-
# :auto` fits (see AutoSize).
|
|
24
|
+
# :auto` or `:fit` fits (see AutoSize).
|
|
25
25
|
attr_reader :size
|
|
26
26
|
|
|
27
27
|
# `resources` are the render's, where the field's fonts get their names.
|
|
@@ -30,7 +30,7 @@ module Stationery
|
|
|
30
30
|
@width = width
|
|
31
31
|
@height = height
|
|
32
32
|
@type = field.typeface.with(resources)
|
|
33
|
-
@size = field.
|
|
33
|
+
@size = field.sized? ? AutoSize.new(field, @type, width, height).size : field.font_size
|
|
34
34
|
@contents = contents
|
|
35
35
|
keep
|
|
36
36
|
end
|
|
@@ -41,8 +41,9 @@ module Stationery
|
|
|
41
41
|
# The resource names of the fonts it draws with or keeps for editing.
|
|
42
42
|
def font_names = @type.names
|
|
43
43
|
|
|
44
|
-
# What the field's /DA says: the font, size (0 for auto
|
|
45
|
-
# viewer redraws the value with. nil for
|
|
44
|
+
# What the field's /DA says: the font, size (0 for `:auto`, the fitted
|
|
45
|
+
# number for `:fit`) and colour a viewer redraws the value with. nil for
|
|
46
|
+
# a field without variable text.
|
|
46
47
|
def default_appearance
|
|
47
48
|
return unless @field.variable_text?
|
|
48
49
|
|
|
@@ -17,13 +17,13 @@ module Stationery
|
|
|
17
17
|
DEFAULTS = { font_size: 10, read_only: false, required: false, border: "#9CA3AF", background: "#FFFFFF",
|
|
18
18
|
radius: 2, tooltip: nil }.freeze
|
|
19
19
|
# What a text field and a select take beyond the defaults: where the
|
|
20
|
-
# value sits, its colour, and the bounds of `font_size: :auto`.
|
|
20
|
+
# value sits, its colour, and the bounds of `font_size: :auto` or `:fit`.
|
|
21
21
|
BOUNDS = %i[min_font_size max_font_size].freeze
|
|
22
22
|
STYLE = [:align, :color, *BOUNDS].freeze
|
|
23
|
-
OPTIONS =
|
|
23
|
+
OPTIONS = Stationery.shareable(
|
|
24
24
|
text: [:multiline, :max_length, :comb, *STYLE], checkbox: [], radio: %i[checked],
|
|
25
25
|
select: [:options, :editable, *STYLE], signature: %i[label]
|
|
26
|
-
|
|
26
|
+
)
|
|
27
27
|
VARIABLE_TEXT = %i[text select].freeze
|
|
28
28
|
# The /Q (quadding) of each `align:` (PDF 32000-1, 12.7.3.3).
|
|
29
29
|
ALIGNMENTS = { left: 0, center: 1, right: 2 }.freeze
|
|
@@ -55,6 +55,10 @@ module Stationery
|
|
|
55
55
|
def font_size = @options[:font_size]
|
|
56
56
|
# Whether the value is drawn at the largest size that fits (`font_size: :auto`).
|
|
57
57
|
def auto_size? = font_size == :auto
|
|
58
|
+
# Whether it is the size `:auto` would draw, written as a number (`font_size: :fit`).
|
|
59
|
+
def fit? = font_size == :fit
|
|
60
|
+
# Whether the gem chooses the size the value is drawn at (`:auto` or `:fit`).
|
|
61
|
+
def sized? = auto_size? || fit?
|
|
58
62
|
def min_font_size = @options[:min_font_size]
|
|
59
63
|
def max_font_size = @options[:max_font_size]
|
|
60
64
|
def align = @options.fetch(:align, :left)
|
|
@@ -150,10 +154,10 @@ module Stationery
|
|
|
150
154
|
def validate_style
|
|
151
155
|
raise ArgumentError, "align: is :left, :center or :right, not #{align.inspect}" unless ALIGNMENTS.key?(align)
|
|
152
156
|
|
|
153
|
-
points(:font_size, " or :
|
|
157
|
+
points(:font_size, ", :auto or :fit") unless sized?
|
|
154
158
|
BOUNDS.each do |key|
|
|
155
159
|
next unless @options.key?(key)
|
|
156
|
-
raise ArgumentError, "#{key}: needs font_size: :auto" unless
|
|
160
|
+
raise ArgumentError, "#{key}: needs font_size: :auto or :fit" unless sized?
|
|
157
161
|
|
|
158
162
|
points(key)
|
|
159
163
|
end
|
|
@@ -16,14 +16,14 @@ module Stationery
|
|
|
16
16
|
dl dt dd center details summary form fieldset
|
|
17
17
|
].freeze
|
|
18
18
|
HEADINGS = %w[h1 h2 h3 h4 h5 h6].freeze
|
|
19
|
-
MARKS =
|
|
19
|
+
MARKS = Stationery.shareable(
|
|
20
20
|
"strong" => { bold: true }, "b" => { bold: true },
|
|
21
21
|
"em" => { italic: true }, "i" => { italic: true },
|
|
22
22
|
"u" => { underline: true }, "ins" => { underline: true },
|
|
23
23
|
"s" => { strike: true }, "del" => { strike: true }, "strike" => { strike: true },
|
|
24
24
|
"code" => { code: true }, "kbd" => { code: true }, "samp" => { code: true }, "tt" => { code: true },
|
|
25
25
|
"sub" => { script: :sub }, "sup" => { script: :sup }
|
|
26
|
-
|
|
26
|
+
)
|
|
27
27
|
ALIGN = /\A\s*(left|center|right|justify)\s*\z/i
|
|
28
28
|
# What makes a container a block of its own instead of flattening into its parent.
|
|
29
29
|
BOXED = %i[background padding padding_top padding_right padding_bottom padding_left margin margin_top
|
|
@@ -20,13 +20,13 @@ module Stationery
|
|
|
20
20
|
HEADINGS = %w[h1 h2 h3 h4 h5 h6].freeze
|
|
21
21
|
SECTIONS = %w[thead tbody tfoot].freeze
|
|
22
22
|
BLOCKS = (CLOSES_P + SECTIONS + %w[tr td th caption]).freeze
|
|
23
|
-
IMPLIED =
|
|
23
|
+
IMPLIED = Stationery.shareable(
|
|
24
24
|
"li" => [%w[li], %w[ul ol]],
|
|
25
25
|
"dt" => [%w[dt dd], %w[dl]], "dd" => [%w[dt dd], %w[dl]],
|
|
26
26
|
"td" => [%w[td th], %w[tr table]], "th" => [%w[td th], %w[tr table]],
|
|
27
27
|
"tr" => [%w[tr], %w[table]],
|
|
28
28
|
"thead" => [SECTIONS, %w[table]], "tbody" => [SECTIONS, %w[table]], "tfoot" => [SECTIONS, %w[table]]
|
|
29
|
-
|
|
29
|
+
)
|
|
30
30
|
|
|
31
31
|
# `css` is the Css::Report unsupported styles are told to.
|
|
32
32
|
def self.parse(tokens, nesting = Rich::Nesting.new, css: nil)
|
|
@@ -8,7 +8,7 @@ module Stationery
|
|
|
8
8
|
# Stationery::Hyphenation.hyphenate("Silbentrennung", "de") # => ["Sil", "ben", "tren", "nung"]
|
|
9
9
|
# Stationery::Hyphenation.points("hyphenation", "en") # => [2, 6]
|
|
10
10
|
module Hyphenation
|
|
11
|
-
PATTERNS = File.expand_path("hyphenation/patterns", __dir__)
|
|
11
|
+
PATTERNS = File.expand_path("hyphenation/patterns", __dir__).freeze
|
|
12
12
|
|
|
13
13
|
# Language tags accepted by `hyphenate:`, mapped to a bundled pattern set.
|
|
14
14
|
LANGUAGES = {
|
|
@@ -19,9 +19,7 @@ module Stationery
|
|
|
19
19
|
|
|
20
20
|
# Letters kept intact at either end of a word (the pattern sets' own
|
|
21
21
|
# typesetting minimums).
|
|
22
|
-
MINIMUMS = { "en-us" => [2, 3], "de-1996" => [2, 2], "sv" => [2, 2] }
|
|
23
|
-
|
|
24
|
-
LOCK = Mutex.new
|
|
22
|
+
MINIMUMS = Stationery.shareable({ "en-us" => [2, 3], "de-1996" => [2, 2], "sv" => [2, 2] })
|
|
25
23
|
|
|
26
24
|
# One pattern set: the patterns keyed by their letters, the longest
|
|
27
25
|
# pattern's length and the exception list.
|
|
@@ -108,10 +106,10 @@ module Stationery
|
|
|
108
106
|
end
|
|
109
107
|
end
|
|
110
108
|
|
|
109
|
+
# The Patterns of a `hyphenate:` value, loaded once per Ractor.
|
|
111
110
|
def patterns(language)
|
|
112
111
|
tag = tag(language)
|
|
113
|
-
|
|
114
|
-
LOCK.synchronize { @sets[tag] ||= Patterns.new(tag) }
|
|
112
|
+
Store.fetch(:stationery_hyphenation) { Memo.new }.fetch(tag) { Patterns.new(tag) }
|
|
115
113
|
end
|
|
116
114
|
|
|
117
115
|
# Indexes where `word` may be broken (before word[index]); an empty
|
|
@@ -4,28 +4,23 @@ require "digest/md5"
|
|
|
4
4
|
|
|
5
5
|
module Stationery
|
|
6
6
|
module Images
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
7
|
+
# The decoded images of one Ractor (see Store), the SIZE most recently
|
|
8
|
+
# used, keyed by content digest, so a logo drawn on every invoice is
|
|
9
|
+
# parsed once. Decoded images are immutable after construction apart
|
|
10
|
+
# from memoized derived data, which each image guards with its own lock.
|
|
10
11
|
module Cache
|
|
11
12
|
SIZE = 16
|
|
12
|
-
@cache = {}
|
|
13
|
-
@mutex = Mutex.new
|
|
14
13
|
|
|
15
14
|
class << self
|
|
16
15
|
def fetch(data)
|
|
17
|
-
|
|
18
|
-
@mutex.synchronize do
|
|
19
|
-
image = @cache.delete(key) || yield
|
|
20
|
-
@cache[key] = image
|
|
21
|
-
@cache.shift while @cache.size > SIZE
|
|
22
|
-
image
|
|
23
|
-
end
|
|
16
|
+
lru.fetch(Digest::MD5.digest(data)) { |held| held || yield }
|
|
24
17
|
end
|
|
25
18
|
|
|
26
|
-
def clear
|
|
27
|
-
|
|
28
|
-
|
|
19
|
+
def clear = lru.clear
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def lru = Store.fetch(:stationery_images) { LRU.new(SIZE) }
|
|
29
24
|
end
|
|
30
25
|
end
|
|
31
26
|
end
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
module Stationery
|
|
4
4
|
# JPEG, PNG and lossless WebP images, embedded as image XObjects.
|
|
5
5
|
module Images
|
|
6
|
-
PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b
|
|
7
|
-
JPEG_SIGNATURE = "\xFF\xD8".b
|
|
6
|
+
PNG_SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
|
|
7
|
+
JPEG_SIGNATURE = "\xFF\xD8".b.freeze
|
|
8
8
|
WEBP_SIGNATURE = /\ARIFF.{4}WEBP/mn
|
|
9
9
|
UNSUPPORTED = { "GIF8" => "GIF", "II*\0" => "TIFF", "MM\0*" => "TIFF", "BM" => "BMP" }.freeze
|
|
10
10
|
|
|
@@ -12,7 +12,7 @@ module Stationery
|
|
|
12
12
|
# The bits still unread when the window is refilled (never more than
|
|
13
13
|
# 16), kept below the next four bytes: the window stays a Fixnum.
|
|
14
14
|
KEEP = 0xFFFF
|
|
15
|
-
PAD = "\0\0\0\0".b
|
|
15
|
+
PAD = "\0\0\0\0".b.freeze
|
|
16
16
|
|
|
17
17
|
def initialize
|
|
18
18
|
start("".b)
|
|
@@ -16,7 +16,7 @@ module Stationery
|
|
|
16
16
|
# restart marker or a fill byte.
|
|
17
17
|
MARKER = /\xFF[^\x00\xD0-\xD7\xFF]/n
|
|
18
18
|
RESTART = /\xFF[\xD0-\xD7]/n
|
|
19
|
-
STUFFED = "\xFF\x00".b
|
|
19
|
+
STUFFED = "\xFF\x00".b.freeze
|
|
20
20
|
ZIGZAG = Decoder::ZIGZAG
|
|
21
21
|
|
|
22
22
|
def initialize(decoder, components, restart, progressive:)
|
|
@@ -36,6 +36,7 @@ module Stationery
|
|
|
36
36
|
end
|
|
37
37
|
|
|
38
38
|
def initialize(data)
|
|
39
|
+
@lock = Mutex.new
|
|
39
40
|
@data = data
|
|
40
41
|
read_frame_header
|
|
41
42
|
raise UnsupportedImage, "invalid JPEG image" unless @width
|
|
@@ -67,15 +68,17 @@ module Stationery
|
|
|
67
68
|
# With `at_least:` [width, height] in pixels, decoded at the smallest
|
|
68
69
|
# of a half, a quarter or an eighth of its size that is still that
|
|
69
70
|
# large (libjpeg's scaled IDCT: far less work for a photo drawn
|
|
70
|
-
# small), else at full size. Each scale is decoded once and kept
|
|
71
|
-
#
|
|
71
|
+
# small), else at full size. Each scale is decoded once and kept, under
|
|
72
|
+
# the image's lock, as the threads of a Ractor share it through the
|
|
73
|
+
# image cache; the rows are built on every call.
|
|
72
74
|
def pixels(at_least: nil)
|
|
73
75
|
reason = unsupported
|
|
74
76
|
raise UnsupportedImage, reason if reason
|
|
75
77
|
|
|
76
78
|
scale = (at_least && SCALES.find { |s| large_enough?(s, *at_least) }) || 1
|
|
77
|
-
@
|
|
78
|
-
|
|
79
|
+
samples, channels = @lock.synchronize do
|
|
80
|
+
(@decoded ||= {})[scale] ||= Colors.call(Decoder.new(@data, scale).call)
|
|
81
|
+
end
|
|
79
82
|
width = (@width + scale - 1) / scale
|
|
80
83
|
height = (@height + scale - 1) / scale
|
|
81
84
|
stride = width * channels
|
|
@@ -17,6 +17,7 @@ module Stationery
|
|
|
17
17
|
attr_reader :width, :height
|
|
18
18
|
|
|
19
19
|
def initialize(data)
|
|
20
|
+
@lock = Mutex.new
|
|
20
21
|
@idat = String.new(encoding: Encoding::BINARY)
|
|
21
22
|
read_chunks(data)
|
|
22
23
|
raise UnsupportedImage, "invalid PNG image" unless @width && CHANNELS.key?(@color_type)
|
|
@@ -26,10 +27,10 @@ module Stationery
|
|
|
26
27
|
def inspect = "#<#{self.class} #{@width}x#{@height}>"
|
|
27
28
|
|
|
28
29
|
# This image scaled down to `width` pixels (a Resampled), memoised per
|
|
29
|
-
# width
|
|
30
|
+
# width under the image's lock: the same instance is shared by the
|
|
31
|
+
# threads of a Ractor through the image cache.
|
|
30
32
|
def resample(width)
|
|
31
|
-
@resampled ||= {}
|
|
32
|
-
@resampled[width] ||= Resampled.new(pixels, width)
|
|
33
|
+
@lock.synchronize { (@resampled ||= {})[width] ||= Resampled.new(pixels, width) }
|
|
33
34
|
end
|
|
34
35
|
|
|
35
36
|
def build(writer)
|
|
@@ -17,7 +17,7 @@ module Stationery
|
|
|
17
17
|
# The order the lengths of the code-length code are stored in.
|
|
18
18
|
LENGTH_ORDER = [17, 18, 0, 1, 2, 3, 4, 5, 16, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15].freeze
|
|
19
19
|
# Extra bits and base of the repeat codes 16, 17 and 18.
|
|
20
|
-
REPEATS = [[2, 3], [3, 3], [7, 11]]
|
|
20
|
+
REPEATS = Stationery.shareable([[2, 3], [3, 3], [7, 11]])
|
|
21
21
|
|
|
22
22
|
module_function
|
|
23
23
|
|
|
@@ -19,6 +19,7 @@ module Stationery
|
|
|
19
19
|
attr_reader :width, :height
|
|
20
20
|
|
|
21
21
|
def initialize(data)
|
|
22
|
+
@lock = Mutex.new
|
|
22
23
|
container = Container.new(data)
|
|
23
24
|
lossless = Lossless.new(container.bitstream)
|
|
24
25
|
@width = lossless.width
|
|
@@ -33,10 +34,10 @@ module Stationery
|
|
|
33
34
|
def inspect = "#<#{self.class} #{@width}x#{@height}>"
|
|
34
35
|
|
|
35
36
|
# This image scaled down to `width` pixels (a Resampled), memoised per
|
|
36
|
-
# width
|
|
37
|
+
# width under the image's lock: the same instance is shared by the
|
|
38
|
+
# threads of a Ractor through the image cache.
|
|
37
39
|
def resample(width)
|
|
38
|
-
@resampled ||= {}
|
|
39
|
-
@resampled[width] ||= Resampled.new(pixels, width)
|
|
40
|
+
@lock.synchronize { (@resampled ||= {})[width] ||= Resampled.new(pixels, width) }
|
|
40
41
|
end
|
|
41
42
|
|
|
42
43
|
def build(writer)
|
|
@@ -13,13 +13,33 @@ module Stationery
|
|
|
13
13
|
end
|
|
14
14
|
end
|
|
15
15
|
|
|
16
|
+
# Frozen, so a render in any Ractor can use it.
|
|
17
|
+
NULL = Null.new.freeze
|
|
18
|
+
|
|
19
|
+
# None set: the default below is asked for on every read and never
|
|
20
|
+
# written, so a non-main Ractor can read it.
|
|
21
|
+
@instrumenter = nil
|
|
22
|
+
|
|
16
23
|
class << self
|
|
17
|
-
# Any object answering `instrument(name, payload) { |payload| }`;
|
|
18
|
-
#
|
|
24
|
+
# Any object answering `instrument(name, payload) { |payload| }`; nil
|
|
25
|
+
# gives the default back. A render inside a non-main Ractor can reach
|
|
26
|
+
# only a shareable one: a module (ActiveSupport::Notifications) or a
|
|
27
|
+
# frozen object.
|
|
28
|
+
# Set at boot, on the main Ractor, so a plain module attribute is enough.
|
|
19
29
|
attr_writer :instrumenter # rubocop:disable ThreadSafety/ClassAndModuleAttributes
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
# The one set, else ActiveSupport::Notifications when it is loaded,
|
|
32
|
+
# else NULL.
|
|
33
|
+
# rubocop:disable-next ThreadSafety/ClassInstanceVariable -- only read here; written at boot on the main Ractor
|
|
34
|
+
def instrumenter = @instrumenter || default
|
|
35
|
+
|
|
36
|
+
def default = defined?(::ActiveSupport::Notifications) ? ::ActiveSupport::Notifications : NULL
|
|
37
|
+
|
|
38
|
+
# Keeps the instrumenter in use, so one loaded after boot is not picked
|
|
39
|
+
# up (Stationery.finalize!).
|
|
40
|
+
def finalize!
|
|
41
|
+
# rubocop:disable-next ThreadSafety/ClassInstanceVariable -- written at boot on the main Ractor, before any render
|
|
42
|
+
@instrumenter = instrumenter
|
|
23
43
|
end
|
|
24
44
|
|
|
25
45
|
def instrument(name, payload = {}, &)
|
|
@@ -19,8 +19,8 @@ module Stationery
|
|
|
19
19
|
class Box < Node
|
|
20
20
|
include Wrapping
|
|
21
21
|
|
|
22
|
-
BORDER = { width: 1, color: "#000000", sides: %i[top right bottom left] }
|
|
23
|
-
SHADOW = { offset: [0, 4], blur: 8, color: "#000000", opacity: 0.15 }
|
|
22
|
+
BORDER = Stationery.shareable({ width: 1, color: "#000000", sides: %i[top right bottom left] })
|
|
23
|
+
SHADOW = Stationery.shareable({ offset: [0, 4], blur: 8, color: "#000000", opacity: 0.15 })
|
|
24
24
|
SHADOW_LAYERS = 8
|
|
25
25
|
|
|
26
26
|
attr_reader :content, :width_spec
|
|
@@ -9,8 +9,6 @@ module Stationery
|
|
|
9
9
|
SIDES = %i[top right bottom left].freeze
|
|
10
10
|
TEXT_OPTIONS = { size: :size, color: :color, weight: :weight, style: :style, font: :family,
|
|
11
11
|
letter_spacing: :letter_spacing }.freeze
|
|
12
|
-
BOXES = {} # rubocop:disable Style/MutableConstant
|
|
13
|
-
|
|
14
12
|
attr_reader :content, :options, :colspan, :rowspan, :tag, :row_tag
|
|
15
13
|
|
|
16
14
|
# `options` is shared with the other cells of a table, which is why a
|
|
@@ -47,11 +45,12 @@ module Stationery
|
|
|
47
45
|
@natural_width = @min_width = @heights = nil
|
|
48
46
|
end
|
|
49
47
|
|
|
50
|
-
# A padding of one number is a box every cell with that number shares
|
|
51
|
-
# a long table holds one Array of it, not
|
|
48
|
+
# A padding of one number is a box every cell with that number shares
|
|
49
|
+
# (one per Ractor, see Store): a long table holds one Array of it, not
|
|
50
|
+
# one per cell.
|
|
52
51
|
def padding
|
|
53
52
|
@padding ||= if (value = @options[:padding]).is_a?(Numeric)
|
|
54
|
-
|
|
53
|
+
Store.fetch(:stationery_cell_paddings) { Memo.new }.fetch(value) { Geometry.box(value).freeze }
|
|
55
54
|
else
|
|
56
55
|
Geometry.box(value)
|
|
57
56
|
end
|
|
@@ -23,8 +23,8 @@ module Stationery
|
|
|
23
23
|
# How far over a limit the rows measured so far must be before the rest
|
|
24
24
|
# is left unmeasured: more than any rounding in their sum.
|
|
25
25
|
SLACK = 0.001
|
|
26
|
-
DEFAULT_CELL = { padding: 5, borders: %i[top right bottom left], border_width: 0.5,
|
|
27
|
-
|
|
26
|
+
DEFAULT_CELL = Stationery.shareable({ padding: 5, borders: %i[top right bottom left], border_width: 0.5,
|
|
27
|
+
border_color: "#000000" })
|
|
28
28
|
|
|
29
29
|
# `tag` is the Table element its fragments share; a `continued` fragment
|
|
30
30
|
# repeats header rows already read on an earlier page.
|
|
@@ -8,7 +8,7 @@ module Stationery
|
|
|
8
8
|
# row links to the entry's anchor once that anchor has painted. Tagged,
|
|
9
9
|
# it is a TOCI holding the title as a Link and the number as a Reference.
|
|
10
10
|
class Entry < Node
|
|
11
|
-
LEADERS = { dots: { dash: [0, 3], cap: :round, width: 1 }, line: { width: 0.5 } }
|
|
11
|
+
LEADERS = Stationery.shareable({ dots: { dash: [0, 3], cap: :round, width: 1 }, line: { width: 0.5 } })
|
|
12
12
|
|
|
13
13
|
def initialize(entry, context:, options:, slot:)
|
|
14
14
|
super()
|