stationery 0.6.0 → 0.8.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 (57) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +21 -0
  3. data/README.md +198 -18
  4. data/lib/stationery/builder.rb +5 -3
  5. data/lib/stationery/canvas/text.rb +4 -3
  6. data/lib/stationery/canvas.rb +24 -2
  7. data/lib/stationery/cli/render.rb +19 -4
  8. data/lib/stationery/document.rb +72 -11
  9. data/lib/stationery/elements.rb +1 -1
  10. data/lib/stationery/errors.rb +13 -0
  11. data/lib/stationery/fonts/fallback.rb +3 -3
  12. data/lib/stationery/fonts/font.rb +39 -21
  13. data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
  14. data/lib/stationery/fonts/gsub.rb +65 -20
  15. data/lib/stationery/fonts/ligatures.rb +6 -4
  16. data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
  17. data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
  18. data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
  19. data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
  20. data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
  21. data/lib/stationery/hyphenation.rb +132 -0
  22. data/lib/stationery/images/jpeg.rb +2 -1
  23. data/lib/stationery/images/png.rb +66 -0
  24. data/lib/stationery/images/resampled.rb +80 -0
  25. data/lib/stationery/layout/image.rb +28 -2
  26. data/lib/stationery/layout/paginator.rb +1 -1
  27. data/lib/stationery/layout/text.rb +2 -2
  28. data/lib/stationery/minitest.rb +17 -0
  29. data/lib/stationery/page_templates.rb +2 -1
  30. data/lib/stationery/pdf/assembler.rb +40 -6
  31. data/lib/stationery/pdf/attachments.rb +76 -0
  32. data/lib/stationery/pdf/conformance.rb +139 -0
  33. data/lib/stationery/pdf/factur_x.rb +117 -0
  34. data/lib/stationery/pdf/icc/LICENSE.md +14 -0
  35. data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
  36. data/lib/stationery/pdf/page_labels.rb +71 -0
  37. data/lib/stationery/pdf/stream.rb +5 -3
  38. data/lib/stationery/pdf/xmp.rb +149 -0
  39. data/lib/stationery/resources.rb +3 -0
  40. data/lib/stationery/svg/clip_path.rb +69 -0
  41. data/lib/stationery/svg/document.rb +26 -22
  42. data/lib/stationery/svg/painter.rb +11 -0
  43. data/lib/stationery/svg/region.rb +25 -0
  44. data/lib/stationery/svg/shapes.rb +2 -0
  45. data/lib/stationery/svg/style.rb +25 -14
  46. data/lib/stationery/svg/viewport.rb +78 -0
  47. data/lib/stationery/svg/walker.rb +142 -0
  48. data/lib/stationery/testing/inspector.rb +81 -0
  49. data/lib/stationery/testing/matchers.rb +77 -0
  50. data/lib/stationery/text/entities.rb +14 -4
  51. data/lib/stationery/text/paragraph.rb +1 -1
  52. data/lib/stationery/text/style.rb +14 -3
  53. data/lib/stationery/text/wrapper.rb +73 -12
  54. data/lib/stationery/version.rb +1 -1
  55. data/lib/stationery/warnings.rb +10 -0
  56. data/lib/stationery.rb +12 -0
  57. metadata +20 -1
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Stationery
4
+ # Liang's hyphenation over the TeX `hyph-utf8` patterns bundled under
5
+ # hyphenation/patterns/ (see LICENSES.md there). A language loads on first
6
+ # use and stays cached per process.
7
+ #
8
+ # Stationery::Hyphenation.hyphenate("Silbentrennung", "de") # => ["Sil", "ben", "tren", "nung"]
9
+ # Stationery::Hyphenation.points("hyphenation", "en") # => [2, 6]
10
+ module Hyphenation
11
+ PATTERNS = File.expand_path("hyphenation/patterns", __dir__)
12
+
13
+ # Language tags accepted by `hyphenate:`, mapped to a bundled pattern set.
14
+ LANGUAGES = {
15
+ "en" => "en-us", "en-us" => "en-us", "en_us" => "en-us",
16
+ "de" => "de-1996", "de-1996" => "de-1996", "de-de" => "de-1996",
17
+ "sv" => "sv", "sv-se" => "sv"
18
+ }.freeze
19
+
20
+ # Letters kept intact at either end of a word (the pattern sets' own
21
+ # typesetting minimums).
22
+ MINIMUMS = { "en-us" => [2, 3], "de-1996" => [2, 2], "sv" => [2, 2] }.freeze
23
+
24
+ LOCK = Mutex.new
25
+
26
+ # One pattern set: the patterns keyed by their letters, the longest
27
+ # pattern's length and the exception list.
28
+ class Patterns
29
+ attr_reader :left_min, :right_min
30
+
31
+ def initialize(tag)
32
+ @left_min, @right_min = MINIMUMS.fetch(tag)
33
+ @patterns = {}
34
+ @longest = 0
35
+ @exceptions = {}
36
+ load_patterns(File.join(PATTERNS, "#{tag}.pat.txt"))
37
+ exceptions = File.join(PATTERNS, "#{tag}.hyp.txt")
38
+ load_exceptions(exceptions) if File.exist?(exceptions)
39
+ end
40
+
41
+ # Indexes where `word` may break (a break before word[index]),
42
+ # honouring the minimums.
43
+ def points(word)
44
+ key = word.downcase
45
+ return @exceptions[key] if @exceptions.key?(key)
46
+
47
+ levels = levels_for(".#{key}.")
48
+ (left_min..(word.length - right_min)).select { |index| levels[index].odd? }
49
+ end
50
+
51
+ private
52
+
53
+ def load_patterns(path)
54
+ File.foreach(path, chomp: true) do |line|
55
+ next if line.empty? || line.start_with?("%")
56
+
57
+ letters = line.delete("0-9")
58
+ values = Array.new(letters.length + 1, 0)
59
+ position = 0
60
+ line.each_char do |char|
61
+ if char.match?(/\d/) then values[position] = char.to_i
62
+ else position += 1
63
+ end
64
+ end
65
+ @patterns[letters] = values
66
+ @longest = letters.length if letters.length > @longest
67
+ end
68
+ end
69
+
70
+ # "as-so-ciate" → { "associate" => [2, 4] }
71
+ def load_exceptions(path)
72
+ File.foreach(path, chomp: true) do |line|
73
+ next if line.empty? || line.start_with?("%")
74
+
75
+ pieces = line.downcase.split("-")
76
+ positions = pieces[0..-2].each_with_object([]) { |piece, list| list << (list.last.to_i + piece.length) }
77
+ @exceptions[pieces.join] = positions
78
+ end
79
+ end
80
+
81
+ # Liang: the highest digit any matching pattern puts between two
82
+ # letters of the dotted word; odd means a break may go there. Index i
83
+ # in the result is the gap before letter i of the undotted word.
84
+ def levels_for(dotted)
85
+ levels = Array.new(dotted.length + 1, 0)
86
+ dotted.length.times do |start|
87
+ 1.upto([@longest, dotted.length - start].min) do |length|
88
+ values = @patterns[dotted[start, length]] or next
89
+ values.each_with_index do |value, offset|
90
+ levels[start + offset] = value if value > levels[start + offset]
91
+ end
92
+ end
93
+ end
94
+ levels.drop(1)
95
+ end
96
+ end
97
+
98
+ module_function
99
+
100
+ # The bundled pattern set for a `hyphenate:` value: true is English,
101
+ # a tag is looked up case-insensitively; nil for false or nil.
102
+ def tag(value)
103
+ return nil if value.nil? || value == false
104
+ return "en-us" if value == true
105
+
106
+ LANGUAGES.fetch(value.to_s.downcase) do
107
+ raise ArgumentError, "unknown hyphenation language #{value.inspect} (bundled: #{LANGUAGES.keys.join(", ")})"
108
+ end
109
+ end
110
+
111
+ def patterns(language)
112
+ tag = tag(language)
113
+ @sets ||= {} # rubocop:disable ThreadSafety/ClassInstanceVariable
114
+ LOCK.synchronize { @sets[tag] ||= Patterns.new(tag) }
115
+ end
116
+
117
+ # Indexes where `word` may be broken (before word[index]); an empty
118
+ # array for a word too short to break or holding anything but letters.
119
+ def points(word, language)
120
+ return [] unless word.match?(/\A\p{L}+\z/)
121
+
122
+ patterns(language).points(word)
123
+ end
124
+
125
+ # The word cut at its break points.
126
+ def hyphenate(word, language)
127
+ last = 0
128
+ pieces = points(word, language).map { |index| word[last...index].tap { last = index } }
129
+ pieces << word[last..]
130
+ end
131
+ end
132
+ end
@@ -21,9 +21,10 @@ module Stationery
21
21
  end
