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.
Files changed (163) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +80 -0
  3. data/COPYING +2 -0
  4. data/GPLv2 +339 -0
  5. data/GPLv3 +674 -0
  6. data/LICENSE +65 -0
  7. data/LICENSE-prawn-svg +21 -0
  8. data/README.md +148 -0
  9. data/data/fonts/Courier-Bold.afm +342 -0
  10. data/data/fonts/Courier-BoldOblique.afm +342 -0
  11. data/data/fonts/Courier-Oblique.afm +342 -0
  12. data/data/fonts/Courier.afm +342 -0
  13. data/data/fonts/Helvetica-Bold.afm +2827 -0
  14. data/data/fonts/Helvetica-BoldOblique.afm +2827 -0
  15. data/data/fonts/Helvetica-Oblique.afm +3051 -0
  16. data/data/fonts/Helvetica.afm +3051 -0
  17. data/data/fonts/MustRead.html +19 -0
  18. data/data/fonts/Symbol.afm +213 -0
  19. data/data/fonts/Times-Bold.afm +2588 -0
  20. data/data/fonts/Times-BoldItalic.afm +2384 -0
  21. data/data/fonts/Times-Italic.afm +2667 -0
  22. data/data/fonts/Times-Roman.afm +2419 -0
  23. data/data/fonts/ZapfDingbats.afm +225 -0
  24. data/lib/gamba/annotations.rb +21 -0
  25. data/lib/gamba/combine.rb +307 -0
  26. data/lib/gamba/document/bounding_box.rb +193 -0
  27. data/lib/gamba/document/column_box.rb +91 -0
  28. data/lib/gamba/document/internals.rb +264 -0
  29. data/lib/gamba/document/span.rb +25 -0
  30. data/lib/gamba/document.rb +268 -0
  31. data/lib/gamba/encoding.rb +13 -0
  32. data/lib/gamba/errors.rb +39 -0
  33. data/lib/gamba/font.rb +228 -0
  34. data/lib/gamba/fonts/afm.rb +171 -0
  35. data/lib/gamba/fonts/otf.rb +50 -0
  36. data/lib/gamba/fonts/ttf.rb +413 -0
  37. data/lib/gamba/graphic_state.rb +84 -0
  38. data/lib/gamba/graphics/color.rb +174 -0
  39. data/lib/gamba/graphics/patterns.rb +100 -0
  40. data/lib/gamba/graphics/style.rb +159 -0
  41. data/lib/gamba/graphics.rb +212 -0
  42. data/lib/gamba/grid.rb +216 -0
  43. data/lib/gamba/images/image.rb +271 -0
  44. data/lib/gamba/images/png_decoder.rb +65 -0
  45. data/lib/gamba/images.rb +115 -0
  46. data/lib/gamba/measurement_extensions.rb +40 -0
  47. data/lib/gamba/measurements.rb +62 -0
  48. data/lib/gamba/outline.rb +148 -0
  49. data/lib/gamba/page.rb +125 -0
  50. data/lib/gamba/page_geometry.rb +27 -0
  51. data/lib/gamba/pdf/decryption.rb +165 -0
  52. data/lib/gamba/pdf/outline_node.rb +53 -0
  53. data/lib/gamba/pdf/reader.rb +616 -0
  54. data/lib/gamba/pdf/reference.rb +31 -0
  55. data/lib/gamba/pdf/writer.rb +105 -0
  56. data/lib/gamba/pdf.rb +87 -0
  57. data/lib/gamba/pdf_a/srgb_profile.rb +61 -0
  58. data/lib/gamba/pdf_a.rb +132 -0
  59. data/lib/gamba/repeater.rb +79 -0
  60. data/lib/gamba/security.rb +111 -0
  61. data/lib/gamba/stamp.rb +76 -0
  62. data/lib/gamba/table/cell/kinds.rb +246 -0
  63. data/lib/gamba/table/cell.rb +331 -0
  64. data/lib/gamba/table/cells.rb +208 -0
  65. data/lib/gamba/table.rb +298 -0
  66. data/lib/gamba/text/box.rb +16 -0
  67. data/lib/gamba/text/formatted/arranger.rb +217 -0
  68. data/lib/gamba/text/formatted/box.rb +312 -0
  69. data/lib/gamba/text/formatted/fragment.rb +205 -0
  70. data/lib/gamba/text/formatted/line_wrap.rb +205 -0
  71. data/lib/gamba/text/formatted/parser.rb +150 -0
  72. data/lib/gamba/text/formatted/wrap.rb +103 -0
  73. data/lib/gamba/text.rb +310 -0
  74. data/lib/gamba/transformation_stack.rb +43 -0
  75. data/lib/gamba/true_type/cff_subset.rb +157 -0
  76. data/lib/gamba/true_type/file.rb +372 -0
  77. data/lib/gamba/true_type/open_type_layout.rb +189 -0
  78. data/lib/gamba/true_type/simple_subsets.rb +108 -0
  79. data/lib/gamba/true_type/subset.rb +206 -0
  80. data/lib/gamba/version.rb +10 -0
  81. data/lib/gamba/view.rb +28 -0
  82. data/lib/gamba.rb +124 -0
  83. data/lib/pdf/core.rb +4 -0
  84. data/lib/prawn/measurement_extensions.rb +4 -0
  85. data/lib/prawn/svg/attributes/clip_path.rb +46 -0
  86. data/lib/prawn/svg/attributes/mask.rb +16 -0
  87. data/lib/prawn/svg/attributes/opacity.rb +30 -0
  88. data/lib/prawn/svg/attributes/space.rb +10 -0
  89. data/lib/prawn/svg/attributes/stroke.rb +50 -0
  90. data/lib/prawn/svg/attributes/transform.rb +10 -0
  91. data/lib/prawn/svg/attributes.rb +6 -0
  92. data/lib/prawn/svg/calculators/arc_to_bezier_curve.rb +57 -0
  93. data/lib/prawn/svg/calculators/aspect_ratio.rb +60 -0
  94. data/lib/prawn/svg/calculators/document_sizing.rb +103 -0
  95. data/lib/prawn/svg/calculators/path_length.rb +168 -0
  96. data/lib/prawn/svg/calculators/pixels.rb +57 -0
  97. data/lib/prawn/svg/color.rb +240 -0
  98. data/lib/prawn/svg/css/font_face_parser.rb +72 -0
  99. data/lib/prawn/svg/css/font_family_parser.rb +39 -0
  100. data/lib/prawn/svg/css/font_parser.rb +46 -0
  101. data/lib/prawn/svg/css/selector_parser.rb +180 -0
  102. data/lib/prawn/svg/css/stylesheets.rb +233 -0
  103. data/lib/prawn/svg/css/values_parser.rb +68 -0
  104. data/lib/prawn/svg/document.rb +169 -0
  105. data/lib/prawn/svg/elements/anchor.rb +10 -0
  106. data/lib/prawn/svg/elements/base.rb +315 -0
  107. data/lib/prawn/svg/elements/bbox_scaling.rb +78 -0
  108. data/lib/prawn/svg/elements/call_duplicator.rb +37 -0
  109. data/lib/prawn/svg/elements/circle.rb +19 -0
  110. data/lib/prawn/svg/elements/clip_path.rb +26 -0
  111. data/lib/prawn/svg/elements/container.rb +10 -0
  112. data/lib/prawn/svg/elements/direct_render_base.rb +28 -0
  113. data/lib/prawn/svg/elements/ellipse.rb +20 -0
  114. data/lib/prawn/svg/elements/gradient.rb +183 -0
  115. data/lib/prawn/svg/elements/ignored.rb +5 -0
  116. data/lib/prawn/svg/elements/image.rb +178 -0
  117. data/lib/prawn/svg/elements/line.rb +27 -0
  118. data/lib/prawn/svg/elements/marker.rb +72 -0
  119. data/lib/prawn/svg/elements/mask.rb +65 -0
  120. data/lib/prawn/svg/elements/path.rb +294 -0
  121. data/lib/prawn/svg/elements/pattern.rb +150 -0
  122. data/lib/prawn/svg/elements/polygon.rb +25 -0
  123. data/lib/prawn/svg/elements/polyline.rb +23 -0
  124. data/lib/prawn/svg/elements/rect.rb +64 -0
  125. data/lib/prawn/svg/elements/root.rb +22 -0
  126. data/lib/prawn/svg/elements/switch.rb +92 -0
  127. data/lib/prawn/svg/elements/text.rb +99 -0
  128. data/lib/prawn/svg/elements/text_component.rb +352 -0
  129. data/lib/prawn/svg/elements/text_node.rb +308 -0
  130. data/lib/prawn/svg/elements/text_path.rb +153 -0
  131. data/lib/prawn/svg/elements/use.rb +142 -0
  132. data/lib/prawn/svg/elements/viewport.rb +29 -0
  133. data/lib/prawn/svg/elements.rb +46 -0
  134. data/lib/prawn/svg/extension.rb +25 -0
  135. data/lib/prawn/svg/font.rb +20 -0
  136. data/lib/prawn/svg/font_metrics.rb +144 -0
  137. data/lib/prawn/svg/font_registry.rb +315 -0
  138. data/lib/prawn/svg/funciri.rb +14 -0
  139. data/lib/prawn/svg/gradient_renderer.rb +332 -0
  140. data/lib/prawn/svg/gradients.rb +53 -0
  141. data/lib/prawn/svg/interface.rb +72 -0
  142. data/lib/prawn/svg/length.rb +43 -0
  143. data/lib/prawn/svg/link_renderer.rb +40 -0
  144. data/lib/prawn/svg/loaders/data.rb +17 -0
  145. data/lib/prawn/svg/loaders/file.rb +111 -0
  146. data/lib/prawn/svg/loaders/web.rb +24 -0
  147. data/lib/prawn/svg/paint.rb +67 -0
  148. data/lib/prawn/svg/pathable.rb +153 -0
  149. data/lib/prawn/svg/pattern_renderer.rb +112 -0
  150. data/lib/prawn/svg/pdfmatrix.rb +39 -0
  151. data/lib/prawn/svg/percentage.rb +24 -0
  152. data/lib/prawn/svg/properties.rb +329 -0
  153. data/lib/prawn/svg/renderer.rb +249 -0
  154. data/lib/prawn/svg/state.rb +24 -0
  155. data/lib/prawn/svg/transform_parser.rb +78 -0
  156. data/lib/prawn/svg/ttc.rb +35 -0
  157. data/lib/prawn/svg/ttf.rb +95 -0
  158. data/lib/prawn/svg/url_loader.rb +46 -0
  159. data/lib/prawn/svg/version.rb +5 -0
  160. data/lib/prawn/table.rb +4 -0
  161. data/lib/prawn-svg.rb +52 -0
  162. data/lib/prawn.rb +3 -0
  163. 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
@@ -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
@@ -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