prawnx 0.1.1

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 (160) hide show
  1. checksums.yaml +7 -0
  2. data/COPYING +2 -0
  3. data/GPLv2 +339 -0
  4. data/GPLv3 +674 -0
  5. data/LICENSE +65 -0
  6. data/LICENSE-prawn-svg +21 -0
  7. data/README.md +107 -0
  8. data/data/fonts/Courier-Bold.afm +342 -0
  9. data/data/fonts/Courier-BoldOblique.afm +342 -0
  10. data/data/fonts/Courier-Oblique.afm +342 -0
  11. data/data/fonts/Courier.afm +342 -0
  12. data/data/fonts/Helvetica-Bold.afm +2827 -0
  13. data/data/fonts/Helvetica-BoldOblique.afm +2827 -0
  14. data/data/fonts/Helvetica-Oblique.afm +3051 -0
  15. data/data/fonts/Helvetica.afm +3051 -0
  16. data/data/fonts/MustRead.html +19 -0
  17. data/data/fonts/Symbol.afm +213 -0
  18. data/data/fonts/Times-Bold.afm +2588 -0
  19. data/data/fonts/Times-BoldItalic.afm +2384 -0
  20. data/data/fonts/Times-Italic.afm +2667 -0
  21. data/data/fonts/Times-Roman.afm +2419 -0
  22. data/data/fonts/ZapfDingbats.afm +225 -0
  23. data/lib/pdf/core.rb +4 -0
  24. data/lib/prawn/measurement_extensions.rb +4 -0
  25. data/lib/prawn/svg/attributes/clip_path.rb +46 -0
  26. data/lib/prawn/svg/attributes/mask.rb +16 -0
  27. data/lib/prawn/svg/attributes/opacity.rb +30 -0
  28. data/lib/prawn/svg/attributes/space.rb +10 -0
  29. data/lib/prawn/svg/attributes/stroke.rb +50 -0
  30. data/lib/prawn/svg/attributes/transform.rb +10 -0
  31. data/lib/prawn/svg/attributes.rb +6 -0
  32. data/lib/prawn/svg/calculators/arc_to_bezier_curve.rb +57 -0
  33. data/lib/prawn/svg/calculators/aspect_ratio.rb +60 -0
  34. data/lib/prawn/svg/calculators/document_sizing.rb +103 -0
  35. data/lib/prawn/svg/calculators/path_length.rb +168 -0
  36. data/lib/prawn/svg/calculators/pixels.rb +57 -0
  37. data/lib/prawn/svg/color.rb +240 -0
  38. data/lib/prawn/svg/css/font_face_parser.rb +72 -0
  39. data/lib/prawn/svg/css/font_family_parser.rb +39 -0
  40. data/lib/prawn/svg/css/font_parser.rb +46 -0
  41. data/lib/prawn/svg/css/selector_parser.rb +178 -0
  42. data/lib/prawn/svg/css/stylesheets.rb +233 -0
  43. data/lib/prawn/svg/css/values_parser.rb +68 -0
  44. data/lib/prawn/svg/document.rb +170 -0
  45. data/lib/prawn/svg/elements/anchor.rb +10 -0
  46. data/lib/prawn/svg/elements/base.rb +312 -0
  47. data/lib/prawn/svg/elements/bbox_scaling.rb +78 -0
  48. data/lib/prawn/svg/elements/call_duplicator.rb +37 -0
  49. data/lib/prawn/svg/elements/circle.rb +25 -0
  50. data/lib/prawn/svg/elements/clip_path.rb +26 -0
  51. data/lib/prawn/svg/elements/container.rb +10 -0
  52. data/lib/prawn/svg/elements/direct_render_base.rb +27 -0
  53. data/lib/prawn/svg/elements/ellipse.rb +22 -0
  54. data/lib/prawn/svg/elements/gradient.rb +184 -0
  55. data/lib/prawn/svg/elements/ignored.rb +5 -0
  56. data/lib/prawn/svg/elements/image.rb +177 -0
  57. data/lib/prawn/svg/elements/line.rb +27 -0
  58. data/lib/prawn/svg/elements/marker.rb +72 -0
  59. data/lib/prawn/svg/elements/mask.rb +65 -0
  60. data/lib/prawn/svg/elements/path.rb +294 -0
  61. data/lib/prawn/svg/elements/pattern.rb +150 -0
  62. data/lib/prawn/svg/elements/polygon.rb +25 -0
  63. data/lib/prawn/svg/elements/polyline.rb +23 -0
  64. data/lib/prawn/svg/elements/rect.rb +64 -0
  65. data/lib/prawn/svg/elements/root.rb +22 -0
  66. data/lib/prawn/svg/elements/switch.rb +92 -0
  67. data/lib/prawn/svg/elements/text.rb +99 -0
  68. data/lib/prawn/svg/elements/text_component.rb +346 -0
  69. data/lib/prawn/svg/elements/text_node.rb +308 -0
  70. data/lib/prawn/svg/elements/text_path.rb +153 -0
  71. data/lib/prawn/svg/elements/use.rb +142 -0
  72. data/lib/prawn/svg/elements/viewport.rb +29 -0
  73. data/lib/prawn/svg/elements.rb +46 -0
  74. data/lib/prawn/svg/extension.rb +25 -0
  75. data/lib/prawn/svg/font.rb +20 -0
  76. data/lib/prawn/svg/font_metrics.rb +144 -0
  77. data/lib/prawn/svg/font_registry.rb +315 -0
  78. data/lib/prawn/svg/funciri.rb +14 -0
  79. data/lib/prawn/svg/gradient_renderer.rb +332 -0
  80. data/lib/prawn/svg/gradients.rb +53 -0
  81. data/lib/prawn/svg/interface.rb +72 -0
  82. data/lib/prawn/svg/length.rb +43 -0
  83. data/lib/prawn/svg/link_renderer.rb +40 -0
  84. data/lib/prawn/svg/loaders/data.rb +17 -0
  85. data/lib/prawn/svg/loaders/file.rb +111 -0
  86. data/lib/prawn/svg/loaders/web.rb +24 -0
  87. data/lib/prawn/svg/paint.rb +67 -0
  88. data/lib/prawn/svg/pathable.rb +153 -0
  89. data/lib/prawn/svg/pattern_renderer.rb +112 -0
  90. data/lib/prawn/svg/pdfmatrix.rb +37 -0
  91. data/lib/prawn/svg/percentage.rb +24 -0
  92. data/lib/prawn/svg/properties.rb +329 -0
  93. data/lib/prawn/svg/renderer.rb +249 -0
  94. data/lib/prawn/svg/state.rb +24 -0
  95. data/lib/prawn/svg/transform_parser.rb +78 -0
  96. data/lib/prawn/svg/ttc.rb +35 -0
  97. data/lib/prawn/svg/ttf.rb +95 -0
  98. data/lib/prawn/svg/url_loader.rb +46 -0
  99. data/lib/prawn/svg/version.rb +5 -0
  100. data/lib/prawn/table.rb +4 -0
  101. data/lib/prawn-svg.rb +53 -0
  102. data/lib/prawn.rb +10 -0
  103. data/lib/prawnx/annotations.rb +21 -0
  104. data/lib/prawnx/document/bounding_box.rb +193 -0
  105. data/lib/prawnx/document/column_box.rb +91 -0
  106. data/lib/prawnx/document/internals.rb +144 -0
  107. data/lib/prawnx/document/span.rb +25 -0
  108. data/lib/prawnx/document.rb +253 -0
  109. data/lib/prawnx/encoding.rb +29 -0
  110. data/lib/prawnx/errors.rb +37 -0
  111. data/lib/prawnx/font.rb +214 -0
  112. data/lib/prawnx/fonts/afm.rb +171 -0
  113. data/lib/prawnx/fonts/otf.rb +50 -0
  114. data/lib/prawnx/fonts/ttf.rb +410 -0
  115. data/lib/prawnx/graphic_state.rb +41 -0
  116. data/lib/prawnx/graphics/color.rb +148 -0
  117. data/lib/prawnx/graphics/patterns.rb +100 -0
  118. data/lib/prawnx/graphics/style.rb +171 -0
  119. data/lib/prawnx/graphics.rb +208 -0
  120. data/lib/prawnx/grid.rb +216 -0
  121. data/lib/prawnx/images/image.rb +244 -0
  122. data/lib/prawnx/images/png_decoder.rb +65 -0
  123. data/lib/prawnx/images.rb +115 -0
  124. data/lib/prawnx/measurement_extensions.rb +40 -0
  125. data/lib/prawnx/measurements.rb +62 -0
  126. data/lib/prawnx/outline.rb +146 -0
  127. data/lib/prawnx/page.rb +125 -0
  128. data/lib/prawnx/page_geometry.rb +27 -0
  129. data/lib/prawnx/pdf/outline_node.rb +53 -0
  130. data/lib/prawnx/pdf/reference.rb +31 -0
  131. data/lib/prawnx/pdf/writer.rb +105 -0
  132. data/lib/prawnx/pdf.rb +66 -0
  133. data/lib/prawnx/pdf_a/srgb_profile.rb +61 -0
  134. data/lib/prawnx/pdf_a.rb +130 -0
  135. data/lib/prawnx/pdf_core.rb +25 -0
  136. data/lib/prawnx/repeater.rb +79 -0
  137. data/lib/prawnx/security.rb +108 -0
  138. data/lib/prawnx/stamp.rb +74 -0
  139. data/lib/prawnx/table/cell/kinds.rb +246 -0
  140. data/lib/prawnx/table/cell.rb +331 -0
  141. data/lib/prawnx/table/cells.rb +208 -0
  142. data/lib/prawnx/table.rb +296 -0
  143. data/lib/prawnx/text/box.rb +16 -0
  144. data/lib/prawnx/text/formatted/arranger.rb +217 -0
  145. data/lib/prawnx/text/formatted/box.rb +312 -0
  146. data/lib/prawnx/text/formatted/fragment.rb +205 -0
  147. data/lib/prawnx/text/formatted/line_wrap.rb +204 -0
  148. data/lib/prawnx/text/formatted/parser.rb +149 -0
  149. data/lib/prawnx/text/formatted/wrap.rb +103 -0
  150. data/lib/prawnx/text.rb +281 -0
  151. data/lib/prawnx/transformation_stack.rb +43 -0
  152. data/lib/prawnx/true_type/cff_subset.rb +157 -0
  153. data/lib/prawnx/true_type/file.rb +371 -0
  154. data/lib/prawnx/true_type/open_type_layout.rb +189 -0
  155. data/lib/prawnx/true_type/simple_subsets.rb +94 -0
  156. data/lib/prawnx/true_type/subset.rb +187 -0
  157. data/lib/prawnx/version.rb +7 -0
  158. data/lib/prawnx/view.rb +28 -0
  159. data/lib/prawnx.rb +102 -0
  160. metadata +251 -0
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ # Conversions between metric and imperial units and PDF points (1/72 inch).
5
+ module Measurements
6
+ def cm2mm(cm)
7
+ cm * 10
8
+ end
9
+
10
+ def dm2mm(dm)
11
+ dm * 100
12
+ end
13
+
14
+ def m2mm(m)
15
+ m * 1000
16
+ end
17
+
18
+ def ft2in(ft)
19
+ ft * 12
20
+ end
21
+
22
+ def yd2in(yd)
23
+ yd * 36
24
+ end
25
+
26
+ def pt2pt(pt)
27
+ pt
28
+ end
29
+
30
+ def in2pt(inch)
31
+ inch * 72
32
+ end
33
+
34
+ def ft2pt(ft)
35
+ in2pt(ft2in(ft))
36
+ end
37
+
38
+ def yd2pt(yd)
39
+ in2pt(yd2in(yd))
40
+ end
41
+
42
+ def mm2pt(mm)
43
+ mm * (72 / 25.4)
44
+ end
45
+
46
+ def cm2pt(cm)
47
+ mm2pt(cm2mm(cm))
48
+ end
49
+
50
+ def dm2pt(dm)
51
+ mm2pt(dm2mm(dm))
52
+ end
53
+
54
+ def m2pt(m)
55
+ mm2pt(m2mm(m))
56
+ end
57
+
58
+ def pt2mm(pt)
59
+ pt * 1 / mm2pt(1)
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,146 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ class Document
5
+ def outline
6
+ @outline ||= Outline.new(self)
7
+ end
8
+ end
9
+
10
+ # The document outline (bookmarks): sections holding pages and subsections, each pointing at a page.
11
+ class Outline
12
+ attr_accessor :parent, :prev, :document, :items
13
+
14
+ def initialize(document)
15
+ @document = document
16
+ @parent = root
17
+ @prev = nil
18
+ @items = {}
19
+ end
20
+
21
+ def page_number
22
+ @document.page_number
23
+ end
24
+
25
+ def define(&block)
26
+ instance_eval(&block) if block
27
+ end
28
+ alias update define
29
+
30
+ def add_subsection_to(title, position = :last, &)
31
+ @parent = items[title] || raise(Errors::UnknownOutlineTitle, "\n No outline item with title: '#{title}' exists in the outline tree")
32
+ @prev = position == :first ? nil : @parent.data.last
33
+ insert_section(position == :first ? @parent.data.first : nil, &)
34
+ end
35
+
36
+ def insert_section_after(title, &)
37
+ @prev = items[title] || raise(Errors::UnknownOutlineTitle, "\n No outline item with title: '#{title}' exists in the outline tree")
38
+ @parent = @prev.data.parent
39
+ insert_section(@prev.data.next, &)
40
+ end
41
+
42
+ def section(title, options = {}, &)
43
+ add_outline_item(title, options, &)
44
+ end
45
+
46
+ def page(options = {})
47
+ raise Errors::RequiredOption, "\nTitle is a required option for page" unless options[:title]
48
+
49
+ add_outline_item(options[:title], options)
50
+ end
51
+
52
+ private
53
+
54
+ def root
55
+ document.outlines_reference
56
+ end
57
+
58
+ def add_outline_item(title, options, &block)
59
+ outline_item = create_outline_item(title, options)
60
+ prev.data.next = outline_item if prev
61
+ parent.data.first = outline_item unless prev
62
+ parent.data.last = outline_item
63
+ increase_count
64
+ self.prev = block ? nil : outline_item
65
+ self.parent = outline_item if block
66
+ yield if block
67
+ return unless parent == outline_item
68
+
69
+ self.prev = outline_item
70
+ self.parent = outline_item.data.parent
71
+ end
72
+
73
+ def create_outline_item(title, options)
74
+ outline_item = PDF::OutlineItem.new(title, parent, options)
75
+ case options[:destination]
76
+ when Integer then outline_item.dest = [document.pages[options[:destination] - 1].dictionary, :Fit]
77
+ when Array then outline_item.dest = options[:destination]
78
+ end
79
+ outline_item.prev = prev if @prev
80
+ items[title] = document.ref!(outline_item)
81
+ end
82
+
83
+ def increase_count
84
+ counting_parent = parent
85
+ while counting_parent
86
+ counting_parent.data.count += 1
87
+ counting_parent = counting_parent == root ? nil : counting_parent.data.parent
88
+ end
89
+ end
90
+
91
+ def insert_section(next_item)
92
+ last = @parent.data.last
93
+ yield if block_given?
94
+ if next_item
95
+ next_item.data.prev = @prev
96
+ @prev.data.next = next_item
97
+ @parent.data.last = last
98
+ end
99
+ @parent = root
100
+ @prev = root.data.last
101
+ end
102
+ end
103
+
104
+ module Destinations
105
+ def dests
106
+ names.data[:Dests] ||= ref!(PDF::NameTree.new)
107
+ end
108
+
109
+ def add_dest(name, reference)
110
+ reference = ref!(reference) unless reference.is_a?(PDF::Reference)
111
+ dests.data.add(name, reference)
112
+ end
113
+
114
+ def dest_xyz(left, top, zoom = nil, dest_page = page)
115
+ [dest_page.dictionary, :XYZ, left, top, zoom]
116
+ end
117
+
118
+ def dest_fit(dest_page = page)
119
+ [dest_page.dictionary, :Fit]
120
+ end
121
+
122
+ def dest_fit_horizontally(top, dest_page = page)
123
+ [dest_page.dictionary, :FitH, top]
124
+ end
125
+
126
+ def dest_fit_vertically(left, dest_page = page)
127
+ [dest_page.dictionary, :FitV, left]
128
+ end
129
+
130
+ def dest_fit_rect(left, bottom, right, top, dest_page = page)
131
+ [dest_page.dictionary, :FitR, left, bottom, right, top]
132
+ end
133
+
134
+ def dest_fit_bounds(dest_page = page)
135
+ [dest_page.dictionary, :FitB]
136
+ end
137
+
138
+ def dest_fit_bounds_horizontally(top, dest_page = page)
139
+ [dest_page.dictionary, :FitBH, top]
140
+ end
141
+
142
+ def dest_fit_bounds_vertically(left, dest_page = page)
143
+ [dest_page.dictionary, :FitBV, left]
144
+ end
145
+ end
146
+ end
@@ -0,0 +1,125 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ # One page: its geometry, the content stream being written, its resources and the stack of graphic states.
5
+ class Page
6
+ DEFAULT_MARGINS = {left: 36, right: 36, top: 36, bottom: 36}.freeze
7
+ NO_INDENTS = {left: 0, bottom: 0, right: 0, top: 0}.freeze
8
+
9
+ attr_accessor :margins, :crops, :bleeds, :trims, :art_indents, :stack
10
+ attr_reader :document, :content
11
+
12
+ def initialize(document, options = {})
13
+ @document = document
14
+ @margins = options[:margins] || DEFAULT_MARGINS.dup
15
+ @crops = options[:crops] || NO_INDENTS
16
+ @bleeds = options[:bleeds] || NO_INDENTS
17
+ @trims = options[:trims] || NO_INDENTS
18
+ @art_indents = options[:art_indents] || NO_INDENTS
19
+ @stack = [GraphicState.new(options[:graphic_state])]
20
+ @size = options[:size] || "LETTER"
21
+ @layout = options[:layout] || :portrait
22
+ @content = document.ref!({}, (+"q\n").b)
23
+ @dictionary = document.ref!(Type: :Page, Parent: document.pages_reference, MediaBox: dimensions, Contents: @content)
24
+ @stamp_stream = nil
25
+ @stamp_dictionary = nil
26
+ end
27
+
28
+ def graphic_state
29
+ stack.last
30
+ end
31
+
32
+ def size
33
+ @size || dimensions[2, 2]
34
+ end
35
+
36
+ def layout
37
+ @layout || (dimensions[3] > dimensions[2] ? :portrait : :landscape)
38
+ end
39
+
40
+ def content_stream
41
+ @stamp_stream || content.stream
42
+ end
43
+
44
+ def dictionary
45
+ @stamp_dictionary || @dictionary
46
+ end
47
+
48
+ # Writes what the block draws into a form XObject instead of the page, starting from a copy of the current
49
+ # graphic settings.
50
+ def stamp_stream(reference)
51
+ @stamp_dictionary = reference
52
+ @stamp_stream = reference.stream
53
+ depth = stack.size
54
+ document.save_graphics_state
55
+ document.__send__(:freeze_stamp_graphics)
56
+ yield if block_given?
57
+ document.restore_graphics_state until stack.size == depth
58
+ @stamp_stream = nil
59
+ @stamp_dictionary = nil
60
+ end
61
+
62
+ def in_stamp_stream?
63
+ !@stamp_stream.nil?
64
+ end
65
+
66
+ def resources
67
+ dictionary.data[:Resources] ||= {}
68
+ end
69
+
70
+ def fonts
71
+ resources[:Font] ||= {}
72
+ end
73
+
74
+ def xobjects
75
+ resources[:XObject] ||= {}
76
+ end
77
+
78
+ def ext_gstates
79
+ resources[:ExtGState] ||= {}
80
+ end
81
+
82
+ def dimensions
83
+ sides = PageGeometry::SIZES[@size] || @size
84
+ sides =
85
+ case @layout
86
+ when :portrait then sides
87
+ when :landscape then sides.reverse
88
+ else raise Errors::InvalidPageLayout, "Layout must be either :portrait or :landscape"
89
+ end
90
+ [0, 0, *sides]
91
+ end
92
+
93
+ def crop_box
94
+ boxed(crops)
95
+ end
96
+
97
+ def bleed_box
98
+ boxed(bleeds)
99
+ end
100
+
101
+ def trim_box
102
+ boxed(trims)
103
+ end
104
+
105
+ def art_box
106
+ boxed(art_indents)
107
+ end
108
+
109
+ # Resources are required even when empty; every box defaults to the media box, so only those that differ are
110
+ # written.
111
+ def finalize
112
+ resources
113
+ {CropBox: crop_box, BleedBox: bleed_box, TrimBox: trim_box, ArtBox: art_box}.each do |name, box|
114
+ dictionary.data[name] = box unless box == dimensions
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def boxed(indents)
121
+ left, bottom, right, top = dimensions
122
+ [left + indents[:left], bottom + indents[:bottom], right - indents[:right], top - indents[:top]]
123
+ end
124
+ end
125
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ module PageGeometry
5
+ POINTS_PER_INCH = 72.0
6
+ MILLIMETERS_PER_INCH = 25.4
7
+
8
+ # ISO 216 (A, B), ISO 217 (RA, SRA) and ISO 269 (C) sizes in millimeters, then North American sizes in inches.
9
+ MILLIMETERS_BY_SIZE = {
10
+ "4A0" => [1682, 2378], "2A0" => [1189, 1682],
11
+ "A0" => [841, 1189], "A1" => [594, 841], "A2" => [420, 594], "A3" => [297, 420], "A4" => [210, 297],
12
+ "A5" => [148, 210], "A6" => [105, 148], "A7" => [74, 105], "A8" => [52, 74], "A9" => [37, 52], "A10" => [26, 37],
13
+ "B0" => [1000, 1414], "B1" => [707, 1000], "B2" => [500, 707], "B3" => [353, 500], "B4" => [250, 353],
14
+ "B5" => [176, 250], "B6" => [125, 176], "B7" => [88, 125], "B8" => [62, 88], "B9" => [44, 62], "B10" => [31, 44],
15
+ "C0" => [917, 1297], "C1" => [648, 917], "C2" => [458, 648], "C3" => [324, 458], "C4" => [229, 324],
16
+ "C5" => [162, 229], "C6" => [114, 162], "C7" => [81, 114], "C8" => [57, 81], "C9" => [40, 57], "C10" => [28, 40],
17
+ "RA0" => [860, 1220], "RA1" => [610, 860], "RA2" => [430, 610], "RA3" => [305, 430], "RA4" => [215, 305],
18
+ "SRA0" => [900, 1280], "SRA1" => [640, 900], "SRA2" => [450, 640], "SRA3" => [320, 450], "SRA4" => [225, 320],
19
+ "EXECUTIVE" => [184.1, 266.7],
20
+ }.freeze
21
+ INCHES_BY_SIZE = {"FOLIO" => [8.5, 13], "LEGAL" => [8.5, 14], "LETTER" => [8.5, 11], "TABLOID" => [11, 17]}.freeze
22
+
23
+ SIZES = MILLIMETERS_BY_SIZE.transform_values { |sides| sides.map { (it * POINTS_PER_INCH / MILLIMETERS_PER_INCH).round(2) } }
24
+ .merge(INCHES_BY_SIZE.transform_values { |sides| sides.map { it * POINTS_PER_INCH } })
25
+ .freeze
26
+ end
27
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ module PDF
5
+ class OutlineRoot
6
+ include Serializable
7
+
8
+ attr_accessor :count, :first, :last
9
+
10
+ def initialize
11
+ @count = 0
12
+ end
13
+
14
+ def to_hash
15
+ {Type: :Outlines, Count: count, First: first, Last: last}
16
+ end
17
+ end
18
+
19
+ class OutlineItem
20
+ include Serializable
21
+
22
+ attr_accessor :count, :first, :last, :next, :prev, :parent, :title, :dest, :closed
23
+
24
+ def initialize(title, parent, options)
25
+ @closed = options[:closed]
26
+ @title = title
27
+ @parent = parent
28
+ @count = 0
29
+ end
30
+
31
+ def to_hash
32
+ {Title: title, Parent: parent, Count: closed ? -count : count, First: first, Last: last, Next: @next, Prev: prev, Dest: dest}.compact
33
+ end
34
+ end
35
+
36
+ # A name tree kept as a single sorted leaf: valid at any size (ISO 32000-1 §7.9.6).
37
+ class NameTree
38
+ include Serializable
39
+
40
+ def initialize
41
+ @value_by_name = {}
42
+ end
43
+
44
+ def add(name, value)
45
+ @value_by_name[name] = value
46
+ end
47
+
48
+ def to_hash
49
+ {Names: @value_by_name.sort_by { |name, _| name.to_s }.flat_map { |name, value| [LiteralString.new(name.to_s), value] }}
50
+ end
51
+ end
52
+ end
53
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ module PDF
5
+ # An indirect object: a number, its value and, for a stream, its bytes.
6
+ class Reference
7
+ attr_reader :identifier
8
+ attr_accessor :data, :uncompressed
9
+ attr_writer :stream
10
+
11
+ def initialize(identifier, data, stream = nil)
12
+ @identifier = identifier
13
+ @data = data
14
+ @stream = stream
15
+ end
16
+
17
+ def to_s
18
+ "#{identifier} 0 R"
19
+ end
20
+
21
+ # A reference becomes a stream once bytes are written to it: prawn-svg appends to ref!({}).stream.
22
+ def stream
23
+ @stream ||= +"".b
24
+ end
25
+
26
+ def stream?
27
+ !@stream.nil? && !@stream.empty?
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,105 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest/md5"
4
+ require "zlib"
5
+
6
+ module PrawnX
7
+ module PDF
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::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.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 = Document::Arcfour.new(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.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.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 { it[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/prawnx/pdf.rb ADDED
@@ -0,0 +1,66 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
4
+ # PDF syntax: numbers, names, strings, arrays and dictionaries, as ISO 32000-1 §7.3 writes them.
5
+ module PDF
6
+ Name = Data.define(:value)
7
+
8
+ # Objects that build their dictionary when the document is written, once every reference they hold is known.
9
+ module Serializable
10
+ end
11
+ LiteralString = Class.new(String)
12
+ ByteString = Class.new(String)
13
+
14
+ DELIMITER_OR_IRREGULAR_BYTES = ((0..32).to_a + [35, 37, 40, 41, 47, 60, 62, 91, 93, 123, 125] + (127..255).to_a).freeze
15
+ LITERAL_STRING_ESCAPES = {"(" => "\\(", ")" => "\\)", "\\" => "\\\\", "\r" => "\\r"}.freeze
16
+ NAME_BY_SYMBOL = Hash.new do |cache, symbol|
17
+ cache[symbol] = (+"/").concat(*symbol.to_s.b.each_byte.map { DELIMITER_OR_IRREGULAR_BYTES.include?(it) ? format("#%02X", it) : it.chr }).freeze
18
+ end
19
+
20
+ module_function
21
+
22
+ # Five decimals, trailing zeros dropped but one: 1 is "1.0", 0.5 is "0.5", 1/3 is "0.33333".
23
+ def real(number)
24
+ digits = format("%.5f", number)
25
+ last = digits.bytesize - 1
26
+ last -= 1 while digits.getbyte(last) == 48 && digits.getbyte(last - 1) != 46
27
+ digits.byteslice(0, last + 1)
28
+ end
29
+
30
+ def real_params(numbers)
31
+ numbers.map { real(it) }.join(" ")
32
+ end
33
+
34
+ # Integers as integers, other numbers to five decimals without a trailing ".0".
35
+ def number(value)
36
+ return value.to_s if value.is_a?(Integer)
37
+
38
+ digits = real(value)
39
+ digits.end_with?(".0") ? digits.delete_suffix(".0") : digits
40
+ end
41
+
42
+ def serialize(value, in_content_stream = false)
43
+ case value
44
+ when nil then "null"
45
+ when true then "true"
46
+ when false then "false"
47
+ when Numeric then number(value)
48
+ when Symbol then NAME_BY_SYMBOL[value]
49
+ when Name then NAME_BY_SYMBOL[value.value.to_sym]
50
+ when LiteralString then "(#{value.gsub(/[\\\r()]/, LITERAL_STRING_ESCAPES)})"
51
+ when ByteString then "<#{value.unpack1("H*")}>"
52
+ when Time then "(#{value.strftime("D:%Y%m%d%H%M%S%z").chop.chop}'00')"
53
+ when String then "<#{(in_content_stream ? value : utf16be(value)).unpack1("H*")}>"
54
+ when Array then "[#{value.map { serialize(it, in_content_stream) }.join(" ")}]"
55
+ when Hash then "<<#{value.map { |key, entry| "#{serialize(key.to_sym)} #{serialize(entry, in_content_stream)}" }.join("\n")}>>"
56
+ when Reference then value.to_s
57
+ when Serializable then serialize(value.to_hash, in_content_stream)
58
+ else raise Errors::FailedObjectConversion, "This object cannot be serialized to PDF (#{value.inspect})"
59
+ end
60
+ end
61
+
62
+ def utf16be(string)
63
+ "\xFE\xFF".b + string.encode(::Encoding::UTF_16BE).b
64
+ end
65
+ end
66
+ end
@@ -0,0 +1,61 @@
1
+ # frozen_string_literal: true
2
+
3
+ module PrawnX
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(it) }, ""].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(it) }.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