22
22
 
23
23
  def inspect = "#<#{self.class} #{@width}x#{@height}>"
24
+ def color_space = COLOR_SPACES.fetch(@components)
24
25
 
25
26
  def build(writer)
26
- dictionary = Images.xobject(@width, @height, COLOR_SPACES.fetch(@components), @bits).merge(Filter: :DCTDecode)
27
+ dictionary = Images.xobject(@width, @height, color_space, @bits).merge(Filter: :DCTDecode)
27
28
  # Adobe CMYK JPEGs store inverted values.
28
29
  dictionary[:Decode] = [1, 0] * 4 if @components == 4 && @adobe
29
30
  writer.add(PDF::Stream.new(@data, dictionary))
@@ -9,6 +9,8 @@ module Stationery
9
9
  # mask for grey/RGB tRNS).
10
10
  class PNG
11
11
  CHANNELS = { 0 => 1, 2 => 3, 3 => 1, 4 => 2, 6 => 4 }.freeze
12
+ # Colour samples per pixel once palette and alpha are unpacked.
13
+ COLOR_CHANNELS = { 0 => 1, 2 => 3, 3 => 3, 4 => 1, 6 => 3 }.freeze
12
14
 
13
15
  attr_reader :width, :height
14
16
 
@@ -21,6 +23,13 @@ module Stationery
21
23
 
