stationery 0.12.3 → 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 +3 -2
- data/README.md +66 -2
- 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/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 +1 -1
- data/lib/stationery/forms/field.rb +2 -2
- 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
|
@@ -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,7 +14,7 @@ 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"
|
|
@@ -20,10 +20,10 @@ module Stationery
|
|
|
20
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
|
|
@@ -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()
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# The `size` most recently used entries of a cache, for the threads of one
|
|
5
|
+
# Ractor (the image and font caches keep one per Ractor in Store). Every
|
|
6
|
+
# read and write holds the cache's own Mutex, and so does building a
|
|
7
|
+
# value, so a value is built once however many threads ask for it.
|
|
8
|
+
class LRU
|
|
9
|
+
def initialize(size)
|
|
10
|
+
@size = size
|
|
11
|
+
@entries = {}
|
|
12
|
+
@lock = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The value for `key`, made the most recently used: what the block
|
|
16
|
+
# answers when given the value held for it (nil when there is none), so
|
|
17
|
+
# a caller keeps a value or replaces a stale one. Past `size` entries
|
|
18
|
+
# the least recently used goes. A block that raises stores nothing.
|
|
19
|
+
def fetch(key)
|
|
20
|
+
@lock.synchronize do
|
|
21
|
+
value = yield @entries.delete(key)
|
|
22
|
+
@entries[key] = value
|
|
23
|
+
@entries.shift while @entries.size > @size
|
|
24
|
+
value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def size = @lock.synchronize { @entries.size }
|
|
29
|
+
def clear = @lock.synchronize { @entries.clear }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Stationery
|
|
4
|
+
# Values built once per key, for the threads of one Ractor (the parsed
|
|
5
|
+
# colours and the hyphenation patterns keep one per Ractor in Store).
|
|
6
|
+
# Every read and write holds the memo's own Mutex, and so does building a
|
|
7
|
+
# value. With `limit:` the memo starts over once it holds that many.
|
|
8
|
+
class Memo
|
|
9
|
+
def initialize(limit: nil)
|
|
10
|
+
@limit = limit
|
|
11
|
+
@values = {}
|
|
12
|
+
@lock = Mutex.new
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
# The value for `key`, built by the block the first time. A block that
|
|
16
|
+
# raises stores nothing and leaves what is held as it was. A Hash keeps
|
|
17
|
+
# a frozen copy of a String key, so a String changed after it was used
|
|
18
|
+
# does not change what is held.
|
|
19
|
+
def fetch(key)
|
|
20
|
+
@lock.synchronize do
|
|
21
|
+
@values.fetch(key) do
|
|
22
|
+
value = yield
|
|
23
|
+
@values.clear if @limit && @values.size >= @limit
|
|
24
|
+
@values[key] = value
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def key?(key) = @lock.synchronize { @values.key?(key) }
|
|
30
|
+
def size = @lock.synchronize { @values.size }
|
|
31
|
+
def clear = @lock.synchronize { @values.clear }
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -15,8 +15,8 @@ module Stationery
|
|
|
15
15
|
module Monochrome
|
|
16
16
|
DITHERS = %i[floyd_steinberg ordered threshold].freeze
|
|
17
17
|
DEFAULTS = { dpi: 203, snap: false, threshold: 0.5, dither: :floyd_steinberg }.freeze
|
|
18
|
-
ACCEPTED = { dpi: "a number above 0", snap: "true or false", threshold: "a number from 0 to 1",
|
|
19
|
-
|
|
18
|
+
ACCEPTED = Stationery.shareable({ dpi: "a number above 0", snap: "true or false", threshold: "a number from 0 to 1",
|
|
19
|
+
dither: DITHERS.map(&:inspect).join(", ").sub(/, (?=[^,]*\z)/, " or ") })
|
|
20
20
|
|
|
21
21
|
# What a monochrome render is asked for. `dot` is the size of one of the
|
|
22
22
|
# printer's dots in points.
|
data/lib/stationery/page.rb
CHANGED
|
@@ -22,13 +22,13 @@ module Stationery
|
|
|
22
22
|
# Portrait, in points. Envelopes (dl, c5, c6) are written the ISO way,
|
|
23
23
|
# short edge first: `layout: :landscape` is the side the address is on.
|
|
24
24
|
# Label stock is width by height as its name has it.
|
|
25
|
-
SIZES =
|
|
25
|
+
SIZES = Stationery.shareable(
|
|
26
26
|
a3: [841.89, 1190.55], a4: [595.28, 841.89], a5: [419.53, 595.28], a6: [297.64, 419.53], a7: [209.76, 297.64],
|
|
27
27
|
b5: [498.9, 708.66], letter: [612, 792], legal: [612, 1008], tabloid: [792, 1224],
|
|
28
28
|
dl: [311.81, 623.62], c5: [459.21, 649.13], c6: [323.15, 459.21],
|
|
29
29
|
label_4x6: [288, 432], label_4x3: [288, 216], label_4x2: [288, 144],
|
|
30
30
|
label_100x150: [283.46, 425.2], label_100x50: [283.46, 141.73]
|
|
31
|
-
|
|
31
|
+
)
|
|
32
32
|
|
|
33
33
|
attr_reader :size, :margin, :reserve, :content, :annotations, :anchors, :template_anchors, :slots,
|
|
34
34
|
:resource_names, :body, :background
|
|
@@ -10,20 +10,20 @@ module Stationery
|
|
|
10
10
|
# conformant raises (ArgumentError for options that contradict the level,
|
|
11
11
|
# ConformanceError for content), so a file is never mislabelled.
|
|
12
12
|
class Conformance
|
|
13
|
-
LEVELS =
|
|
13
|
+
LEVELS = Stationery.shareable(
|
|
14
14
|
pdf_a2b: { standard: :pdf_a, part: 2, conformance: "B", label: "PDF/A-2b" },
|
|
15
15
|
pdf_a3b: { standard: :pdf_a, part: 3, conformance: "B", label: "PDF/A-3b" },
|
|
16
16
|
pdf_ua1: { standard: :pdf_ua, part: 1, label: "PDF/UA-1" }
|
|
17
|
-
|
|
17
|
+
)
|
|
18
18
|
PDFA_ID = "http://www.aiim.org/pdfa/ns/id/"
|
|
19
19
|
PDFUA_ID = "http://www.aiim.org/pdfua/ns/id/"
|
|
20
20
|
# PDF/A knows its own schema; PDF/UA's has to be described to it.
|
|
21
|
-
PDFUA_SCHEMA =
|
|
21
|
+
PDFUA_SCHEMA = Stationery.shareable(
|
|
22
22
|
name: "PDF/UA identification schema", uri: PDFUA_ID, prefix: "pdfuaid",
|
|
23
23
|
properties: [{ name: "part", type: "Integer", category: "internal",
|
|
24
24
|
description: "The part of ISO 14289 the document conforms to" }]
|
|
25
|
-
|
|
26
|
-
ICC_PROFILE = File.expand_path("icc/sRGB2014.icc", __dir__)
|
|
25
|
+
)
|
|
26
|
+
ICC_PROFILE = File.expand_path("icc/sRGB2014.icc", __dir__).freeze
|
|
27
27
|
OUTPUT_CONDITION = "sRGB IEC61966-2.1"
|
|
28
28
|
PRINT = 4
|
|
29
29
|
CMYK_OPERATOR = /^(?:-?[\d.]+ ){4}[kK]$/
|
|
@@ -38,6 +38,10 @@ module Stationery
|
|
|
38
38
|
|
|
39
39
|
def self.label(level) = LEVELS.dig(level.to_sym, :label) || level.to_s
|
|
40
40
|
|
|
41
|
+
# The bytes of the sRGB profile every PDF/A file embeds, read once per
|
|
42
|
+
# Ractor (see Store).
|
|
43
|
+
def self.icc_profile = Store.fetch(:stationery_icc_profile) { File.binread(ICC_PROFILE).freeze }
|
|
44
|
+
|
|
41
45
|
# The Conformance for `levels` (a Symbol, an Array or nil); nil without
|
|
42
46
|
# any. `missing_glyphs:` is checked even then.
|
|
43
47
|
def self.for(levels, missing_glyphs: :raise)
|
|
@@ -107,7 +111,7 @@ module Stationery
|
|
|
107
111
|
def catalog_entries(writer)
|
|
108
112
|
return {} unless pdf_a?
|
|
109
113
|
|
|
110
|
-
profile = writer.add(Stream.new(
|
|
114
|
+
profile = writer.add(Stream.new(Conformance.icc_profile, { N: 3 }))
|
|
111
115
|
{ OutputIntents: [{ Type: :OutputIntent, S: :GTS_PDFA1, DestOutputProfile: profile,
|
|
112
116
|
OutputConditionIdentifier: TextString.new(OUTPUT_CONDITION),
|
|
113
117
|
Info: TextString.new(OUTPUT_CONDITION) }] }
|
|
@@ -13,11 +13,11 @@ module Stationery
|
|
|
13
13
|
class StandardSecurity
|
|
14
14
|
PERMISSIONS = { print: 3, modify: 4, copy: 5, annotate: 6, fill_forms: 9, extract_accessible: 10,
|
|
15
15
|
assemble: 11, print_high: 12 }.freeze
|
|
16
|
-
ALGORITHMS =
|
|
16
|
+
ALGORITHMS = Stationery.shareable(
|
|
17
17
|
aes_256: { V: 5, R: 6, Length: 256, CFM: :AESV3, key: 32 },
|
|
18
18
|
aes_128: { V: 4, R: 4, Length: 128, CFM: :AESV2, key: 16 },
|
|
19
19
|
rc4_128: { V: 2, R: 3, Length: 128 }
|
|
20
|
-
|
|
20
|
+
)
|
|
21
21
|
|
|
22
22
|
# Checks and normalizes the options without deriving any keys.
|
|
23
23
|
def self.options(owner_password:, user_password: "", permissions: PERMISSIONS.keys, algorithm: :aes_256)
|