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.
Files changed (78) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +3 -2
  3. data/README.md +66 -2
  4. data/lib/stationery/barcode/data_matrix.rb +2 -2
  5. data/lib/stationery/barcode/data_matrix_placement.rb +1 -1
  6. data/lib/stationery/barcode/ean13.rb +2 -2
  7. data/lib/stationery/barcode/qr.rb +4 -4
  8. data/lib/stationery/barcode/qr_matrix.rb +1 -1
  9. data/lib/stationery/barcode/reed_solomon.rb +7 -6
  10. data/lib/stationery/canvas/debug.rb +1 -1
  11. data/lib/stationery/canvas/interface.rb +1 -1
  12. data/lib/stationery/canvas/marking.rb +1 -1
  13. data/lib/stationery/cli.rb +7 -2
  14. data/lib/stationery/color.rb +5 -15
  15. data/lib/stationery/css/stylesheet.rb +1 -1
  16. data/lib/stationery/document.rb +32 -8
  17. data/lib/stationery/examples.rb +1 -1
  18. data/lib/stationery/fonts/bundled.rb +3 -3
  19. data/lib/stationery/fonts/catalog.rb +4 -3
  20. data/lib/stationery/fonts/cff.rb +6 -7
  21. data/lib/stationery/fonts/cff_subset.rb +1 -1
  22. data/lib/stationery/fonts/family.rb +5 -2
  23. data/lib/stationery/fonts/font.rb +4 -1
  24. data/lib/stationery/fonts/gsub.rb +12 -8
  25. data/lib/stationery/fonts/installer.rb +2 -2
  26. data/lib/stationery/fonts/ligatures.rb +5 -3
  27. data/lib/stationery/fonts/registry.rb +9 -19
  28. data/lib/stationery/fonts/true_type.rb +14 -17
  29. data/lib/stationery/forms/appearance.rb +1 -1
  30. data/lib/stationery/forms/field.rb +2 -2
  31. data/lib/stationery/html/tree_builder/blocks.rb +2 -2
  32. data/lib/stationery/html/tree_builder.rb +2 -2
  33. data/lib/stationery/hyphenation.rb +4 -6
  34. data/lib/stationery/images/cache.rb +10 -15
  35. data/lib/stationery/images/images.rb +2 -2
  36. data/lib/stationery/images/jpeg/bit_reader.rb +1 -1
  37. data/lib/stationery/images/jpeg/scan.rb +1 -1
  38. data/lib/stationery/images/jpeg.rb +7 -4
  39. data/lib/stationery/images/png.rb +4 -3
  40. data/lib/stationery/images/webp/prefix_code.rb +1 -1
  41. data/lib/stationery/images/webp.rb +4 -3
  42. data/lib/stationery/instrumentation.rb +24 -4
  43. data/lib/stationery/layout/box.rb +2 -2
  44. data/lib/stationery/layout/table/cell.rb +4 -5
  45. data/lib/stationery/layout/table.rb +2 -2
  46. data/lib/stationery/layout/table_of_contents/entry.rb +1 -1
  47. data/lib/stationery/lru.rb +31 -0
  48. data/lib/stationery/memo.rb +33 -0
  49. data/lib/stationery/monochrome.rb +2 -2
  50. data/lib/stationery/page.rb +2 -2
  51. data/lib/stationery/pdf/conformance.rb +10 -6
  52. data/lib/stationery/pdf/encryption/aes.rb +1 -1
  53. data/lib/stationery/pdf/encryption/standard_security.rb +2 -2
  54. data/lib/stationery/pdf/factur_x.rb +2 -2
  55. data/lib/stationery/pdf/serializer.rb +1 -1
  56. data/lib/stationery/pdf/writer.rb +1 -1
  57. data/lib/stationery/pdf/xmp.rb +1 -1
  58. data/lib/stationery/preview.rb +15 -5
  59. data/lib/stationery/railtie.rb +3 -0
  60. data/lib/stationery/raster/png.rb +2 -2
  61. data/lib/stationery/raster/render.rb +3 -3
  62. data/lib/stationery/rich/renderer/inlines.rb +4 -4
  63. data/lib/stationery/rich/styles.rb +2 -2
  64. data/lib/stationery/shareable.rb +41 -0
  65. data/lib/stationery/skill.rb +2 -2
  66. data/lib/stationery/store.rb +42 -0
  67. data/lib/stationery/svg/region.rb +1 -1
  68. data/lib/stationery/testing/inspector.rb +1 -1
  69. data/lib/stationery/testing/marked_text.rb +1 -1
  70. data/lib/stationery/testing/page_reader.rb +1 -1
  71. data/lib/stationery/text/markup.rb +2 -2
  72. data/lib/stationery/verify/allowlist.rb +1 -1
  73. data/lib/stationery/verify/engines/pdfium.rb +1 -1
  74. data/lib/stationery/verify/engines/pdfjs.rb +1 -1
  75. data/lib/stationery/verify/engines/pdfkit.rb +1 -1
  76. data/lib/stationery/version.rb +1 -1
  77. data/lib/stationery.rb +35 -1
  78. metadata +5 -1