22
24
  def inspect = "#<#{self.class} #{@width}x#{@height}>"
23
25
 
26
+ # This image scaled down to `width` pixels (a Resampled), memoised per
27
+ # width; the same instance is shared through the image cache.
28
+ def resample(width)
29
+ @resampled ||= {}
30
+ @resampled[width] ||= Resampled.new(pixels, width)
31
+ end
32
+
24
33
  def build(writer)
25
34
  return build_with_alpha(writer) if alpha_channel?
26
35
 
@@ -38,6 +47,63 @@ module Stationery
38
47
 
39
48
  private
40
49
 
50
+ # Every sample as 8 bits, colour rows apart from alpha rows: palette and
51
+ # sub-byte greys expanded, 16-bit samples dropped to their high byte, a
52
+ # colour-key or palette transparency turned into an alpha row set.
53
+ def pixels
54
+ channels = CHANNELS[@color_type]
55
+ color = []
56
+ alpha = []
57
+ Scanlines.each(@idat, @height, [(channels * @bit_depth) / 8, 1].max,
58
+ ((@width * channels * @bit_depth) + 7) / 8) do |row|
59
+ samples = samples_of(row)
60
+ case @color_type
61
+ when 3 then palette_pixels(samples, color, alpha)
62
+ when 4, 6 then split_pixels(samples, channels, color, alpha)
63
+ else keyed_pixels(samples, channels, color, alpha)
64
+ end
65
+ end
66
+ Pixels.new(width: @width, height: @height, channels: COLOR_CHANNELS[@color_type],
67
+ color:, alpha: alpha.empty? ? nil : alpha)
68
+ end
69
+
70
+ # A row's samples at 8 bits.
71
+ def samples_of(row)
72
+ case @bit_depth
73
+ when 8 then row
74
+ when 16 then row.each_slice(2).map(&:first)
75
+ else
76
+ scale = 255 / ((1 << @bit_depth) - 1)
77
+ bits = row.pack("C*").unpack1("B*")
78
+ values = bits.scan(/.{#{@bit_depth}}/o).first(@width).map { |b| b.to_i(2) }
79
+ @color_type == 3 ? values : values.map { |v| v * scale }
80
+ end
81
+ end
82
+
83
+ def palette_pixels(indexes, color, alpha)
84
+ palette = @palette.bytes
85
+ alphas = @transparency&.bytes
86
+ color << indexes.flat_map { |i| palette[i * 3, 3] }
87
+ alpha << indexes.map { |i| alphas[i] || 255 } if alphas
88
+ end
89
+
90
+ def split_pixels(samples, channels, color, alpha)
91
+ color << samples.each_slice(channels).flat_map { |px| px[0...-1] }
92
+ alpha << samples.each_slice(channels).map(&:last)
93
+ end
94
+
95
+ # Grey or RGB, with a tRNS colour key (compared at the file's bit depth)
96
+ # becoming a fully transparent alpha.
97
+ def keyed_pixels(samples, channels, color, alpha)
98
+ color << samples
99
+ return unless @transparency
100
+
101
+ key = @transparency.unpack("n*").first(channels).map do |v|
102
+ @bit_depth == 16 ? v >> 8 : v * (255 / ((1 << @bit_depth) - 1))
103
+ end
104
+ alpha << samples.each_slice(channels).map { |px| px == key ? 0 : 255 }
105
+ end
106
+
41
107
  def read_chunks(data)
42
108
  pos = PNG_SIGNATURE.bytesize
43
109
  while pos + 8 <= data.bytesize
@@ -0,0 +1,80 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Stationery
6
+ module Images
7
+ # A decoded image scaled down to a pixel width with a box filter (every
8
+ # source pixel of a block weighs the same), embedded as 8-bit grey or RGB
9
+ # with the alpha, when there is one, in a soft mask. Built by
10
+ # PNG#resample; JPEG data is never decoded, so it never comes here.
11
+ class Resampled
12
+ attr_reader :width, :height
13
+
14
+ # `pixels` is a Pixels: rows of 8-bit samples, `channels` colour samples
15
+ # per pixel (1 or 3) and an optional alpha row set.
16
+ def initialize(pixels, width)
17
+ @channels = pixels.channels
18
+ @width = [width, 1].max
19
+ @height = [(pixels.height * @width / pixels.width.to_f).round, 1].max
20
+ @color = shrink(pixels.color, pixels.width, pixels.height, @channels)
21
+ @alpha = pixels.alpha && shrink(pixels.alpha, pixels.width, pixels.height, 1)
22
+ end
23
+
24
+ def inspect = "#<#{self.class} #{@width}x#{@height}>"
25
+
26
+ def build(writer)
27
+ color_space = @channels == 1 ? :DeviceGray : :DeviceRGB
28
+ dictionary = Images.xobject(@width, @height, color_space, 8).merge(Filter: :FlateDecode)
29
+ if @alpha
30
+ mask = Images.xobject(@width, @height, :DeviceGray, 8).merge(Filter: :FlateDecode)
31
+ dictionary[:SMask] = writer.add(PDF::Stream.new(Zlib::Deflate.deflate(@alpha), mask))
32
+ end
33
+ writer.add(PDF::Stream.new(Zlib::Deflate.deflate(@color), dictionary))
34
+ end
35
+
36
+ private
37
+
38
+ # Box filter: output pixel (ox, oy) averages the source block whose edges
39
+ # are the scaled output edges. Row sums are accumulated per output column
40
+ # first so every source sample is visited once.
41
+ def shrink(rows, source_width, source_height, channels)
42
+ columns = spans(source_width, @width)
43
+ bands = spans(source_height, @height)
44
+ out = String.new(capacity: @width * @height * channels, encoding: Encoding::BINARY)
45
+ bands.each do |y0, y1|
46
+ sums = Array.new(@width * channels, 0)
47
+ (y0...y1).each { |y| add_row(sums, rows[y], columns, channels) }
48
+ count = y1 - y0
49
+ columns.each_with_index do |(x0, x1), ox|
50
+ n = (x1 - x0) * count
51
+ channels.times { |c| out << (sums[(ox * channels) + c] / n) }
52
+ end
53
+ end
54
+ out
55
+ end
56
+
57
+ def add_row(sums, row, columns, channels)
58
+ columns.each_with_index do |(x0, x1), ox|
59
+ base = ox * channels
60
+ (x0...x1).each do |x|
61
+ offset = x * channels
62
+ channels.times { |c| sums[base + c] += row[offset + c] }
63
+ end
64
+ end
65
+ end
66
+
67
+ # [start, end) source indexes for each of `target` output positions;
68
+ # every source index lands in exactly one span.
69
+ def spans(source, target)
70
+ Array.new(target) do |i|
71
+ [(i * source) / target, (((i + 1) * source) / target).clamp(((i * source) / target) + 1, source)]
72
+ end
73
+ end
74
+ end
75
+
76
+ # Decoded PNG samples: `color` and `alpha` are Arrays of rows, each row an
77
+ # Array of 8-bit sample values (`channels` per pixel for colour, one for alpha).
78
+ Pixels = Data.define(:width, :height, :channels, :color, :alpha)
79
+ end
80
+ end
@@ -9,14 +9,25 @@ module Stationery
9
9
  # `radius:` clips to rounded corners and `rotate:` (degrees, clockwise)
10
10
  # turns the painted image around the centre of its rectangle; neither
11
11
  # changes the space the image takes up.
12
+ #
13
+ # A bitmap keeps its pixels: drawn at more than twice `max_ppi:` (300;
14
+ # nil disables) it is reported as a Warnings::OversizedImage, and with
15
+ # `downscale: true` a PNG is resampled to `max_ppi` at its drawn size
16
+ # instead (a JPEG is never re-encoded; resize it before embedding).
12
17
  class Image < Node
18
+ DEFAULT_MAX_PPI = 300
19
+
13
20
  # `alt:` describes the image in a tagged PDF; `alt: false` marks it decorative.
14
- def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil, radius: 0, rotate: 0)
21
+ def initialize(source, width: nil, height: nil, fit: nil, opacity: nil, alt: nil, radius: 0, rotate: 0,
22
+ max_ppi: DEFAULT_MAX_PPI, downscale: false)
15
23
  raise ArgumentError, "fit: :cover needs width: and height:" if fit == :cover && !(width && height)
16
24
 
17
25
  super()
18
26
  @tag = alt == false ? nil : Tagging::Element.new(:Figure, alt:, kind: :image)
19
27
  @image = source.respond_to?(:build) ? source : Images.load(source)
28
+ @name = source.is_a?(String) || source.is_a?(Pathname) ? File.basename(source.to_s) : "inline image"
29
+ @max_ppi = max_ppi
30
+ @downscale = downscale
20
31
  @width = width
21
32
  @height = height
22
33
  @fit = fit
@@ -63,7 +74,22 @@ module Stationery
63
74
  w = cw
64
75
  h = ch
65
76
  end
66
- canvas.image(@image, x:, y:, width: w, height: h, opacity: @opacity)
77
+ canvas.image(bitmap(canvas, w), x:, y:, width: w, height: h, opacity: @opacity)
78
+ end
79
+
80
+ # The image to embed for a drawn width: resampled to the ppi limit when
81
+ # asked and possible, else the source, reported when it is oversized.
82
+ def bitmap(canvas, drawn_width)
83
+ return @image unless @max_ppi
84
+
85
+ wanted = (drawn_width / 72.0 * @max_ppi).ceil
86
+ return @image.resample(wanted) if @downscale && @image.width > wanted && @image.respond_to?(:resample)
87
+
88
+ ppi = (@image.width * 72.0 / drawn_width).round
89
+ if ppi > 2 * @max_ppi && canvas.warnings
90
+ canvas.warnings << Warnings::OversizedImage.new(source: @name, pixels: @image.width, ppi:, limit: @max_ppi)
91
+ end
92
+ @image
67
93
  end
68
94
 
69
95
  def requested
@@ -62,7 +62,7 @@ module Stationery
62
62
  box = page.content_box
63
63
  height = head.measure(box.width)
64
64
  @warnings << Overflow.new(page: number, height:, available: box.height) if height > box.height + EPSILON
65
- canvas = Canvas.new(page, @resources, debug: @debug, tagging: @tagging)
65
+ canvas = Canvas.new(page, @resources, debug: @debug, tagging: @tagging, warnings: @warnings)
66
66
  head.paint(canvas, box.x, box.y, box.width)
67
67
  canvas.debug_rect(box.x, box.y, box.width, box.height, :page)
68
68
  end
@@ -42,9 +42,9 @@ module Stationery
42
42
  @min_width ||= @runs.flat_map do |run|
43
43
  style = run.style
44
44
  font = @context.book.resolve(style).first
45
- run.text.split(/[ \t\n]+/).map do |word|
45
+ run.text.delete(::Stationery::Text::Wrapper::SOFT_HYPHEN).split(/[ \t\n]+/).map do |word|
46
46
  font.width_of(word, style.render_size, letter_spacing: style.letter_spacing, kerning: style.kerning,
47
- ligatures: style.ligatures)
47
+ ligatures: style.ligatures, features: style.features)
48
48
  end
