gamba 2.0.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 +7 -0
- data/CHANGELOG.md +80 -0
- data/COPYING +2 -0
- data/GPLv2 +339 -0
- data/GPLv3 +674 -0
- data/LICENSE +65 -0
- data/LICENSE-prawn-svg +21 -0
- data/README.md +148 -0
- data/data/fonts/Courier-Bold.afm +342 -0
- data/data/fonts/Courier-BoldOblique.afm +342 -0
- data/data/fonts/Courier-Oblique.afm +342 -0
- data/data/fonts/Courier.afm +342 -0
- data/data/fonts/Helvetica-Bold.afm +2827 -0
- data/data/fonts/Helvetica-BoldOblique.afm +2827 -0
- data/data/fonts/Helvetica-Oblique.afm +3051 -0
- data/data/fonts/Helvetica.afm +3051 -0
- data/data/fonts/MustRead.html +19 -0
- data/data/fonts/Symbol.afm +213 -0
- data/data/fonts/Times-Bold.afm +2588 -0
- data/data/fonts/Times-BoldItalic.afm +2384 -0
- data/data/fonts/Times-Italic.afm +2667 -0
- data/data/fonts/Times-Roman.afm +2419 -0
- data/data/fonts/ZapfDingbats.afm +225 -0
- data/lib/gamba/annotations.rb +21 -0
- data/lib/gamba/combine.rb +307 -0
- data/lib/gamba/document/bounding_box.rb +193 -0
- data/lib/gamba/document/column_box.rb +91 -0
- data/lib/gamba/document/internals.rb +264 -0
- data/lib/gamba/document/span.rb +25 -0
- data/lib/gamba/document.rb +268 -0
- data/lib/gamba/encoding.rb +13 -0
- data/lib/gamba/errors.rb +39 -0
- data/lib/gamba/font.rb +228 -0
- data/lib/gamba/fonts/afm.rb +171 -0
- data/lib/gamba/fonts/otf.rb +50 -0
- data/lib/gamba/fonts/ttf.rb +413 -0
- data/lib/gamba/graphic_state.rb +84 -0
- data/lib/gamba/graphics/color.rb +174 -0
- data/lib/gamba/graphics/patterns.rb +100 -0
- data/lib/gamba/graphics/style.rb +159 -0
- data/lib/gamba/graphics.rb +212 -0
- data/lib/gamba/grid.rb +216 -0
- data/lib/gamba/images/image.rb +271 -0
- data/lib/gamba/images/png_decoder.rb +65 -0
- data/lib/gamba/images.rb +115 -0
- data/lib/gamba/measurement_extensions.rb +40 -0
- data/lib/gamba/measurements.rb +62 -0
- data/lib/gamba/outline.rb +148 -0
- data/lib/gamba/page.rb +125 -0
- data/lib/gamba/page_geometry.rb +27 -0
- data/lib/gamba/pdf/decryption.rb +165 -0
- data/lib/gamba/pdf/outline_node.rb +53 -0
- data/lib/gamba/pdf/reader.rb +616 -0
- data/lib/gamba/pdf/reference.rb +31 -0
- data/lib/gamba/pdf/writer.rb +105 -0
- data/lib/gamba/pdf.rb +87 -0
- data/lib/gamba/pdf_a/srgb_profile.rb +61 -0
- data/lib/gamba/pdf_a.rb +132 -0
- data/lib/gamba/repeater.rb +79 -0
- data/lib/gamba/security.rb +111 -0
- data/lib/gamba/stamp.rb +76 -0
- data/lib/gamba/table/cell/kinds.rb +246 -0
- data/lib/gamba/table/cell.rb +331 -0
- data/lib/gamba/table/cells.rb +208 -0
- data/lib/gamba/table.rb +298 -0
- data/lib/gamba/text/box.rb +16 -0
- data/lib/gamba/text/formatted/arranger.rb +217 -0
- data/lib/gamba/text/formatted/box.rb +312 -0
- data/lib/gamba/text/formatted/fragment.rb +205 -0
- data/lib/gamba/text/formatted/line_wrap.rb +205 -0
- data/lib/gamba/text/formatted/parser.rb +150 -0
- data/lib/gamba/text/formatted/wrap.rb +103 -0
- data/lib/gamba/text.rb +310 -0
- data/lib/gamba/transformation_stack.rb +43 -0
- data/lib/gamba/true_type/cff_subset.rb +157 -0
- data/lib/gamba/true_type/file.rb +372 -0
- data/lib/gamba/true_type/open_type_layout.rb +189 -0
- data/lib/gamba/true_type/simple_subsets.rb +108 -0
- data/lib/gamba/true_type/subset.rb +206 -0
- data/lib/gamba/version.rb +10 -0
- data/lib/gamba/view.rb +28 -0
- data/lib/gamba.rb +124 -0
- data/lib/pdf/core.rb +4 -0
- data/lib/prawn/measurement_extensions.rb +4 -0
- data/lib/prawn/svg/attributes/clip_path.rb +46 -0
- data/lib/prawn/svg/attributes/mask.rb +16 -0
- data/lib/prawn/svg/attributes/opacity.rb +30 -0
- data/lib/prawn/svg/attributes/space.rb +10 -0
- data/lib/prawn/svg/attributes/stroke.rb +50 -0
- data/lib/prawn/svg/attributes/transform.rb +10 -0
- data/lib/prawn/svg/attributes.rb +6 -0
- data/lib/prawn/svg/calculators/arc_to_bezier_curve.rb +57 -0
- data/lib/prawn/svg/calculators/aspect_ratio.rb +60 -0
- data/lib/prawn/svg/calculators/document_sizing.rb +103 -0
- data/lib/prawn/svg/calculators/path_length.rb +168 -0
- data/lib/prawn/svg/calculators/pixels.rb +57 -0
- data/lib/prawn/svg/color.rb +240 -0
- data/lib/prawn/svg/css/font_face_parser.rb +72 -0
- data/lib/prawn/svg/css/font_family_parser.rb +39 -0
- data/lib/prawn/svg/css/font_parser.rb +46 -0
- data/lib/prawn/svg/css/selector_parser.rb +180 -0
- data/lib/prawn/svg/css/stylesheets.rb +233 -0
- data/lib/prawn/svg/css/values_parser.rb +68 -0
- data/lib/prawn/svg/document.rb +169 -0
- data/lib/prawn/svg/elements/anchor.rb +10 -0
- data/lib/prawn/svg/elements/base.rb +315 -0
- data/lib/prawn/svg/elements/bbox_scaling.rb +78 -0
- data/lib/prawn/svg/elements/call_duplicator.rb +37 -0
- data/lib/prawn/svg/elements/circle.rb +19 -0
- data/lib/prawn/svg/elements/clip_path.rb +26 -0
- data/lib/prawn/svg/elements/container.rb +10 -0
- data/lib/prawn/svg/elements/direct_render_base.rb +28 -0
- data/lib/prawn/svg/elements/ellipse.rb +20 -0
- data/lib/prawn/svg/elements/gradient.rb +183 -0
- data/lib/prawn/svg/elements/ignored.rb +5 -0
- data/lib/prawn/svg/elements/image.rb +178 -0
- data/lib/prawn/svg/elements/line.rb +27 -0
- data/lib/prawn/svg/elements/marker.rb +72 -0
- data/lib/prawn/svg/elements/mask.rb +65 -0
- data/lib/prawn/svg/elements/path.rb +294 -0
- data/lib/prawn/svg/elements/pattern.rb +150 -0
- data/lib/prawn/svg/elements/polygon.rb +25 -0
- data/lib/prawn/svg/elements/polyline.rb +23 -0
- data/lib/prawn/svg/elements/rect.rb +64 -0
- data/lib/prawn/svg/elements/root.rb +22 -0
- data/lib/prawn/svg/elements/switch.rb +92 -0
- data/lib/prawn/svg/elements/text.rb +99 -0
- data/lib/prawn/svg/elements/text_component.rb +352 -0
- data/lib/prawn/svg/elements/text_node.rb +308 -0
- data/lib/prawn/svg/elements/text_path.rb +153 -0
- data/lib/prawn/svg/elements/use.rb +142 -0
- data/lib/prawn/svg/elements/viewport.rb +29 -0
- data/lib/prawn/svg/elements.rb +46 -0
- data/lib/prawn/svg/extension.rb +25 -0
- data/lib/prawn/svg/font.rb +20 -0
- data/lib/prawn/svg/font_metrics.rb +144 -0
- data/lib/prawn/svg/font_registry.rb +315 -0
- data/lib/prawn/svg/funciri.rb +14 -0
- data/lib/prawn/svg/gradient_renderer.rb +332 -0
- data/lib/prawn/svg/gradients.rb +53 -0
- data/lib/prawn/svg/interface.rb +72 -0
- data/lib/prawn/svg/length.rb +43 -0
- data/lib/prawn/svg/link_renderer.rb +40 -0
- data/lib/prawn/svg/loaders/data.rb +17 -0
- data/lib/prawn/svg/loaders/file.rb +111 -0
- data/lib/prawn/svg/loaders/web.rb +24 -0
- data/lib/prawn/svg/paint.rb +67 -0
- data/lib/prawn/svg/pathable.rb +153 -0
- data/lib/prawn/svg/pattern_renderer.rb +112 -0
- data/lib/prawn/svg/pdfmatrix.rb +39 -0
- data/lib/prawn/svg/percentage.rb +24 -0
- data/lib/prawn/svg/properties.rb +329 -0
- data/lib/prawn/svg/renderer.rb +249 -0
- data/lib/prawn/svg/state.rb +24 -0
- data/lib/prawn/svg/transform_parser.rb +78 -0
- data/lib/prawn/svg/ttc.rb +35 -0
- data/lib/prawn/svg/ttf.rb +95 -0
- data/lib/prawn/svg/url_loader.rb +46 -0
- data/lib/prawn/svg/version.rb +5 -0
- data/lib/prawn/table.rb +4 -0
- data/lib/prawn-svg.rb +52 -0
- data/lib/prawn.rb +3 -0
- metadata +263 -0
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/md5"
|
|
4
|
+
require "zlib"
|
|
5
|
+
|
|
6
|
+
module PDF
|
|
7
|
+
module Core
|
|
8
|
+
# Writes a PDF 1.5 file: streams compressed, every other object packed into one compressed object stream, and
|
|
9
|
+
# a compressed cross-reference stream instead of the xref table (ISO 32000-1 §7.5.7, §7.5.8).
|
|
10
|
+
class Writer
|
|
11
|
+
MINIMUM_VERSION = 1.5
|
|
12
|
+
BINARY_MARKER = "%\xE2\xE3\xCF\xD3\n".b
|
|
13
|
+
|
|
14
|
+
def self.call(...)
|
|
15
|
+
new(...).call
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(objects:, root:, info:, version:, trailer: {}, encryption: nil, identified: false)
|
|
19
|
+
@objects = objects
|
|
20
|
+
@root = root
|
|
21
|
+
@info = info
|
|
22
|
+
@version = [version, MINIMUM_VERSION].max
|
|
23
|
+
@trailer = trailer
|
|
24
|
+
return unless encryption || identified
|
|
25
|
+
|
|
26
|
+
file_identifier = PDF::Core::ByteString.new(document_digest)
|
|
27
|
+
@trailer = trailer.merge(ID: [file_identifier, file_identifier])
|
|
28
|
+
return unless encryption
|
|
29
|
+
|
|
30
|
+
@encryption_key = encryption.key(file_identifier)
|
|
31
|
+
@trailer[:Encrypt] = encryption.dictionary(file_identifier)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def call
|
|
35
|
+
output = "%PDF-#{version}\n".b << BINARY_MARKER
|
|
36
|
+
location_by_identifier = {}
|
|
37
|
+
|
|
38
|
+
stream_objects, packed_objects = objects.partition(&:stream?)
|
|
39
|
+
stream_objects.each do |reference|
|
|
40
|
+
location_by_identifier[reference.identifier] = [1, output.bytesize]
|
|
41
|
+
output << indirect_stream(reference.identifier, reference.data, reference.stream, compressed: !reference.uncompressed)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
object_stream_identifier = objects.size + 1
|
|
45
|
+
unless packed_objects.empty?
|
|
46
|
+
location_by_identifier[object_stream_identifier] = [1, output.bytesize]
|
|
47
|
+
output << object_stream(object_stream_identifier, packed_objects, location_by_identifier)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
cross_reference_identifier = object_stream_identifier + 1
|
|
51
|
+
location_by_identifier[cross_reference_identifier] = [1, output.bytesize]
|
|
52
|
+
output << cross_reference_stream(cross_reference_identifier, location_by_identifier)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
private
|
|
56
|
+
|
|
57
|
+
attr_reader :objects, :root, :info, :version, :trailer
|
|
58
|
+
|
|
59
|
+
# A file identifier derived from the document's content, so the same document always gets the same one.
|
|
60
|
+
def document_digest
|
|
61
|
+
digest = Digest::MD5.new
|
|
62
|
+
objects.each do |reference|
|
|
63
|
+
digest << PDF::Core.serialize(reference.data)
|
|
64
|
+
digest << reference.stream if reference.stream?
|
|
65
|
+
end
|
|
66
|
+
digest.digest
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
# Streams are encrypted once compressed; the cross-reference stream never is (ISO 32000-1 §7.5.8.4).
|
|
70
|
+
def indirect_stream(identifier, dictionary, bytes, encrypted: true, compressed: true)
|
|
71
|
+
compressed &&= dictionary[:Filter].nil?
|
|
72
|
+
bytes = Zlib::Deflate.deflate(bytes) if compressed
|
|
73
|
+
bytes = Prawn::Document::Arcfour.new(Prawn::Document::Security.object_key(@encryption_key, identifier, 0)).encrypt(bytes) if @encryption_key && encrypted
|
|
74
|
+
dictionary = dictionary.merge(Length: bytes.bytesize)
|
|
75
|
+
dictionary[:Filter] = :FlateDecode if compressed
|
|
76
|
+
"#{identifier} 0 obj\n#{PDF::Core.serialize(dictionary)}\nstream\n".b << bytes.b << "\nendstream\nendobj\n"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def object_stream(identifier, references, location_by_identifier)
|
|
80
|
+
offsets = +""
|
|
81
|
+
body = +""
|
|
82
|
+
references.each_with_index do |reference, index|
|
|
83
|
+
location_by_identifier[reference.identifier] = [2, identifier, index]
|
|
84
|
+
offsets << "#{reference.identifier} #{body.bytesize} "
|
|
85
|
+
body << PDF::Core.serialize(reference.data) << "\n"
|
|
86
|
+
end
|
|
87
|
+
dictionary = {Type: :ObjStm, N: references.size, First: offsets.bytesize}
|
|
88
|
+
indirect_stream(identifier, dictionary, offsets << body)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Each row is one byte of type, then the offset or the object stream, then the generation or the index.
|
|
92
|
+
def cross_reference_stream(identifier, location_by_identifier)
|
|
93
|
+
size = identifier + 1
|
|
94
|
+
offset_width = [(location_by_identifier.values.map { _1[1] }.max.bit_length + 7) / 8, 1].max
|
|
95
|
+
rows = (+"\x00".b) << ("\x00" * offset_width).b << "\xFF\xFF".b
|
|
96
|
+
(1...size).each do |object_identifier|
|
|
97
|
+
type, field, generation_or_index = location_by_identifier.fetch(object_identifier)
|
|
98
|
+
rows << type.chr << [field].pack("Q>").byteslice(8 - offset_width, offset_width) << [generation_or_index.to_i].pack("n")
|
|
99
|
+
end
|
|
100
|
+
dictionary = {Type: :XRef, Size: size, W: [1, offset_width, 2], Root: root, Info: info}.merge(trailer)
|
|
101
|
+
indirect_stream(identifier, dictionary, rows, encrypted: false) << "startxref\n#{location_by_identifier.fetch(identifier)[1]}\n%%EOF\n"
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
end
|
data/lib/gamba/pdf.rb
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module PDF
|
|
4
|
+
# PDF syntax: numbers, names, strings, arrays and dictionaries, as ISO 32000-1 §7.3 writes them.
|
|
5
|
+
module Core
|
|
6
|
+
Name = Data.define(:value)
|
|
7
|
+
# A real number read from another file, written back exactly as it was read.
|
|
8
|
+
Real = Data.define(:source) do
|
|
9
|
+
def to_f = source.to_f
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
# Objects that build their dictionary when the document is written, once every reference they hold is known.
|
|
13
|
+
module Serializable
|
|
14
|
+
end
|
|
15
|
+
LiteralString = Class.new(String)
|
|
16
|
+
ByteString = Class.new(String)
|
|
17
|
+
|
|
18
|
+
DELIMITER_OR_IRREGULAR_BYTES = ((0..32).to_a + [35, 37, 40, 41, 47, 60, 62, 91, 93, 123, 125] + (127..255).to_a).freeze
|
|
19
|
+
LITERAL_STRING_ESCAPES = {"(" => "\\(", ")" => "\\)", "\\" => "\\\\", "\r" => "\\r"}.freeze
|
|
20
|
+
REGULAR_NAME = %r{\A[!-~&&[^#%()<>\[\]{}/]]*\z}
|
|
21
|
+
# Dictionary keys read from other files are symbols too: past a size no document of ours reaches, stop caching.
|
|
22
|
+
NAME_CACHE_LIMIT = 10_000
|
|
23
|
+
NAME_BY_SYMBOL = Hash.new do |cache, symbol|
|
|
24
|
+
name = escape_name(symbol.to_s).freeze
|
|
25
|
+
(cache.size < NAME_CACHE_LIMIT) ? cache[symbol] = name : name
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def escape_name(name)
|
|
31
|
+
return "/#{name}" if name.match?(REGULAR_NAME)
|
|
32
|
+
|
|
33
|
+
(+"/").concat(*name.b.each_byte.map { |byte| DELIMITER_OR_IRREGULAR_BYTES.include?(byte) ? format("#%02X", byte) : byte.chr })
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# Five decimals, trailing zeros dropped but one: 1 is "1.0", 0.5 is "0.5", 1/3 is "0.33333".
|
|
37
|
+
def real(number)
|
|
38
|
+
digits = format("%.5f", number)
|
|
39
|
+
last = digits.bytesize - 1
|
|
40
|
+
last -= 1 while digits.getbyte(last) == 48 && digits.getbyte(last - 1) != 46
|
|
41
|
+
digits.byteslice(0, last + 1)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def real_params(numbers)
|
|
45
|
+
numbers.map { real(_1) }.join(" ")
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
# Integers as integers, other numbers to five decimals without a trailing ".0".
|
|
49
|
+
def number(value)
|
|
50
|
+
return value.to_s if value.is_a?(Integer)
|
|
51
|
+
|
|
52
|
+
digits = real(value)
|
|
53
|
+
digits.end_with?(".0") ? digits.delete_suffix(".0") : digits
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def serialize(value, in_content_stream = false)
|
|
57
|
+
case value
|
|
58
|
+
when nil then "null"
|
|
59
|
+
when true then "true"
|
|
60
|
+
when false then "false"
|
|
61
|
+
when Numeric then number(value)
|
|
62
|
+
when Symbol then NAME_BY_SYMBOL[value]
|
|
63
|
+
# Names read from other files are escaped every time: caching them would keep every font subset name forever.
|
|
64
|
+
when Name then escape_name(value.value)
|
|
65
|
+
when Real then value.source
|
|
66
|
+
when LiteralString then "(#{value.gsub(/[\\\r()]/, LITERAL_STRING_ESCAPES)})"
|
|
67
|
+
when ByteString then "<#{value.unpack1("H*")}>"
|
|
68
|
+
when Time then "(#{value.strftime("D:%Y%m%d%H%M%S%z").chop.chop}'00')"
|
|
69
|
+
when String then "<#{(in_content_stream ? value : utf16be(value)).unpack1("H*")}>"
|
|
70
|
+
when Array then "[#{value.map { serialize(_1, in_content_stream) }.join(" ")}]"
|
|
71
|
+
when Hash then "<<#{value.map { |key, entry| "#{serialize(key.to_sym)} #{serialize(entry, in_content_stream)}" }.join("\n")}>>"
|
|
72
|
+
when Reference then value.to_s
|
|
73
|
+
when Serializable then serialize(value.to_hash, in_content_stream)
|
|
74
|
+
else raise Prawn::Errors::FailedObjectConversion, "This object cannot be serialized to PDF (#{value.inspect})"
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# PDF::Core's name for serialize, which code written for Prawn calls, prawn-svg among it.
|
|
79
|
+
def pdf_object(object, in_content_stream = false)
|
|
80
|
+
serialize(object, in_content_stream)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def utf16be(string)
|
|
84
|
+
"\xFE\xFF".b + string.encode(::Encoding::UTF_16BE).b
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Gamba
|
|
4
|
+
module PdfA
|
|
5
|
+
# An ICC version 2 display profile for sRGB (IEC 61966-2.1), built from the standard's primaries adapted to D50
|
|
6
|
+
# (ICC.1:2001-04): PDF/A needs one as output intent, and building it avoids shipping a third-party file.
|
|
7
|
+
module SrgbProfile
|
|
8
|
+
DESCRIPTION = "sRGB IEC61966-2.1"
|
|
9
|
+
D50_WHITE = [0.9642, 1.0, 0.8249].freeze
|
|
10
|
+
# sRGB primaries, Bradford-adapted from D65 to D50.
|
|
11
|
+
RED = [0.4361, 0.2225, 0.0139].freeze
|
|
12
|
+
GREEN = [0.3851, 0.7169, 0.0971].freeze
|
|
13
|
+
BLUE = [0.1431, 0.0606, 0.7141].freeze
|
|
14
|
+
# The sRGB tone curve, approximated by a 2.2 gamma, as u8Fixed8Number.
|
|
15
|
+
GAMMA = 0x0233
|
|
16
|
+
|
|
17
|
+
module_function
|
|
18
|
+
|
|
19
|
+
def bytes
|
|
20
|
+
@bytes ||= begin
|
|
21
|
+
curve = "curv".b + "\x00" * 4 + [1, GAMMA].pack("Nn") + "\x00\x00".b
|
|
22
|
+
tags = [
|
|
23
|
+
["desc", text_description(DESCRIPTION)],
|
|
24
|
+
["cprt", "text".b + ("\x00" * 4) + "No copyright, use freely\x00".b],
|
|
25
|
+
["wtpt", xyz(D50_WHITE)],
|
|
26
|
+
["rXYZ", xyz(RED)], ["gXYZ", xyz(GREEN)], ["bXYZ", xyz(BLUE)],
|
|
27
|
+
["rTRC", curve], ["gTRC", curve], ["bTRC", curve]
|
|
28
|
+
]
|
|
29
|
+
table_size = 4 + (tags.size * 12)
|
|
30
|
+
offset = 128 + table_size
|
|
31
|
+
directory = [tags.size].pack("N")
|
|
32
|
+
data = +"".b
|
|
33
|
+
tags.each do |signature, tag|
|
|
34
|
+
directory << [signature, offset + data.bytesize, tag.bytesize].pack("a4N2")
|
|
35
|
+
data << tag << ("\x00" * ((4 - (tag.bytesize % 4)) % 4))
|
|
36
|
+
end
|
|
37
|
+
body = directory + data
|
|
38
|
+
header(128 + body.bytesize) + body
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def header(size)
|
|
43
|
+
[size, "", 0x02100000, "mntr", "RGB ", "XYZ ", 2026, 1, 1, 0, 0, 0, "acsp", "APPL", 0, "", "", 0, 0,
|
|
44
|
+
*D50_WHITE.map { s15_fixed16(_1) }, ""].pack("N a4 N a4 a4 a4 n6 a4 a4 N a4 a4 Q> N N3 a4") + ("\x00" * 44)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def xyz(values)
|
|
48
|
+
"XYZ ".b + ("\x00" * 4) + values.map { s15_fixed16(_1) }.pack("N3")
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def s15_fixed16(value)
|
|
52
|
+
(value * 65_536).round & 0xFFFFFFFF
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def text_description(text)
|
|
56
|
+
ascii = "#{text}\x00".b
|
|
57
|
+
"desc".b + ("\x00" * 4) + [ascii.bytesize].pack("N") + ascii + [0, 0, 0].pack("N2n") + ("\x00" * 67)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/gamba/pdf_a.rb
ADDED
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "time"
|
|
4
|
+
|
|
5
|
+
module Prawn
|
|
6
|
+
module Errors
|
|
7
|
+
PdfAViolation = Class.new(StandardError)
|
|
8
|
+
end
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
module Gamba
|
|
12
|
+
# PDF/A-3b (ISO 19005-3), switched on with Prawn::Document.new(pdf_a: true): XMP metadata matching the
|
|
13
|
+
# document information, an sRGB output intent, a file identifier, and errors for what PDF/A forbids.
|
|
14
|
+
# Factur-X invoices (pdf.factur_x) embed their CII XML into such a document.
|
|
15
|
+
module PdfA
|
|
16
|
+
FACTUR_X_NAMESPACE = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0#"
|
|
17
|
+
FACTUR_X_FILENAME = "factur-x.xml"
|
|
18
|
+
# Profile names of the Factur-X specification, and the relationship it gives the XML of each (§ 6.2.2).
|
|
19
|
+
FACTUR_X_LEVEL_BY_PROFILE = {
|
|
20
|
+
minimum: "MINIMUM", basic_wl: "BASIC WL", basic: "BASIC", en16931: "EN 16931", extended: "EXTENDED"
|
|
21
|
+
}.freeze
|
|
22
|
+
RELATIONSHIP_BY_PROFILE = {
|
|
23
|
+
minimum: :Data, basic_wl: :Data, basic: :Alternative, en16931: :Alternative, extended: :Alternative
|
|
24
|
+
}.freeze
|
|
25
|
+
# Annotation flag 3, Print: PDF/A requires printing every annotation (ISO 19005-2 §6.3.2).
|
|
26
|
+
PRINT_FLAG = 4
|
|
27
|
+
XMP_PROPERTY_BY_INFO_KEY = {
|
|
28
|
+
Title: "dc:title", Author: "dc:creator", Subject: "dc:description", Keywords: "pdf:Keywords",
|
|
29
|
+
Creator: "xmp:CreatorTool", Producer: "pdf:Producer", CreationDate: "xmp:CreateDate", ModDate: "xmp:ModifyDate"
|
|
30
|
+
}.freeze
|
|
31
|
+
|
|
32
|
+
def pdf_a?
|
|
33
|
+
@pdf_a == true
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def factur_x(xml, profile: :en16931, filename: FACTUR_X_FILENAME, description: "Factur-X invoice")
|
|
37
|
+
raise Prawn::Errors::PdfAViolation, "Factur-X invoices are PDF/A-3 documents: create this one with pdf_a: true" unless pdf_a?
|
|
38
|
+
|
|
39
|
+
level = FACTUR_X_LEVEL_BY_PROFILE.fetch(profile) { raise ArgumentError, "Unknown Factur-X profile #{profile.inspect}: #{FACTUR_X_LEVEL_BY_PROFILE.keys.join(", ")}" }
|
|
40
|
+
@factur_x = {xml: xml.b, level:, relationship: RELATIONSHIP_BY_PROFILE.fetch(profile), filename:, description:}
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def finalize_pdf_a
|
|
46
|
+
min_version(1.7)
|
|
47
|
+
icc_profile = ref!({N: 3}, PdfA::SrgbProfile.bytes)
|
|
48
|
+
@root.data[:OutputIntents] = [{
|
|
49
|
+
Type: :OutputIntent, S: :GTS_PDFA1, OutputConditionIdentifier: PDF::Core::LiteralString.new(SrgbProfile::DESCRIPTION),
|
|
50
|
+
Info: PDF::Core::LiteralString.new(SrgbProfile::DESCRIPTION), DestOutputProfile: icc_profile
|
|
51
|
+
}]
|
|
52
|
+
embed_factur_x if @factur_x
|
|
53
|
+
metadata = ref!({Type: :Metadata, Subtype: :XML}, xmp_packet)
|
|
54
|
+
metadata.uncompressed = true
|
|
55
|
+
@root.data[:Metadata] = metadata
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def embed_factur_x
|
|
59
|
+
xml = @factur_x[:xml]
|
|
60
|
+
modification_date = @info.data[:ModDate] || @info.data[:CreationDate]
|
|
61
|
+
params = {Size: xml.bytesize}
|
|
62
|
+
params[:ModDate] = modification_date if modification_date
|
|
63
|
+
embedded_file = ref!({Type: :EmbeddedFile, Subtype: :"text/xml", Params: params}, xml)
|
|
64
|
+
file_specification = ref!(
|
|
65
|
+
Type: :Filespec, F: PDF::Core::LiteralString.new(@factur_x[:filename]), UF: @factur_x[:filename],
|
|
66
|
+
Desc: @factur_x[:description], AFRelationship: @factur_x[:relationship], EF: {F: embedded_file, UF: embedded_file}
|
|
67
|
+
)
|
|
68
|
+
@root.data[:AF] = [file_specification]
|
|
69
|
+
(names.data[:EmbeddedFiles] ||= ref!(PDF::Core::NameTree.new)).data.add(@factur_x[:filename], file_specification)
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def xmp_packet
|
|
73
|
+
properties = XMP_PROPERTY_BY_INFO_KEY.filter_map do |key, property|
|
|
74
|
+
value = @info.data[key]
|
|
75
|
+
next unless value
|
|
76
|
+
|
|
77
|
+
text = value.is_a?(Time) ? value.iso8601 : escape_xml(value.to_s)
|
|
78
|
+
case property
|
|
79
|
+
when "dc:title", "dc:description" then "<#{property}><rdf:Alt><rdf:li xml:lang=\"x-default\">#{text}</rdf:li></rdf:Alt></#{property}>"
|
|
80
|
+
when "dc:creator" then "<#{property}><rdf:Seq><rdf:li>#{text}</rdf:li></rdf:Seq></#{property}>"
|
|
81
|
+
else "<#{property}>#{text}</#{property}>"
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
<<~XMP
|
|
85
|
+
<?xpacket begin="\u{FEFF}" id="W5M0MpCehiHzreSzNTczkc9d"?>
|
|
86
|
+
<x:xmpmeta xmlns:x="adobe:ns:meta/">
|
|
87
|
+
<rdf:RDF xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">
|
|
88
|
+
<rdf:Description rdf:about="" xmlns:pdfaid="http://www.aiim.org/pdfa/ns/id/" xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:pdf="http://ns.adobe.com/pdf/1.3/" xmlns:xmp="http://ns.adobe.com/xap/1.0/">
|
|
89
|
+
<pdfaid:part>3</pdfaid:part>
|
|
90
|
+
<pdfaid:conformance>B</pdfaid:conformance>
|
|
91
|
+
#{properties.join("\n")}
|
|
92
|
+
</rdf:Description>
|
|
93
|
+
#{factur_x_xmp}
|
|
94
|
+
</rdf:RDF>
|
|
95
|
+
</x:xmpmeta>
|
|
96
|
+
<?xpacket end="w"?>
|
|
97
|
+
XMP
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# The Factur-X properties, and the PDF/A extension schema declaring them (Factur-X specification § 6.2.1).
|
|
101
|
+
def factur_x_xmp
|
|
102
|
+
return "" unless @factur_x
|
|
103
|
+
|
|
104
|
+
fields = {DocumentFileName: "name of the embedded XML invoice file", DocumentType: "INVOICE",
|
|
105
|
+
Version: "The actual version of the Factur-X XML schema", ConformanceLevel: "The conformance level of the embedded Factur-X data"}
|
|
106
|
+
described = fields.map do |name, description|
|
|
107
|
+
"<rdf:li rdf:parseType=\"Resource\"><pdfaProperty:name>#{name}</pdfaProperty:name><pdfaProperty:valueType>Text</pdfaProperty:valueType>" \
|
|
108
|
+
"<pdfaProperty:category>external</pdfaProperty:category><pdfaProperty:description>#{description}</pdfaProperty:description></rdf:li>"
|
|
109
|
+
end
|
|
110
|
+
<<~XMP
|
|
111
|
+
<rdf:Description rdf:about="" xmlns:fx="#{FACTUR_X_NAMESPACE}">
|
|
112
|
+
<fx:DocumentType>INVOICE</fx:DocumentType>
|
|
113
|
+
<fx:DocumentFileName>#{escape_xml(@factur_x[:filename])}</fx:DocumentFileName>
|
|
114
|
+
<fx:Version>1.0</fx:Version>
|
|
115
|
+
<fx:ConformanceLevel>#{@factur_x[:level]}</fx:ConformanceLevel>
|
|
116
|
+
</rdf:Description>
|
|
117
|
+
<rdf:Description rdf:about="" xmlns:pdfaExtension="http://www.aiim.org/pdfa/ns/extension/" xmlns:pdfaSchema="http://www.aiim.org/pdfa/ns/schema#" xmlns:pdfaProperty="http://www.aiim.org/pdfa/ns/property#">
|
|
118
|
+
<pdfaExtension:schemas><rdf:Bag><rdf:li rdf:parseType="Resource">
|
|
119
|
+
<pdfaSchema:schema>Factur-X PDFA Extension Schema</pdfaSchema:schema>
|
|
120
|
+
<pdfaSchema:namespaceURI>#{FACTUR_X_NAMESPACE}</pdfaSchema:namespaceURI>
|
|
121
|
+
<pdfaSchema:prefix>fx</pdfaSchema:prefix>
|
|
122
|
+
<pdfaSchema:property><rdf:Seq>#{described.join}</rdf:Seq></pdfaSchema:property>
|
|
123
|
+
</rdf:li></rdf:Bag></pdfaExtension:schemas>
|
|
124
|
+
</rdf:Description>
|
|
125
|
+
XMP
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def escape_xml(text)
|
|
129
|
+
text.encode(xml: :text)
|
|
130
|
+
end
|
|
131
|
+
end
|
|
132
|
+
end
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prawn
|
|
4
|
+
class Document
|
|
5
|
+
def repeat(page_filter, options = {}, &)
|
|
6
|
+
repeaters << Repeater.new(self, page_filter, options.fetch(:dynamic, false), &)
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def number_pages(string, options = {})
|
|
10
|
+
options = options.dup
|
|
11
|
+
start_count_at = options.delete(:start_count_at)
|
|
12
|
+
page_filter = options.key?(:page_filter) ? options.delete(:page_filter) : :all
|
|
13
|
+
total_pages = options.delete(:total_pages)
|
|
14
|
+
text_color = options.delete(:color)
|
|
15
|
+
options[:height] = 50 unless options.key?(:height)
|
|
16
|
+
|
|
17
|
+
counting = false
|
|
18
|
+
pseudo_page = 0
|
|
19
|
+
(1..page_count).each do |page_number|
|
|
20
|
+
unless counting
|
|
21
|
+
pseudo_page =
|
|
22
|
+
case start_count_at
|
|
23
|
+
when String then Integer(start_count_at, 10)
|
|
24
|
+
when (1..) then Integer(start_count_at)
|
|
25
|
+
else 1
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
if page_match?(page_filter, page_number)
|
|
29
|
+
go_to_page(page_number)
|
|
30
|
+
fill_color(text_color) unless text_color.nil?
|
|
31
|
+
total_pages = page_count if total_pages.nil?
|
|
32
|
+
text_box(string.gsub("<page>", pseudo_page.to_s).gsub("<total>", total_pages.to_s), options)
|
|
33
|
+
counting = true
|
|
34
|
+
end
|
|
35
|
+
pseudo_page += 1 if counting
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Content drawn on every page matching a filter when the document renders: stamped once, or drawn again on
|
|
41
|
+
# each page when dynamic.
|
|
42
|
+
class Repeater
|
|
43
|
+
class << self
|
|
44
|
+
attr_writer :count
|
|
45
|
+
|
|
46
|
+
def count
|
|
47
|
+
@count ||= 0
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
attr_reader :name
|
|
52
|
+
|
|
53
|
+
def initialize(document, page_filter, dynamic = false, &block)
|
|
54
|
+
@document = document
|
|
55
|
+
@page_filter = page_filter
|
|
56
|
+
@dynamic = dynamic
|
|
57
|
+
@stamp_name = "prawn_repeater(#{document.repeaters.size})"
|
|
58
|
+
@document.create_stamp(@stamp_name, &block) unless dynamic
|
|
59
|
+
@block = block if dynamic
|
|
60
|
+
@graphic_state = document.page.graphic_state.dup
|
|
61
|
+
Repeater.count += 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def match?(page_number)
|
|
65
|
+
@document.page_match?(@page_filter, page_number)
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def run(page_number)
|
|
69
|
+
return unless match?(page_number)
|
|
70
|
+
return @document.stamp(@stamp_name) unless @dynamic
|
|
71
|
+
return unless @block
|
|
72
|
+
|
|
73
|
+
@document.save_graphics_state(@graphic_state) do
|
|
74
|
+
@document.__send__(:freeze_stamp_graphics)
|
|
75
|
+
@block.call
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
79
|
+
end
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest/md5"
|
|
4
|
+
|
|
5
|
+
module Prawn
|
|
6
|
+
class Document
|
|
7
|
+
# The standard security handler, revision 2: 40-bit RC4 (ISO 32000-1 §7.6.3).
|
|
8
|
+
module Security
|
|
9
|
+
PERMISSIONS_BITS = {print_document: 3, modify_contents: 4, copy_contents: 5, modify_annotations: 6}.freeze
|
|
10
|
+
FULL_PERMISSIONS = 0xFFFFFFFF
|
|
11
|
+
PASSWORD_PADDING = ["28BF4E5E4E758A4164004E56FFFA01082E2E00B6D0683E802F0CA9FE6453697A"].pack("H*").freeze
|
|
12
|
+
KEY_LENGTH_BYTES = 5
|
|
13
|
+
private_constant :PERMISSIONS_BITS, :FULL_PERMISSIONS
|
|
14
|
+
|
|
15
|
+
def encrypt_document(options = {})
|
|
16
|
+
raise Errors::PdfAViolation, "PDF/A forbids encryption" if pdf_a?
|
|
17
|
+
|
|
18
|
+
Prawn.verify_options(%i[user_password owner_password permissions], options)
|
|
19
|
+
@user_password = options.delete(:user_password) || ""
|
|
20
|
+
@owner_password = options.delete(:owner_password) || @user_password
|
|
21
|
+
@owner_password = Random.bytes(32) if @owner_password == :random
|
|
22
|
+
self.permissions = options.delete(:permissions) || {}
|
|
23
|
+
@encryption = Encryption.new(user_password: @user_password, owner_password: @owner_password, permissions: permissions_value)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def self.encrypt_string(string, key, identifier, generation)
|
|
27
|
+
Arcfour.new(object_key(key, identifier, generation)).encrypt(string)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
# The key of one object: the document key extended with its number and generation (algorithm 1), and with
|
|
31
|
+
# "sAlT" for AES-128, which only PDF::Core::Decryption reads.
|
|
32
|
+
def self.object_key(key, identifier, generation, salt = nil)
|
|
33
|
+
seed = key + [identifier].pack("V")[0, 3] + [generation].pack("V")[0, 2]
|
|
34
|
+
seed << salt if salt
|
|
35
|
+
Digest::MD5.digest(seed)[0, key.bytesize + 5]
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def self.pad_password(password)
|
|
39
|
+
password = password.b[0, 32]
|
|
40
|
+
password + PASSWORD_PADDING[0, 32 - password.bytesize]
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def permissions=(permissions = {})
|
|
46
|
+
@permissions ||= FULL_PERMISSIONS
|
|
47
|
+
permissions.each do |key, value|
|
|
48
|
+
raise ArgumentError, "Unknown permission :#{key}. Valid options: #{PERMISSIONS_BITS.keys.map(&:inspect).join(", ")}" unless PERMISSIONS_BITS[key]
|
|
49
|
+
|
|
50
|
+
bit = 1 << (PERMISSIONS_BITS[key] - 1)
|
|
51
|
+
@permissions = value ? @permissions | bit : @permissions & ~bit
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def permissions_value
|
|
56
|
+
@permissions || FULL_PERMISSIONS
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
# The keys and dictionary of an encrypted document, which depend on its file identifier.
|
|
61
|
+
class Encryption
|
|
62
|
+
def initialize(user_password:, owner_password:, permissions:)
|
|
63
|
+
@user_password = user_password
|
|
64
|
+
@owner_password = owner_password
|
|
65
|
+
@permissions = permissions
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def owner_password_hash
|
|
69
|
+
@owner_password_hash ||= begin
|
|
70
|
+
key = Digest::MD5.digest(Security.pad_password(@owner_password))[0, Security::KEY_LENGTH_BYTES]
|
|
71
|
+
Arcfour.new(key).encrypt(Security.pad_password(@user_password))
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
def key(file_identifier)
|
|
76
|
+
Digest::MD5.digest(Security.pad_password(@user_password) + owner_password_hash + [@permissions].pack("V") + file_identifier)[0, Security::KEY_LENGTH_BYTES]
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def dictionary(file_identifier)
|
|
80
|
+
user_password_hash = Arcfour.new(key(file_identifier)).encrypt(Security::PASSWORD_PADDING)
|
|
81
|
+
{Filter: :Standard, V: 1, R: 2, O: PDF::Core::ByteString.new(owner_password_hash), U: PDF::Core::ByteString.new(user_password_hash),
|
|
82
|
+
P: [@permissions].pack("L").unpack1("l")}
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
# RC4, the stream cipher of the standard security handler's first revisions.
|
|
87
|
+
class Arcfour
|
|
88
|
+
def initialize(key)
|
|
89
|
+
@state = (0..255).to_a
|
|
90
|
+
j = 0
|
|
91
|
+
key_bytes = key.bytes
|
|
92
|
+
256.times do |i|
|
|
93
|
+
j = (j + @state[i] + key_bytes[i % key_bytes.size]) & 0xFF
|
|
94
|
+
@state[i], @state[j] = @state[j], @state[i]
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
def encrypt(string)
|
|
99
|
+
state = @state.dup
|
|
100
|
+
i = 0
|
|
101
|
+
j = 0
|
|
102
|
+
string.b.bytes.map do |byte|
|
|
103
|
+
i = (i + 1) & 0xFF
|
|
104
|
+
j = (j + state[i]) & 0xFF
|
|
105
|
+
state[i], state[j] = state[j], state[i]
|
|
106
|
+
byte ^ state[(state[i] + state[j]) & 0xFF]
|
|
107
|
+
end.pack("C*")
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
111
|
+
end
|
data/lib/gamba/stamp.rb
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Prawn
|
|
4
|
+
# Content drawn once into a form XObject, then placed on any page by name.
|
|
5
|
+
module Stamp
|
|
6
|
+
def stamp(name)
|
|
7
|
+
dictionary_name, dictionary = stamp_dictionary(name)
|
|
8
|
+
add_content("/#{dictionary_name} Do")
|
|
9
|
+
annotations = dictionary.data[:Annots]
|
|
10
|
+
page.dictionary.data[:Annots] = (page.dictionary.data[:Annots] || []) | annotations if annotations&.any?
|
|
11
|
+
page.xobjects[dictionary_name] = dictionary
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def stamp_at(name, point, &)
|
|
15
|
+
translate(point[0], point[1]) { stamp(name) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def create_stamp(name, &)
|
|
19
|
+
page.stamp_stream(create_stamp_dictionary(name), &)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def stamp_dictionary_registry
|
|
25
|
+
@stamp_dictionary_registry ||= {}
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def stamp_dictionary(name)
|
|
29
|
+
raise Errors::InvalidName if name.empty?
|
|
30
|
+
raise Errors::UndefinedObjectName unless stamp_dictionary_registry.key?(name)
|
|
31
|
+
|
|
32
|
+
stamp_dictionary_registry[name].values_at(:stamp_dictionary_name, :stamp_dictionary)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def create_stamp_dictionary(name)
|
|
36
|
+
raise Errors::InvalidName if name.empty?
|
|
37
|
+
raise Errors::NameTaken if stamp_dictionary_registry.key?(name)
|
|
38
|
+
|
|
39
|
+
_left, _bottom, width, height = page.dimensions
|
|
40
|
+
dictionary = ref!({Type: :XObject, Subtype: :Form, BBox: [0, 0, width, height]}, +"")
|
|
41
|
+
stamp_dictionary_registry[name] = {stamp_dictionary_name: "Stamp#{stamp_dictionary_registry.length + 1}", stamp_dictionary: dictionary}
|
|
42
|
+
dictionary
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def freeze_stamp_graphics
|
|
46
|
+
update_colors
|
|
47
|
+
write_line_width
|
|
48
|
+
write_stroke_cap_style
|
|
49
|
+
write_stroke_join_style
|
|
50
|
+
write_stroke_dash
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
module SoftMask
|
|
55
|
+
def soft_mask(&)
|
|
56
|
+
min_version(1.4)
|
|
57
|
+
group_attributes = ref!(Type: :Group, S: :Transparency, CS: :DeviceRGB, I: false, K: false)
|
|
58
|
+
group = ref!({Type: :XObject, Subtype: :Form, BBox: page.dimensions, Group: group_attributes}, +"")
|
|
59
|
+
page.stamp_stream(group, &)
|
|
60
|
+
mask = ref!(Type: :Mask, S: :Luminosity, G: group)
|
|
61
|
+
graphic_state_dictionary = ref!(Type: :ExtGState, SMask: mask, AIS: false, BM: :Normal, OP: false, op: false, OPM: 1, SA: true)
|
|
62
|
+
|
|
63
|
+
registry_key = [page.dimensions, group.stream, page_count].hash
|
|
64
|
+
@soft_mask_registry ||= {}
|
|
65
|
+
unless @soft_mask_registry[registry_key]
|
|
66
|
+
masks = page.resources[:ExtGState] ||= {}
|
|
67
|
+
name = masks.empty? ? "GS1" : masks.keys.max.succ
|
|
68
|
+
masks[name] = graphic_state_dictionary
|
|
69
|
+
@soft_mask_registry[registry_key] = name
|
|
70
|
+
end
|
|
71
|
+
add_content("/#{@soft_mask_registry[registry_key]} gs")
|
|
72
|
+
# Its ExtGState sets the blend mode back to Normal.
|
|
73
|
+
graphic_state.blend_mode = nil
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|