@@ -2,15 +2,14 @@
2
2
 
3
3
  module Stationery
4
4
  module Fonts
5
- # Process-wide cache of parsed TrueType files, keyed by path, face and
6
- # mtime so an edited font is re-read. Parsing is the expensive part; the
7
- # per-document Font objects that track used glyphs are cheap wrappers
8
- # around a parse. A `#N` suffix on the path picks face N of a collection.
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
- @mutex.synchronize { fetch(path, index, mtime) }
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 "] }.freeze
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"].freeze
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, read on first use. Shared by every document using this
74
- # parse; the source is immutable once built.
75
- def kerning
76
- @kerning ||= Kerning.for(self)
77
- end
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 }.freeze
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
- }.freeze
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
- }.freeze
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
- }.freeze
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] }.freeze
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
- @sets ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
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
- # Process-wide cache of decoded images keyed by content digest, so a logo
8
- # drawn on every invoice is parsed once. Decoded images are immutable after
9
- # construction apart from memoized derived data, which is deterministic.
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
- key = Digest::MD5.digest(data)
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
- @mutex.synchronize { @cache.clear }
28
- end
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; the
71
- # rows are built on every call.
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
- @decoded ||= {}
78
- samples, channels = @decoded[scale] ||= Colors.call(Decoder.new(@data, scale).call)
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; the same instance is shared through the image cache.
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]].freeze
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; the same instance is shared through the image cache.
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| }`; set
18
- # once at boot, so a plain module attribute is enough.
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
- def instrumenter
22
- @instrumenter ||= defined?(::ActiveSupport::Notifications) ? ::ActiveSupport::Notifications : Null.new # rubocop:disable ThreadSafety/ClassInstanceVariable
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] }.freeze
23
- SHADOW = { offset: [0, 4], blur: 8, color: "#000000", opacity: 0.15 }.freeze
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 one per cell.
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
- BOXES[value] ||= Geometry.box(value).freeze
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
- border_color: "#000000" }.freeze
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 } }.freeze
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
- dither: DITHERS.map(&:inspect).join(", ").sub(/, (?=[^,]*\z)/, " or ") }.freeze
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.
@@ -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
- }.freeze
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
- }.freeze
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
- }.freeze
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(File.binread(ICC_PROFILE), { N: 3 }))
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) }] }
@@ -8,7 +8,7 @@ module Stationery
8
8
  # AES as PDF uses it: object data is CBC with a random IV in front and
9
9
  # PKCS#5 padding; key material (UE, OE, Perms) is unpadded.
10
10
  module AES
11
- ZERO_IV = ("\0" * 16).b
11
+ ZERO_IV = ("\0" * 16).b.freeze
12
12
 
13
13
  module_function
14
14
 
@@ -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
- }.freeze
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)