49
49
  end.max || 0
50
50
  end
@@ -17,6 +17,23 @@ module Stationery
17
17
  def assert_image_count(subject, count, msg = nil) = assert_pdf(Matchers::HaveImageCount.new(count), subject, msg)
18
18
  def assert_bookmark(subject, title, msg = nil) = assert_pdf(Matchers::HaveBookmark.new(title), subject, msg)
19
19
  def assert_pdf_language(subject, lang, msg = nil) = assert_pdf(Matchers::HaveLanguage.new(lang), subject, msg)
20
+
21
+ def assert_page_labels(subject, labels, msg = nil)
22
+ assert_pdf(Matchers::HavePageLabels.new(labels), subject, msg)
23
+ end
24
+
25
+ def assert_pdf_attachment(subject, name, msg = nil, **)
26
+ assert_pdf(Matchers::HaveAttachment.new(name, **), subject, msg)
27
+ end
28
+
29
+ def assert_pdf_conformance(subject, levels, msg = nil)
30
+ assert_pdf(Matchers::HaveConformance.new(*levels), subject, msg)
31
+ end
32
+
33
+ def assert_factur_x(subject, msg = nil, profile: nil)
34
+ assert_pdf(Matchers::HaveFacturX.new(profile:), subject, msg)
35
+ end
36
+
20
37
  def assert_no_pdf_warnings(subject, msg = nil) = assert_pdf(Matchers::HaveNoWarnings.new, subject, msg)
