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.
Files changed (80) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +4 -2
  3. data/README.md +74 -6
  4. data/examples/price_card.rb +10 -8
  5. data/lib/stationery/barcode/data_matrix.rb +2 -2
  6. data/lib/stationery/barcode/data_matrix_placement.rb +1 -1
  7. data/lib/stationery/barcode/ean13.rb +2 -2
  8. data/lib/stationery/barcode/qr.rb +4 -4
  9. data/lib/stationery/barcode/qr_matrix.rb +1 -1
  10. data/lib/stationery/barcode/reed_solomon.rb +7 -6
  11. data/lib/stationery/canvas/debug.rb +1 -1
  12. data/lib/stationery/canvas/interface.rb +1 -1
  13. data/lib/stationery/canvas/marking.rb +1 -1
  14. data/lib/stationery/cli.rb +7 -2
  15. data/lib/stationery/color.rb +5 -15
  16. data/lib/stationery/css/stylesheet.rb +1 -1
  17. data/lib/stationery/document.rb +32 -8
  18. data/lib/stationery/elements/forms.rb +2 -2
  19. data/lib/stationery/examples.rb +1 -1
  20. data/lib/stationery/fonts/bundled.rb +3 -3
  21. data/lib/stationery/fonts/catalog.rb +4 -3
  22. data/lib/stationery/fonts/cff.rb +6 -7
  23. data/lib/stationery/fonts/cff_subset.rb +1 -1
  24. data/lib/stationery/fonts/family.rb +5 -2
  25. data/lib/stationery/fonts/font.rb +4 -1
  26. data/lib/stationery/fonts/gsub.rb +12 -8
  27. data/lib/stationery/fonts/installer.rb +2 -2
  28. data/lib/stationery/fonts/ligatures.rb +5 -3
  29. data/lib/stationery/fonts/registry.rb +9 -19
  30. data/lib/stationery/fonts/true_type.rb +14 -17
  31. data/lib/stationery/forms/appearance.rb +6 -5
  32. data/lib/stationery/forms/field.rb +9 -5
  33. data/lib/stationery/html/tree_builder/blocks.rb +2 -2
  34. data/lib/stationery/html/tree_builder.rb +2 -2
  35. data/lib/stationery/hyphenation.rb +4 -6
  36. data/lib/stationery/images/cache.rb +10 -15
  37. data/lib/stationery/images/images.rb +2 -2
  38. data/lib/stationery/images/jpeg/bit_reader.rb +1 -1
  39. data/lib/stationery/images/jpeg/scan.rb +1 -1
  40. data/lib/stationery/images/jpeg.rb +7 -4
  41. data/lib/stationery/images/png.rb +4 -3
  42. data/lib/stationery/images/webp/prefix_code.rb +1 -1
  43. data/lib/stationery/images/webp.rb +4 -3
  44. data/lib/stationery/instrumentation.rb +24 -4
  45. data/lib/stationery/layout/box.rb +2 -2
  46. data/lib/stationery/layout/table/cell.rb +4 -5
  47. data/lib/stationery/layout/table.rb +2 -2
  48. data/lib/stationery/layout/table_of_contents/entry.rb +1 -1
  49. data/lib/stationery/lru.rb +31 -0
  50. data/lib/stationery/memo.rb +33 -0
  51. data/lib/stationery/monochrome.rb +2 -2
  52. data/lib/stationery/page.rb +2 -2
  53. data/lib/stationery/pdf/conformance.rb +10 -6
  54. data/lib/stationery/pdf/encryption/aes.rb +1 -1
  55. data/lib/stationery/pdf/encryption/standard_security.rb +2 -2
  56. data/lib/stationery/pdf/factur_x.rb +2 -2
  57. data/lib/stationery/pdf/serializer.rb +1 -1
  58. data/lib/stationery/pdf/writer.rb +1 -1
  59. data/lib/stationery/pdf/xmp.rb +1 -1
  60. data/lib/stationery/preview.rb +15 -5
  61. data/lib/stationery/railtie.rb +3 -0
  62. data/lib/stationery/raster/png.rb +2 -2
  63. data/lib/stationery/raster/render.rb +3 -3
  64. data/lib/stationery/rich/renderer/inlines.rb +4 -4
  65. data/lib/stationery/rich/styles.rb +2 -2
  66. data/lib/stationery/shareable.rb +41 -0
  67. data/lib/stationery/skill.rb +2 -2
  68. data/lib/stationery/store.rb +42 -0
  69. data/lib/stationery/svg/region.rb +1 -1
  70. data/lib/stationery/testing/inspector.rb +1 -1
  71. data/lib/stationery/testing/marked_text.rb +1 -1
  72. data/lib/stationery/testing/page_reader.rb +1 -1
  73. data/lib/stationery/text/markup.rb +2 -2
  74. data/lib/stationery/verify/allowlist.rb +1 -1
  75. data/lib/stationery/verify/engines/pdfium.rb +1 -1
  76. data/lib/stationery/verify/engines/pdfjs.rb +1 -1
  77. data/lib/stationery/verify/engines/pdfkit.rb +1 -1
  78. data/lib/stationery/version.rb +1 -1
  79. data/lib/stationery.rb +35 -1
  80. metadata +5 -1
