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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +21 -0
- data/README.md +198 -18
- data/lib/stationery/builder.rb +5 -3
- data/lib/stationery/canvas/text.rb +4 -3
- data/lib/stationery/canvas.rb +24 -2
- data/lib/stationery/cli/render.rb +19 -4
- data/lib/stationery/document.rb +72 -11
- data/lib/stationery/elements.rb +1 -1
- data/lib/stationery/errors.rb +13 -0
- data/lib/stationery/fonts/fallback.rb +3 -3
- data/lib/stationery/fonts/font.rb +39 -21
- data/lib/stationery/fonts/gsub/single_subst.rb +37 -0
- data/lib/stationery/fonts/gsub.rb +65 -20
- data/lib/stationery/fonts/ligatures.rb +6 -4
- data/lib/stationery/hyphenation/patterns/LICENSES.md +60 -0
- data/lib/stationery/hyphenation/patterns/de-1996.pat.txt +36709 -0
- data/lib/stationery/hyphenation/patterns/en-us.hyp.txt +14 -0
- data/lib/stationery/hyphenation/patterns/en-us.pat.txt +4938 -0
- data/lib/stationery/hyphenation/patterns/sv.pat.txt +4693 -0
- data/lib/stationery/hyphenation.rb +132 -0
- data/lib/stationery/images/jpeg.rb +2 -1
- data/lib/stationery/images/png.rb +66 -0
- data/lib/stationery/images/resampled.rb +80 -0
- data/lib/stationery/layout/image.rb +28 -2
- data/lib/stationery/layout/paginator.rb +1 -1
- data/lib/stationery/layout/text.rb +2 -2
- data/lib/stationery/minitest.rb +17 -0
- data/lib/stationery/page_templates.rb +2 -1
- data/lib/stationery/pdf/assembler.rb +40 -6
- data/lib/stationery/pdf/attachments.rb +76 -0
- data/lib/stationery/pdf/conformance.rb +139 -0
- data/lib/stationery/pdf/factur_x.rb +117 -0
- data/lib/stationery/pdf/icc/LICENSE.md +14 -0
- data/lib/stationery/pdf/icc/sRGB2014.icc +0 -0
- data/lib/stationery/pdf/page_labels.rb +71 -0
- data/lib/stationery/pdf/stream.rb +5 -3
- data/lib/stationery/pdf/xmp.rb +149 -0
- data/lib/stationery/resources.rb +3 -0
- data/lib/stationery/svg/clip_path.rb +69 -0
- data/lib/stationery/svg/document.rb +26 -22
- data/lib/stationery/svg/painter.rb +11 -0
- data/lib/stationery/svg/region.rb +25 -0
- data/lib/stationery/svg/shapes.rb +2 -0
- data/lib/stationery/svg/style.rb +25 -14
- data/lib/stationery/svg/viewport.rb +78 -0
- data/lib/stationery/svg/walker.rb +142 -0
- data/lib/stationery/testing/inspector.rb +81 -0
- data/lib/stationery/testing/matchers.rb +77 -0
- data/lib/stationery/text/entities.rb +14 -4
- data/lib/stationery/text/paragraph.rb +1 -1
- data/lib/stationery/text/style.rb +14 -3
- data/lib/stationery/text/wrapper.rb +73 -12
- data/lib/stationery/version.rb +1 -1
- data/lib/stationery/warnings.rb +10 -0
- data/lib/stationery.rb +12 -0
- 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,
|
|
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(
|
|
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
|
data/lib/stationery/minitest.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
30
|
-
|
|
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(
|
|
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
|