21
38
  def assert_pdf_structure(subject, tree, msg = nil) = assert_pdf(Matchers::HaveStructure.new(tree), subject, msg)
22
39
  def assert_tagged_content(subject, msg = nil) = assert_pdf(Matchers::HaveTaggedContent.new, subject, msg)
@@ -54,7 +54,8 @@ module Stationery
54
54
  # Everything a template paints is a pagination artifact in a tagged PDF.
55
55
  def paint(page, root, rect, layer = :foreground, subtype: nil)
56
56
  mark = page.content.bytesize
57
- canvas = Canvas.new(page, @resources, template: true, debug: @debug, tagging: @tagging)
57
+ canvas = Canvas.new(page, @resources, template: true, debug: @debug, tagging: @tagging,
58
+ warnings: @warnings)
58
59
  canvas.artifact(type: :pagination, subtype:) { root.paint(canvas, rect.x, rect.y, rect.width) }
59
60
  page.content.prepend(page.content.slice!(mark..)) if layer == :background
60
61
  end
@@ -5,10 +5,23 @@ module Stationery
5
5
  # Writes finished pages, their shared resources and document info as PDF.
6
6
  class Assembler
7
7
  # `tagging:` (a Tagging::Tree) writes the structure tree of a tagged PDF;
8
- # `lang:` is the document's natural language.
9
- def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil)
8
+ # `lang:` is the document's natural language; `page_labels:` is the
9
+ # /PageLabels number tree from PageLabels.entries; `attachments:` are
10
+ # PDF::Attachment files to embed; `xmp:` (true) writes the XMP packet,
11
+ # with `xmp_extensions:` as further schemas and `xmp_schemas:` describing
12
+ # them to PDF/A (see XMP); `conformance:` (a PDF::Conformance) adds what
13
+ # PDF/A and PDF/UA ask of the file.
14
+ def initialize(pages:, resources:, info: {}, outline: [], encryption: nil, tagging: nil, lang: nil,
15
+ page_labels: nil, attachments: [], xmp: true, xmp_extensions: {}, xmp_schemas: [],
16
+ conformance: nil)
17
+ @xmp_schemas = xmp_schemas
18
+ @conformance = conformance
10
19
  @tagging = tagging