@@ -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)
@@ -20,7 +20,7 @@ module Stationery
20
20
  DEFAULT_FILENAME = "factur-x.xml"
21
21
  DESCRIPTION = "Factur-X Invoice"
22
22
  ROOTS = ["<?xml", "<rsm:CrossIndustryInvoice"].freeze
23
- SCHEMA = {
23
+ SCHEMA = Stationery.shareable(
24
24
  name: "Factur-X PDFA Extension Schema", uri: NAMESPACE, prefix: "fx",
25
25
  properties: [
26
26
  { name: "DocumentFileName", type: "Text", category: "external",
@@ -32,7 +32,7 @@ module Stationery
32
32
  { name: "ConformanceLevel", type: "Text", category: "external",
33
33
  description: "The conformance level of the embedded XML document" }
34
34
  ].freeze
35
- }.freeze
35
+ )
36
36
 
37
37
  attr_reader :xml, :profile, :filename, :version
38
38
 
@@ -14,7 +14,7 @@ module Stationery
14
14
  module Serializer
15
15
  NAME_ESCAPE = %r{[^\x21-\x7E]|[#%()/<>\[\]{}]}n
16
16
  LITERAL_ESCAPE = /[\\()\r]/n
17
- UTF16_BOM = "\xFE\xFF".b
17
+ UTF16_BOM = "\xFE\xFF".b.freeze
18
18
  # Below it a Float times 10,000 is off by less than TIE / 8, so which
19
19
  # way it rounds is certain unless it is within TIE of a half.
20
20
  FAST_BELOW = 100_000.0
@@ -12,7 +12,7 @@ module Stationery
12
12
  # object set so far and drops it, so objects leave in the order they were
13
13
  # flushed and the first bytes leave before the last page is assembled.
14
14
  class Writer
15
- HEADER = "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".b
15
+ HEADER = "%PDF-1.7\n%\xE2\xE3\xCF\xD3\n".b.freeze
16
16
  WRITTEN = Object.new.freeze
17
17
 
18
18
  # `encryption` (an Encryption::StandardSecurity) encrypts every string
@@ -11,7 +11,7 @@ module Stationery
11
11
  # ids, so two renders of one document are byte-identical.
12
12
  module XMP
13
13
  PACKET_ID = "W5M0MpCehiHzreSzNTczkc9d"
14
- PADDING = "#{" " * 63}\n" * 32 # 2 KB of whitespace, as the spec suggests
14
+ PADDING = ("#{" " * 63}\n" * 32).freeze # 2 KB of whitespace, as the spec suggests
15
15
  DC = "http://purl.org/dc/elements/1.1/"
16
16
  XMP_NS = "http://ns.adobe.com/xap/1.0/"
17
17
  PDF_NS = "http://ns.adobe.com/pdf/1.3/"
@@ -15,20 +15,30 @@ module Stationery
15
15
  # def around_render(_name, params) = I18n.with_locale(params.fetch("locale", I18n.default_locale)) { yield }
16
16
  # end
17
17
  class Preview
18
- REGISTRY_LOCK = Mutex.new
19
18
  HOOKS = %w[around_render].freeze
20
19
 
20
+ # The subclasses defined in one Ractor (see Store), in the order they
21
+ # were, behind their own lock.
22
+ class Registry
23
+ def initialize
24
+ @classes = []
25
+ @lock = Mutex.new
26
+ end
27
+
28
+ def <<(subclass) = @lock.synchronize { @classes << subclass }
29
+ def to_a = @lock.synchronize { @classes.dup }
30
+ end
31
+
21
32
  class << self
22
33
  def inherited(subclass)
23
34
  super
24
- REGISTRY_LOCK.synchronize { Preview.registry << subclass }
35
+ Preview.registry << subclass
25
36
  end
26
37
 
27
38
  # Named subclasses with at least one preview; a name defined again (a
28
39
  # reloaded file) keeps its latest class.
29
40
  def all
30
- classes = REGISTRY_LOCK.synchronize { Preview.registry.dup }
31
- classes.select { it.name && it.pdfs.any? }.reverse.uniq(&:name).sort_by(&:preview_name)
41
+ Preview.registry.to_a.select { it.name && it.pdfs.any? }.reverse.uniq(&:name).sort_by(&:preview_name)
32
42
  end
33
43
 
34
44
  # `load`, not `require`: edited previews show up without a restart.
@@ -48,7 +58,7 @@ module Stationery
48
58
 
49
59
  protected
50
60
 
51
- def registry = (@registry ||= []) # rubocop:disable ThreadSafety/ClassInstanceVariable
61
+ def registry = Store.fetch(:stationery_previews) { Registry.new }
52
62
 
53
63
  private
54
64
 
@@ -32,6 +32,9 @@ module Stationery
32
32
  Railtie.add_previews(app.routes) if app.config.stationery.show_previews
33
33
  end
34
34
 
35
+ # Once every initializer has added its font paths: see Stationery.finalize!.
36
+ config.after_initialize { Stationery.finalize! }
37
+
35
38
  def self.add_font_paths(root, dirs)
36
39
  dirs.each do |dir|
37
40
  path = root.join(dir).to_s
@@ -10,8 +10,8 @@ module Stationery
10
10
  # written unfiltered, which deflate squeezes well enough for pages that
11
11
  # are mostly flat colour, and much faster in Ruby than a filter.
12
12
  module PNG
13
- SIGNATURE = "\x89PNG\r\n\x1A\n".b
14
- NO_FILTER = "\x00".b
13
+ SIGNATURE = "\x89PNG\r\n\x1A\n".b.freeze
14
+ NO_FILTER = "\x00".b.freeze
15
15
 
16
16
  module_function
17
17
 
@@ -17,9 +17,9 @@ module Stationery
17
17
  # class declares it.
18
18
  PDF_ONLY = %i[sign encrypt conformance attachments print tagged page_labels xmp factur_x incremental
19
19
  missing_glyphs object_streams].freeze
20
- GREY = "\x01-\xFE".b
21
- DARK = "\x00-\x7F".b
22
- LIGHT = "\x80-\xFF".b
20
+ GREY = "\x01-\xFE".b.freeze
21
+ DARK = "\x00-\x7F".b.freeze
22
+ LIGHT = "\x80-\xFF".b.freeze
23
23
 
24
24
  def self.refuse(options, output = "to_png")
25
25
  key = options.keys.first
@@ -5,7 +5,7 @@ module Stationery
5
5
  class Renderer
6
6
  # Writes inlines into a Text::RunsBuilder, nesting one builder call per mark.
7
7
  class Inlines
8
- STYLE_STEPS = {
8
+ STYLE_STEPS = Stationery.shareable(
9
9
  weight: ->(value) { [:b] if value == :bold },
10
10
  style: ->(value) { [:i] if value == :italic },
11
11
  underline: ->(value) { [:u] if value },
@@ -13,15 +13,15 @@ module Stationery
13
13
  color: ->(value) { [:color, value] if value },
14
14
  size: ->(value) { [:size, value] if value },
15
15
  font: ->(value) { [:font, value] if value }
16
- }.freeze
16
+ )
17
17
 
18
18
  # A true mark switches the style on, a false one (from CSS) switches it off again.
19
- MARK_STEPS = {
19
+ MARK_STEPS = Stationery.shareable(
20
20
  bold: { true => [[:b]], false => [[:with, { weight: :regular }]] },
21
21
  italic: { true => [[:i]], false => [[:with, { style: :normal }]] },
22
22
  underline: { true => [[:u]], false => [[:with, { underline: false }]] },
23
23
  strike: { true => [[:strikethrough]], false => [[:with, { strikethrough: false }]] }
24
- }.freeze
24
+ )
25
25
 
26
26
  def initialize(runs, styles, links = Links.new(:all, nil))
27
27
  @runs = runs
@@ -7,7 +7,7 @@ module Stationery
7
7
  module Styles
8
8
  HEADING_SCALES = [2.0, 1.6, 1.3, 1.1, 1.0, 0.9].freeze
9
9
 
10
- DEFAULTS = {
10
+ DEFAULTS = Stationery.shareable(
11
11
  **HEADING_SCALES.each_with_index.to_h do |scale, index|
12
12
  [:"h#{index + 1}", { scale:, weight: :bold, keep_with_next: true }]
13
13
  end,
@@ -22,7 +22,7 @@ module Stationery
22
22
  ol: {},
23
23
  li: {},
24
24
  img: { max_width: nil, float_margin: 8 }
25
- }.freeze
25
+ )
26
26
 
27
27
  module_function
28
28
 
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # `value` deep-frozen, so a constant holding it can be read from any
5
+ # Ractor: Ractor.make_shareable, which also makes a capture-free Proc
6
+ # shareable, or a plain freeze on an engine without Ractor (JRuby,
7
+ # TruffleRuby), where there is nothing to share it with. For the gem's
8
+ # own constants.
9
+ def self.shareable(value) = defined?(::Ractor) ? ::Ractor.make_shareable(value) : value.freeze
10
+
11
+ # Settings an app gave at boot, frozen as far as they can be without
12
+ # touching what the app holds: a Hash, an Array or a Data is a frozen copy
13
+ # of its contents settled in turn, a String or a Time a frozen copy, and a
14
+ # block is made shareable when it uses nothing unshareable from around it.
15
+ # Anything else not shareable already (a shaper or an instrumenter that
16
+ # keeps state, a key, a block that closes over the app's state) is kept as
17
+ # it is, never frozen: the main Ractor uses it as before, and a Ractor
18
+ # cannot. Never raises. For Document.finalize!.
19
+ def self.settled(value)
20
+ return value if defined?(::Ractor) && ::Ractor.shareable?(value)
21
+
22
+ case value
23
+ when Hash then value.transform_values { settled(it) }.freeze
24
+ when Array then value.map { settled(it) }.freeze
25
+ when String, Time then value.frozen? ? value : value.dup.freeze
26
+ when Data then value.with(**value.to_h.transform_values { settled(it) })
27
+ when Proc then settled_block(value)
28
+ else value
29
+ end
30
+ end
31
+
32
+ # A capture-free block made shareable where it is, which leaves it as it
33
+ # was when it cannot be: Ractor.make_shareable checks what the block uses
34
+ # before it changes anything.
35
+ def self.settled_block(block)
36
+ defined?(::Ractor) ? ::Ractor.make_shareable(block) : block
37
+ rescue ::Ractor::Error
38
+ block
39
+ end
40
+ private_class_method :settled_block
41
+ end
@@ -15,8 +15,8 @@ module Stationery
15
15
  class Skill
16
16
  NAME = "stationery"
17
17
  GENERATOR = "stationery skill"
18
- TEMPLATES = File.expand_path("skill", __dir__)
19
- ROOT = File.expand_path("../..", __dir__)
18
+ TEMPLATES = File.expand_path("skill", __dir__).freeze
19
+ ROOT = File.expand_path("../..", __dir__).freeze
20
20
  DOCS = "https://stationery.zoolutions.llc"
21
21
 
22
22
  # The version a SKILL.md was written for, when stationery wrote it.
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Where the gem's caches live: one value per key and per Ractor, shared by
5
+ # that Ractor's threads, built by the block the first time it is asked for.
6
+ #
7
+ # Stationery::Store.fetch(:stationery_fonts) { LRU.new(16) }
8
+ #
9
+ # A cache in a constant or a class instance variable cannot be read from a
10
+ # non-main Ractor, so each Ractor keeps its own here and a cache's lock
11
+ # lives inside it (see LRU and Memo). On Ruby 3.4+ this is
12
+ # `Ractor.store_if_absent`, atomic and per Ractor. An engine without it
13
+ # (JRuby, TruffleRuby: no Ractor at all) has one Hash behind a Mutex,
14
+ # both made at load. conformance-kit's ConformanceKit::Store is the same
15
+ # pattern; the gem cannot depend on the kit when it runs.
16
+ module Store
17
+ # A Hash whose every read and write holds one Mutex: the store of an
18
+ # engine without Ractor.store_if_absent.
19
+ class Locked
20
+ def initialize
21
+ @lock = Mutex.new
22
+ @values = {}
23
+ end
24
+
25
+ def fetch(key)
26
+ @lock.synchronize { @values.fetch(key) { @values[key] = yield } }
27
+ end
28
+ end
29
+
30
+ def self.native? = defined?(::Ractor) ? ::Ractor.respond_to?(:store_if_absent) : false
31
+
32
+ if native?
33
+ def self.fetch(key, &) = ::Ractor.store_if_absent(key, &)
34
+ else
35
+ # Only where there is no Ractor to read it from: made once, at load.
36
+ @locked = Locked.new
37
+
38
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable -- written once at load; Locked holds its own Mutex
39
+ def self.fetch(key, &) = @locked.fetch(key, &)
40
+ end
41
+ end
42
+ end
@@ -19,7 +19,7 @@ module Stationery
19
19
  def trace(path) = Shapes.trace(path, element)
20
20
  end
21
21
 
22
- EMPTY = new([], false, nil).freeze
22
+ EMPTY = Stationery.shareable(new([], false, nil))
23
23
  end
24
24
  end
25
25
  end
@@ -14,7 +14,7 @@ module Stationery
14
14
  # `password:` opens an encrypted one. Needs the pdf-reader gem, loaded on
15
15
  # first use.
16
16
  class Inspector
17
- UTF16_BOM = "\xFE\xFF".b
17
+ UTF16_BOM = "\xFE\xFF".b.freeze
18
18
  # id-aa-signatureTimeStampToken: a signature's RFC 3161 timestamp.
19
19
  TIMESTAMP_TOKEN = "1.2.840.113549.1.9.16.2.14"
20
20
  XMP_PROPERTY = %r{^\s*<((?!rdf:)[\w.-]+:[\w.-]+)>(.*?)</\1>$}
@@ -19,7 +19,7 @@ module Stationery
19
19
  set_character_spacing set_word_spacing set_text_rise set_horizontal_text_scaling
20
20
  set_text_rendering_mode].freeze
21
21
 
22
- UTF16_BOM = "\xFE\xFF".b
22
+ UTF16_BOM = "\xFE\xFF".b.freeze
23
23
  # The width of a character in a sequence that moves the pen nowhere, in
24
24
  # parts of its size.
25
25
  NOMINAL = 0.5
@@ -15,7 +15,7 @@ module Stationery
15
15
  set_text_matrix_and_text_line_matrix move_to_start_of_next_line set_text_leading
16
16
  set_character_spacing set_word_spacing set_text_rise set_horizontal_text_scaling
17
17
  set_text_rendering_mode].freeze
18
- UTF16_BOM = "\xFE\xFF".b
18
+ UTF16_BOM = "\xFE\xFF".b.freeze
19
19
  SUBSET = /\A[A-Z]{6}\+/
20
20
  # A gap wider than this share of the size is a space between words;
21
21
  # one wider than the size itself starts a new run.
@@ -15,13 +15,13 @@ module Stationery
15
15
  class Markup
16
16
  TAG = %r{<(/?)([a-zA-Z][a-zA-Z0-9]*)((?:\s+[a-zA-Z_:-]+\s*=\s*(?:"[^"]*"|'[^']*'|[^\s>"']+))*)\s*/?>}
17
17
  ATTRIBUTE = /([a-zA-Z_:-]+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s>"']+))/
18
- STYLES = {
18
+ STYLES = Stationery.shareable(
19
19
  "b" => { weight: :bold }, "strong" => { weight: :bold },
20
20
  "i" => { style: :italic }, "em" => { style: :italic },
21
21
  "u" => { underline: true },
22
22
  "strikethrough" => { strikethrough: true }, "s" => { strikethrough: true }, "del" => { strikethrough: true },
23
23
  "sub" => { script: :sub }, "sup" => { script: :sup }
24
- }.freeze
24
+ )
25
25
 
26
26
  def self.parse(source, style)
27
27
  new(source, style).runs
@@ -19,7 +19,7 @@ module Stationery
19
19
  { engine: "mupdf", pattern: /\Awarning: ICC support is not available\z/,
20
20
  why: "Debian's mutool (mupdf-tools, in the verify image) is built without colour management and says so " \
21
21
  "before it opens any file, even one that is not a PDF." }
22
- ].freeze
22
+ ].map { Stationery.shareable(it) }.freeze
23
23
 
24
24
  def self.allows?(entries, engine, message)
25
25
  entries.any? { |entry| entry[:engine] == engine && entry[:pattern].match?(message) }
@@ -8,7 +8,7 @@ module Stationery
8
8
  class Pdfium < Engine
9
9
  NAME = "pdfium"
10
10
  INSTALL = "pip install pypdfium2 (STATIONERY_PYTHON names the Python that has it)"
11
- SCRIPT = File.expand_path("../scripts/pdfium.py", __dir__)
11
+ SCRIPT = File.expand_path("../scripts/pdfium.py", __dir__).freeze
12
12
  VERSION = "import pypdfium2.version as v; print(v.PYPDFIUM_INFO, v.PDFIUM_INFO)"
13
13
 
14
14
  # -P everywhere: Python loads no module from beside the script or from
@@ -10,7 +10,7 @@ module Stationery
10
10
  class Pdfjs < Engine
11
11
  NAME = "pdfjs"
12
12
  INSTALL = "npm i pdfjs-dist (STATIONERY_PDFJS names its directory)"
13
- SCRIPT = File.expand_path("../scripts/pdfjs.mjs", __dir__)
13
+ SCRIPT = File.expand_path("../scripts/pdfjs.mjs", __dir__).freeze
14
14
  RESOLVE = "console.log(require('path').dirname(require.resolve('pdfjs-dist/package.json')))"
15
15
 
16
16
  def available? = !directory.nil?
@@ -8,7 +8,7 @@ module Stationery
8
8
  class Pdfkit < Engine
9
9
  NAME = "pdfkit"
10
10
  INSTALL = "macOS only: xcode-select --install"
11
- SCRIPT = File.expand_path("../scripts/pdfkit.swift", __dir__)
11
+ SCRIPT = File.expand_path("../scripts/pdfkit.swift", __dir__).freeze
12
12
 
13
13
  def available? = RUBY_PLATFORM.include?("darwin") && !Command.which("swift").nil?
14
14
  def version = "macOS #{run("sw_vers", "-productVersion").stdout.strip}"
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Stationery
4
- VERSION = "0.12.2"
4
+ VERSION = "0.13.0"
5
5
  end
data/lib/stationery.rb CHANGED
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require_relative "stationery/version"
4
+ require_relative "stationery/shareable"
4
5
  require_relative "stationery/errors"
6
+ require_relative "stationery/store"
7
+ require_relative "stationery/lru"
8
+ require_relative "stationery/memo"
5
9
  require_relative "stationery/instrumentation"
6
10
  require_relative "stationery/pdf/types"
7
11
  require_relative "stationery/pdf/serializer"
@@ -202,11 +206,41 @@ require_relative "stationery/document"
202
206
 
203
207
  # Pure-Ruby PDF documents built from Phlex-style components.
204
208
  module Stationery
205
- @font_paths = []
209
+ # Appended to at boot, on the main Ractor (the Railtie, an app's own
210
+ # setup); finalize! freezes it.
211
+ @font_paths = [] # rubocop:disable ThreadSafety/MutableClassInstanceVariable
206
212
 
207
213
  class << self
208
214
  # Directories searched for font files; the Railtie appends the app's.
215
+ # Frozen by finalize!.
209
216
  attr_reader :font_paths
217
+
218
+ # Ends boot: what the gem holds for every render is frozen, and
219
+ # Ractor-shareable as far as the app's settings let it be. `font_paths`
220
+ # is frozen, as a frozen copy of each directory; the instrumenter in use
221
+ # is kept, so one loaded later is not picked up; and every document class
222
+ # loaded by then is finalized (Document.finalize!), with its subclasses.
223
+ # Nothing it cannot make shareable raises: it is kept as it is, for the
224
+ # main Ractor. Run it once everything that adds a font path or defines a
225
+ # document class at boot has; the Railtie runs it after the app has
226
+ # initialized. Answers Stationery; running it again finalizes only the
227
+ # classes loaded since.
228
+ def finalize!
229
+ # rubocop:disable-next ThreadSafety/ClassInstanceVariable -- written at boot on the main Ractor, before any render
230
+ @font_paths = @font_paths.map { |path| path.to_s.dup.freeze }.freeze
231
+ Instrumentation.finalize!
232
+ finalize_documents(Document)
233
+ self
234
+ end
235
+
236
+ private
237
+
238
+ def finalize_documents(parent)
239
+ parent.subclasses.each do |document|
240
+ document.finalize!
241
+ finalize_documents(document)
242
+ end
243
+ end
210
244
  end
211
245
  # Names of the font families shipped inside the gem.
212
246
  def self.bundled_fonts = Fonts::Bundled.names
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: stationery
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.12.2
4
+ version: 0.13.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Mikael Henriksson
@@ -224,6 +224,7 @@ files:
224
224
  - lib/stationery/layout/text.rb
225
225
  - lib/stationery/layout/wrap.rb
226
226
  - lib/stationery/list_markers.rb
227
+ - lib/stationery/lru.rb
227
228
  - lib/stationery/markdown/block_parser.rb
228
229
  - lib/stationery/markdown/block_parser/containers.rb
229
230
  - lib/stationery/markdown/block_parser/tables.rb
@@ -232,6 +233,7 @@ files:
232
233
  - lib/stationery/markdown/inline_parser/emphasis.rb
233
234
  - lib/stationery/markdown/inline_parser/links.rb
234
235
  - lib/stationery/markdown/inline_parser/nodes.rb
236
+ - lib/stationery/memo.rb
235
237
  - lib/stationery/minitest.rb
236
238
  - lib/stationery/monochrome.rb
237
239
  - lib/stationery/monochrome/bitmap.rb
@@ -301,6 +303,7 @@ files:
301
303
  - lib/stationery/rich/styles.rb
302
304
  - lib/stationery/rspec.rb
303
305
  - lib/stationery/shaper.rb
306
+ - lib/stationery/shareable.rb
304
307
  - lib/stationery/skill.rb
305
308
  - lib/stationery/skill/SKILL.md.erb
306
309
  - lib/stationery/skill/recipes/flyer.md.erb
@@ -317,6 +320,7 @@ files:
317
320
  - lib/stationery/skill/reference/pages.md.erb
318
321
  - lib/stationery/skill/reference/printing.md.erb
319
322
  - lib/stationery/skill/reference/testing.md.erb
323
+ - lib/stationery/store.rb
320
324
  - lib/stationery/structure.rb
321
325
  - lib/stationery/svg/arc.rb
322
326
  - lib/stationery/svg/bounds.rb