11
20
  @lang = lang
21
+ @page_labels = page_labels
22
+ @attachments = attachments
23
+ @xmp = xmp
24
+ @xmp_extensions = xmp_extensions
12
25
  @pages = pages
13
26
  @resources = resources
14
27
  @info = info
@@ -26,14 +39,19 @@ module Stationery
26
39
  @pages.each_with_index { |page, index| write_page(writer, page, kids[index], tree, refs) }
27
40
  writer.set(tree, { Type: :Pages, Kids: kids, Count: kids.size })
28
41
  outlines = OutlineWriter.new(writer, @outline, kids).write
29
- root = writer.add(catalog(tree, outlines, @form.write).merge(accessibility(writer)))
30
- writer.render(root:, info: writer.add(info_dictionary))
42
+ now = Time.now
43
+ info = info_dictionary(now)
44
+ entries = catalog(tree, outlines, @form.write)
45
+ .merge(accessibility(writer), metadata(writer, info, now), Attachments.write(writer, @attachments))
46
+ entries.merge!(@conformance.catalog_entries(writer)) if @conformance
47
+ writer.render(root: writer.add(entries), info: writer.add(info))
31
48
  end
32
49
 
33
50
  private
34
51
 
35
52
  def catalog(tree, outlines, form)
36
53
  catalog = { Type: :Catalog, Pages: tree }
54
+ catalog[:PageLabels] = @page_labels if @page_labels
37
55
  catalog[:AcroForm] = form if form
38
56
  outlines ? catalog.merge(Outlines: outlines, PageMode: :UseOutlines) : catalog
39
57
  end
@@ -55,6 +73,7 @@ module Stationery
55
73
  dictionary[:Annots] = page.annotations.map { |annot| annotation_ref(writer, annot, ref) }
56
74
  end
57
75
  dictionary.merge!(@structure.page_entries(page)) if @structure
76
+ dictionary.merge!(@conformance.page_entries(page)) if @conformance
58
77
  writer.set(ref, dictionary)
59
78
  end
60
79
 
@@ -73,6 +92,7 @@ module Stationery
73
92
 
74
93
  ref = writer.reserve
75
94
  dictionary = annotation(annotation)
95
+ dictionary = dictionary.merge(@conformance.annotation_entries(annotation)) if @conformance
76
96
  dictionary = dictionary.merge(@structure.annotation(annotation, ref)) if @structure
77
97
  writer.set(ref, dictionary)
78
98
  end
@@ -86,12 +106,26 @@ module Stationery
86
106
  { Type: :Annot, Subtype: :Link, Rect: link[:rect], Border: [0, 0, 0], **target }
87
107
  end
88
108
 
89
- def info_dictionary
109
+ def info_dictionary(now)
90
110
  info = { Producer: "Stationery #{VERSION}" }.merge(@info.compact)
91
111
  info = info.transform_values { |value| TextString.new(value.to_s) }
92
- info[:CreationDate] = TextString.new(Time.now.utc.strftime("D:%Y%m%d%H%M%SZ"))
112
+ info[:CreationDate] = TextString.new(now.utc.strftime("D:%Y%m%d%H%M%SZ"))
93
113
  info
94
114
  end
115
+
116
+ def extensions = (@conformance&.xmp_extensions || {}).merge(@xmp_extensions)
117
+ def schemas = (@conformance&.xmp_schemas || []) + @xmp_schemas
118
+
119
+ # The XMP packet as an uncompressed /Metadata stream, mirroring the Info
120
+ # dictionary at the same instant. An encrypted document encrypts it like
121
+ # every other stream (/EncryptMetadata defaults to true).
122
+ def metadata(writer, info, now)
123
+ return {} unless @xmp
124
+
125
+ values = info.except(:CreationDate).transform_values(&:value)
126
+ packet = XMP.packet(info: values, lang: @lang, time: now, extensions:, schemas:)
127
+ { Metadata: writer.add(Stream.new(packet, { Type: :Metadata, Subtype: :XML }, compress: false)) }
128
+ end
95
129
  end
96
130
  end
97
131
  end
@@ -0,0 +1,76 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+
5
+ module Stationery
6
+ module PDF
7
+ # A file embedded in the document: its name, bytes, MIME type, an
8
+ # optional description and how it relates to the document (the
9
+ # /AFRelationship a Factur-X invoice or a PDF/A-3 attachment declares).
10
+ Attachment = Data.define(:name, :data, :mime, :description, :relationship, :modified_at)
11
+
12
+ # Builds and writes embedded files: each becomes a /Filespec with an
13
+ # /EmbeddedFile stream, listed in the catalog's /Names /EmbeddedFiles
14
+ # tree and its /AF array.
15
+ module Attachments
16
+ RELATIONSHIPS = { alternative: :Alternative, source: :Source, data: :Data, supplement: :Supplement,
17
+ unspecified: :Unspecified }.freeze
18
+
19
+ module_function
20
+
21
+ def build(name, data, mime: "application/octet-stream", description: nil, relationship: :unspecified,
22
+ modified_at: nil)
23
+ name = name.to_s
24
+ raise ArgumentError, "an attachment needs a name" if name.empty?
25
+ raise ArgumentError, "attachment #{name.inspect} needs its data as a String" unless data.is_a?(String)
26
+ unless RELATIONSHIPS.key?(relationship)
27
+ raise ArgumentError, "unknown attachment relationship #{relationship.inspect}; use one of " \
28
+ "#{RELATIONSHIPS.keys.map(&:inspect).join(", ")}"
29
+ end
30
+
31
+ Attachment.new(name:, data: data.b, mime: mime.to_s, description: description&.to_s, relationship:,
32
+ modified_at:)
33
+ end
34
+
35
+ # One list from class-level attachments and per-render hashes
36
+ # (`{ name:, data:, mime:, … }`); the same name twice raises.
37
+ def merge(*lists)
38
+ lists.flatten.compact.map { |item| item.is_a?(Attachment) ? item : from_hash(item) }.tap do |all|
39
+ duplicate = all.map(&:name).tally.find { |_, count| count > 1 }&.first
40
+ raise ArgumentError, "attachment #{duplicate.inspect} is given twice" if duplicate
41
+ end
42
+ end
43
+
44
+ def from_hash(hash)
45
+ hash = hash.to_h.transform_keys(&:to_sym)
46
+ build(hash[:name], hash[:data], **hash.except(:name, :data))
47
+ end
48
+
49
+ # The catalog entries for the files, written through `writer`; empty without any.
50
+ def write(writer, attachments)
51
+ return {} if attachments.nil? || attachments.empty?
52
+
53
+ refs = attachments.sort_by(&:name).map do |attachment|
54
+ [attachment.name, writer.add(filespec(writer, attachment))]
55
+ end
56
+ { Names: { EmbeddedFiles: { Names: refs.flatten } }, AF: refs.map(&:last) }
57
+ end
58
+
59
+ def filespec(writer, attachment)
60
+ spec = { Type: :Filespec, F: attachment.name.b, UF: TextString.new(attachment.name),
61
+ AFRelationship: RELATIONSHIPS.fetch(attachment.relationship),
62
+ EF: { F: writer.add(stream(attachment)) } }
63
+ spec[:Desc] = TextString.new(attachment.description) if attachment.description
64
+ spec
65
+ end
66
+
67
+ def stream(attachment)
68
+ params = { Size: attachment.data.bytesize, CheckSum: HexString.new(Digest::MD5.digest(attachment.data)) }
69
+ if attachment.modified_at
70
+ params[:ModDate] = TextString.new(attachment.modified_at.utc.strftime("D:%Y%m%d%H%M%SZ"))
71
+ end
72
+ Stream.new(attachment.data, { Type: :EmbeddedFile, Subtype: attachment.mime.to_sym, Params: params })
73
+ end
74
+ end
75
+ end
76
+ end