pdfrb 0.7.1 → 0.7.11

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 (48) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +93 -0
  3. data/RELEASE.md +17 -11
  4. data/data/pdfrb/layout/hyphenation_en.txt +408 -0
  5. data/lib/pdfrb/color/default_profile.rb +213 -0
  6. data/lib/pdfrb/color/icc_validator.rb +69 -0
  7. data/lib/pdfrb/color.rb +2 -0
  8. data/lib/pdfrb/conformance/ltv.rb +112 -0
  9. data/lib/pdfrb/conformance/pades.rb +198 -0
  10. data/lib/pdfrb/conformance/pdf_2_af.rb +185 -0
  11. data/lib/pdfrb/conformance/pdf_a.rb +123 -0
  12. data/lib/pdfrb/conformance/pdf_a4_deep.rb +94 -0
  13. data/lib/pdfrb/conformance/pdf_ua2_deep.rb +93 -0
  14. data/lib/pdfrb/conformance/pdf_ua_tagging_deep.rb +125 -0
  15. data/lib/pdfrb/conformance/pdf_vt.rb +128 -0
  16. data/lib/pdfrb/conformance/pdf_x.rb +66 -1
  17. data/lib/pdfrb/conformance/tagged_pdf.rb +182 -0
  18. data/lib/pdfrb/conformance.rb +8 -0
  19. data/lib/pdfrb/content/parser.rb +82 -0
  20. data/lib/pdfrb/digital_signature/timestamp_client.rb +86 -0
  21. data/lib/pdfrb/digital_signature.rb +1 -0
  22. data/lib/pdfrb/document.rb +29 -0
  23. data/lib/pdfrb/encryption/public_key_security_handler.rb +146 -0
  24. data/lib/pdfrb/encryption/standard_security_handler.rb +98 -3
  25. data/lib/pdfrb/encryption/v5_writer.rb +108 -0
  26. data/lib/pdfrb/encryption.rb +3 -0
  27. data/lib/pdfrb/font_loader/type3.rb +65 -0
  28. data/lib/pdfrb/font_loader.rb +1 -0
  29. data/lib/pdfrb/image_loader/gif.rb +246 -0
  30. data/lib/pdfrb/image_loader/tiff.rb +257 -0
  31. data/lib/pdfrb/image_loader.rb +2 -0
  32. data/lib/pdfrb/layout/font_fallback.rb +203 -0
  33. data/lib/pdfrb/layout/hyphenation.rb +120 -0
  34. data/lib/pdfrb/layout/justification_kashidas.rb +72 -0
  35. data/lib/pdfrb/layout/multi_cell_text_layout.rb +65 -0
  36. data/lib/pdfrb/layout/multi_page_table_box.rb +155 -0
  37. data/lib/pdfrb/layout/polygon_frame.rb +106 -0
  38. data/lib/pdfrb/layout/table_box.rb +154 -23
  39. data/lib/pdfrb/layout/text_shaper.rb +129 -0
  40. data/lib/pdfrb/layout.rb +7 -0
  41. data/lib/pdfrb/source/linearization_reader.rb +76 -0
  42. data/lib/pdfrb/source/recovery.rb +65 -1
  43. data/lib/pdfrb/source/tokenizer.rb +21 -0
  44. data/lib/pdfrb/source.rb +1 -0
  45. data/lib/pdfrb/task/thumbnail.rb +133 -0
  46. data/lib/pdfrb/task.rb +1 -0
  47. data/lib/pdfrb/version.rb +1 -1
  48. metadata +28 -2
@@ -0,0 +1,246 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Pdfrb
6
+ module ImageLoader
7
+ # GIF image loader with full GIF-spec LZW decode. Supports:
8
+ #
9
+ # * Format detection via the "GIF8" magic.
10
+ # * Logical screen descriptor + global color table parse.
11
+ # * Image descriptor (per-frame) for the first image frame.
12
+ # * GIF-LZW decompression (variable-width codes, dictionary
13
+ # rebuild on clear code, dict-reset semantics).
14
+ #
15
+ # Output: an Indexed PDF image XObject whose /ColorSpace is
16
+ # [/Indexed /DeviceRGB <palette bytes>] and whose stream is the
17
+ # decoded palette-index bytes (one byte per pixel), suitable for
18
+ # /FlateDecode re-compression.
19
+ module GIF
20
+ module_function
21
+
22
+ def call(document, data, **_opts)
23
+ data = data.read if data.is_a?(IO) || data.is_a?(StringIO)
24
+ info = parse_header(data)
25
+ return nil if info.empty?
26
+
27
+ palette = read_global_color_table(data, info)
28
+ return nil unless palette
29
+
30
+ frame = parse_first_image_descriptor(data, info)
31
+ return nil unless frame
32
+
33
+ lzw_min = data.getbyte(frame[:lzw_min_code_offset])
34
+ compressed_start = frame[:lzw_min_code_offset] + 1
35
+ compressed = extract_subblocks(data, compressed_start)
36
+ return nil unless compressed
37
+
38
+ indices = lzw_decode(compressed, lzw_min)
39
+ return nil unless indices
40
+
41
+ rgb_palette = palette.pack("C*")
42
+ cs = [:Indexed, :DeviceRGB, (palette.length / 3) - 1, rgb_palette]
43
+ compressed_indices = Zlib.deflate(indices)
44
+
45
+ image = document.add(
46
+ {
47
+ Type: :XObject, Subtype: :Image,
48
+ Width: frame[:width], Height: frame[:height],
49
+ BitsPerComponent: 8,
50
+ ColorSpace: cs,
51
+ Filter: :FlateDecode,
52
+ Length: compressed_indices.bytesize
53
+ },
54
+ type: Pdfrb::Model::Type::XObjectImage
55
+ )
56
+ image.stream = compressed_indices
57
+ image
58
+ end
59
+
60
+ def parse_header(data)
61
+ return {} unless data.is_a?(::String) && data.bytesize >= 13
62
+ return {} unless data.start_with?("GIF87a", "GIF89a")
63
+
64
+ width = (data.getbyte(7) << 8) | data.getbyte(6)
65
+ height = (data.getbyte(9) << 8) | data.getbyte(8)
66
+ packed = data.getbyte(10)
67
+ global_ct_flag = packed.anybits?(0x80)
68
+ global_ct_size = global_ct_flag ? (2**((packed & 0x07) + 1)) : 0
69
+ bg_color = data.getbyte(11)
70
+ aspect_ratio = data.getbyte(12)
71
+ {
72
+ width: width,
73
+ height: height,
74
+ global_ct_flag: global_ct_flag,
75
+ global_ct_size: global_ct_size,
76
+ bg_color: bg_color,
77
+ aspect_ratio: aspect_ratio,
78
+ global_ct_offset: 13,
79
+ }
80
+ end
81
+
82
+ def read_global_color_table(data, info)
83
+ return nil unless info[:global_ct_flag]
84
+
85
+ n = info[:global_ct_size]
86
+ offset = info[:global_ct_offset]
87
+ return nil unless offset + (n * 3) <= data.bytesize
88
+
89
+ (0...(n * 3)).map { |i| data.getbyte(offset + i) }
90
+ end
91
+
92
+ # Walk the GIF looking for the first image descriptor (0x2C).
93
+ # Returns a Hash with image descriptor fields + the LZW min
94
+ # code size offset within the data.
95
+ def parse_first_image_descriptor(data, info)
96
+ offset = info[:global_ct_offset] + (info[:global_ct_size] * 3)
97
+ while offset < data.bytesize
98
+ marker = data.getbyte(offset)
99
+ case marker
100
+ when 0x2C
101
+ return parse_image_descriptor(data, offset + 1)
102
+ when 0x21
103
+ offset += 2
104
+ while offset < data.bytesize
105
+ block_size = data.getbyte(offset)
106
+ offset += 1
107
+ break if block_size.zero?
108
+
109
+ offset += block_size
110
+ end
111
+ when 0x3B
112
+ return nil
113
+ else
114
+ offset += 1
115
+ end
116
+ end
117
+ nil
118
+ end
119
+
120
+ def parse_image_descriptor(data, offset)
121
+ return nil if offset + 9 > data.bytesize
122
+
123
+ left = (data.getbyte(offset + 1) << 8) | data.getbyte(offset)
124
+ top = (data.getbyte(offset + 3) << 8) | data.getbyte(offset + 2)
125
+ width = (data.getbyte(offset + 5) << 8) | data.getbyte(offset + 4)
126
+ height = (data.getbyte(offset + 7) << 8) | data.getbyte(offset + 6)
127
+ packed = data.getbyte(offset + 8)
128
+ local_ct_flag = packed.anybits?(0x80)
129
+ local_ct_size = local_ct_flag ? (2**((packed & 0x07) + 1)) : 0
130
+ body_offset = offset + 9 + (local_ct_size * 3)
131
+ {
132
+ left: left, top: top, width: width, height: height,
133
+ local_ct_flag: local_ct_flag, local_ct_size: local_ct_size,
134
+ lzw_min_code_offset: body_offset
135
+ }
136
+ end
137
+
138
+ # Collect sub-blocks (length-prefixed) starting at +offset+
139
+ # until a zero-length terminator. Returns concatenated bytes.
140
+ def extract_subblocks(data, offset)
141
+ bytes = +"".b
142
+ while offset < data.bytesize
143
+ block_size = data.getbyte(offset)
144
+ offset += 1
145
+ break if block_size.zero?
146
+
147
+ bytes << data.byteslice(offset, block_size)
148
+ offset += block_size
149
+ end
150
+ bytes
151
+ end
152
+
153
+ # GIF-spec LZW decode. Variable-width codes starting at
154
+ # min_code_size + 1 bits. Clear code = 2**min, end code =
155
+ # 2**min + 1.
156
+ def lzw_decode(compressed, min_code_size)
157
+ clear_code = 1 << min_code_size
158
+ end_code = clear_code + 1
159
+ codes = unpack_lzw_codes(compressed, min_code_size + 1, clear_code)
160
+ return nil unless codes
161
+
162
+ out = +"".b
163
+ dict = init_dictionary(min_code_size)
164
+ prev = nil
165
+ codes.each do |code|
166
+ if code == clear_code
167
+ dict = init_dictionary(min_code_size)
168
+ prev = nil
169
+ next
170
+ end
171
+ break if code == end_code
172
+
173
+ entry =
174
+ if dict.key?(code)
175
+ dict[code]
176
+ elsif prev && dict.key?(prev)
177
+ dict[prev] + first_byte(dict[prev])
178
+ else
179
+ return nil
180
+ end
181
+ out << entry
182
+ if prev && !dict.key?(code) && dict.length < 4096
183
+ dict[code] = dict[prev] + first_byte(entry)
184
+ end
185
+ prev = code
186
+ end
187
+ out
188
+ end
189
+
190
+ def first_byte(str)
191
+ str.getbyte(0)&.chr(Encoding::BINARY) || "".b
192
+ end
193
+
194
+ def init_dictionary(min_code_size)
195
+ clear_code = 1 << min_code_size
196
+ end_code = clear_code + 1
197
+ dict = {}
198
+ (0...clear_code).each { |i| dict[i] = first_byte_of_code(i) }
199
+ dict[clear_code] = ""
200
+ dict[end_code] = ""
201
+ dict
202
+ end
203
+
204
+ def first_byte_of_code(i)
205
+ i.chr(Encoding::BINARY)
206
+ end
207
+
208
+ # Unpack variable-width codes from the LZW-compressed byte
209
+ # stream. GIF codes are packed LSB-first. Width starts at
210
+ # initial_width and grows by 1 when the next-code threshold
211
+ # reaches 2**width. Width caps at 12 bits.
212
+ def unpack_lzw_codes(bytes, initial_width, clear_code)
213
+ codes = []
214
+ bit_buffer = 0
215
+ bits_in_buffer = 0
216
+ width = initial_width
217
+ min_code_size = initial_width - 1
218
+ next_code = (1 << min_code_size) + 2 # next free dict slot
219
+ byte_index = 0
220
+
221
+ while byte_index < bytes.bytesize
222
+ bit_buffer |= (bytes.getbyte(byte_index) << bits_in_buffer)
223
+ bits_in_buffer += 8
224
+ byte_index += 1
225
+
226
+ while bits_in_buffer >= width
227
+ code = bit_buffer & ((1 << width) - 1)
228
+ bit_buffer >>= width
229
+ bits_in_buffer -= width
230
+ codes << code
231
+ if code == clear_code
232
+ width = initial_width
233
+ next_code = (1 << min_code_size) + 2
234
+ else
235
+ next_code += 1
236
+ if width < 12 && next_code > (1 << width)
237
+ width += 1
238
+ end
239
+ end
240
+ end
241
+ end
242
+ codes
243
+ end
244
+ end
245
+ end
246
+ end
@@ -0,0 +1,257 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "zlib"
4
+
5
+ module Pdfrb
6
+ module ImageLoader
7
+ # TIFF image loader. Pure-Ruby support for:
8
+ #
9
+ # * Format detection via the II/MM byte-order magic.
10
+ # * Header + IFD parsing to extract width, height, bpc, sample
11
+ # count, photometric interpretation, compression, strip
12
+ # offsets + byte counts.
13
+ # * Single-strip, uncompressed (compression == 1) RGB or
14
+ # grayscale pixel decode into raw image bytes suitable for
15
+ # embedding as a /FlateDecode image XObject.
16
+ #
17
+ # For multi-strip, tiled, YCbCr, or compressed TIFFs (LZW, PackBits,
18
+ # CCITT, JPEG-in-TIFF), the loader emits a metadata-only stub
19
+ # XObject. Callers should pre-convert those to PNG/JPEG.
20
+ module TIFF
21
+ COMPRESSION_NONE = 1
22
+ COMPRESSION_CCITT_RLE = 2
23
+ COMPRESSION_CCITT_FAX3 = 3
24
+ COMPRESSION_CCITT_FAX4 = 4
25
+ COMPRESSION_LZW = 5
26
+ COMPRESSION_OJPEG = 6
27
+ COMPRESSION_JPEG = 7
28
+ COMPRESSION_DEFLATE = 8
29
+ COMPRESSION_PACKBITS = 32773
30
+
31
+ module_function
32
+
33
+ def call(document, data, **_opts)
34
+ data = data.read if data.is_a?(IO) || data.is_a?(StringIO)
35
+ info = parse_header(data)
36
+ return nil if info.empty?
37
+
38
+ pixels = decode_pixels(data, info)
39
+ if pixels
40
+ build_compressed_image(document, info, pixels)
41
+ else
42
+ build_stub_image(document, info)
43
+ end
44
+ end
45
+
46
+ # Parse the TIFF header (8 bytes) + first IFD. Returns a Hash
47
+ # with width, height, bits_per_sample, samples_per_pixel,
48
+ # photometric, compression, strip_offsets, strip_byte_counts,
49
+ # rows_per_strip, color_space, or empty Hash if not TIFF.
50
+ def parse_header(data)
51
+ return {} unless data.is_a?(::String) && data.bytesize >= 8
52
+
53
+ byte_order = data.byteslice(0, 2)
54
+ case byte_order
55
+ when "II" then little_endian = true
56
+ when "MM" then little_endian = false
57
+ else return {}
58
+ end
59
+
60
+ magic = read_u16(data, 2, little_endian)
61
+ return {} unless [42, 43].include?(magic)
62
+ return {} unless magic == 42
63
+
64
+ ifd_offset = read_u32(data, 4, little_endian)
65
+ parse_ifd(data, ifd_offset, little_endian)
66
+ end
67
+
68
+ def parse_ifd(data, offset, little_endian)
69
+ return {} if offset.nil? || offset + 2 > data.bytesize
70
+
71
+ count = read_u16(data, offset, little_endian)
72
+ entries = {}
73
+ count.times do |i|
74
+ entry_off = offset + 2 + (i * 12)
75
+ break if entry_off + 12 > data.bytesize
76
+
77
+ tag = read_u16(data, entry_off, little_endian)
78
+ type_id = read_u16(data, entry_off + 2, little_endian)
79
+ count_val = read_u32(data, entry_off + 4, little_endian)
80
+ value = read_ifd_value(data, entry_off + 8, type_id, count_val, little_endian)
81
+ entries[tag] = { type: type_id, count: count_val, value: value }
82
+ end
83
+
84
+ interpret_ifd(entries)
85
+ end
86
+
87
+ # Decode pixel data when the TIFF is single-strip uncompressed
88
+ # RGB or grayscale. Returns binary pixel bytes or nil if the
89
+ # compression/photometric combo isn't supported here.
90
+ def decode_pixels(data, info)
91
+ return nil unless info[:compression] == COMPRESSION_NONE
92
+ return nil unless info[:strip_offsets] && info[:strip_byte_counts]
93
+
94
+ # Only handle 8-bit single-sample (gray) or 3-sample (RGB)
95
+ # in photometric 0/1/2.
96
+ return nil unless info[:bits_per_sample] == 8
97
+ return nil unless [1, 3].include?(info[:samples_per_pixel])
98
+ return nil unless [0, 1, 2].include?(info[:photometric])
99
+
100
+ bytes = read_strips(data, info)
101
+ return nil unless bytes
102
+
103
+ # TIFF rows are padded to word boundaries; PDF image XObjects
104
+ # don't need this padding. Strip per row.
105
+ channels = info[:samples_per_pixel]
106
+ row_bytes = info[:width] * channels
107
+ return nil if row_bytes.zero?
108
+
109
+ rows = info[:height]
110
+ out = +"".b
111
+ rows.times do |r|
112
+ offset = r * row_bytes
113
+ out << bytes.byteslice(offset, row_bytes)
114
+ end
115
+ out
116
+ end
117
+
118
+ # Read all strips concatenated. Single-strip case is most common
119
+ # for small images; multi-strip is supported when present.
120
+ def read_strips(data, info)
121
+ offsets = Array(info[:strip_offsets])
122
+ counts = Array(info[:strip_byte_counts])
123
+ return nil if offsets.length != counts.length
124
+
125
+ bytes = +"".b
126
+ offsets.each_with_index do |off, i|
127
+ n = counts[i]
128
+ piece = data.byteslice(off, n)
129
+ return nil unless piece && piece.bytesize == n
130
+
131
+ bytes << piece
132
+ end
133
+ bytes
134
+ end
135
+
136
+ def build_compressed_image(document, info, pixels)
137
+ compressed = Zlib.deflate(pixels)
138
+ image = document.add(
139
+ {
140
+ Type: :XObject, Subtype: :Image,
141
+ Width: info[:width], Height: info[:height],
142
+ BitsPerComponent: 8,
143
+ ColorSpace: info[:color_space],
144
+ Filter: :FlateDecode,
145
+ Length: compressed.bytesize
146
+ },
147
+ type: Pdfrb::Model::Type::XObjectImage
148
+ )
149
+ image.stream = compressed
150
+ image
151
+ end
152
+
153
+ def build_stub_image(document, info)
154
+ image = document.add(
155
+ {
156
+ Type: :XObject, Subtype: :Image,
157
+ Width: info[:width], Height: info[:height],
158
+ BitsPerComponent: info[:bits_per_sample] || 8,
159
+ ColorSpace: info[:color_space] || :DeviceRGB,
160
+ Length: 0
161
+ },
162
+ type: Pdfrb::Model::Type::XObjectImage
163
+ )
164
+ image.stream = +""
165
+ image
166
+ end
167
+
168
+ def read_ifd_value(data, offset, type_id, count, little_endian)
169
+ # For multi-value entries (count > 1 for SHORT/LONG), the
170
+ # value field is a pointer to the actual data. For count <= 1
171
+ # the value lives inline in the 4-byte value field.
172
+ case type_id
173
+ when 3 # SHORT
174
+ count <= 1 ? read_u16(data, offset, little_endian) : read_short_array(data, offset, count, little_endian)
175
+ when 4 # LONG
176
+ count <= 1 ? read_u32(data, offset, little_endian) : read_long_array(data, offset, count, little_endian)
177
+ else
178
+ count <= 1 ? read_u16(data, offset, little_endian) : read_u32(data, offset, little_endian)
179
+ end
180
+ end
181
+
182
+ def read_short_array(data, value_field_offset, count, little_endian)
183
+ # If count * 2 <= 4, values live in the value field; otherwise
184
+ # the field holds a pointer.
185
+ if count * 2 <= 4
186
+ (0...count).map { |i| read_u16(data, value_field_offset + (i * 2), little_endian) }
187
+ else
188
+ ptr = read_u32(data, value_field_offset, little_endian)
189
+ (0...count).map { |i| read_u16(data, ptr + (i * 2), little_endian) }
190
+ end
191
+ end
192
+
193
+ def read_long_array(data, value_field_offset, count, little_endian)
194
+ if count * 4 <= 4
195
+ (0...count).map { |i| read_u32(data, value_field_offset + (i * 4), little_endian) }
196
+ else
197
+ ptr = read_u32(data, value_field_offset, little_endian)
198
+ (0...count).map { |i| read_u32(data, ptr + (i * 4), little_endian) }
199
+ end
200
+ end
201
+
202
+ def read_u16(data, offset, little_endian)
203
+ bytes = data.bytes[offset, 2]
204
+ return 0 unless bytes && bytes.length == 2
205
+
206
+ little_endian ? ((bytes[1] << 8) | bytes[0]) : ((bytes[0] << 8) | bytes[1])
207
+ end
208
+
209
+ def read_u32(data, offset, little_endian)
210
+ bytes = data.bytes[offset, 4]
211
+ return 0 unless bytes && bytes.length == 4
212
+
213
+ if little_endian
214
+ (bytes[3] << 24) | (bytes[2] << 16) | (bytes[1] << 8) | bytes[0]
215
+ else
216
+ (bytes[0] << 24) | (bytes[1] << 16) | (bytes[2] << 8) | bytes[3]
217
+ end
218
+ end
219
+
220
+ def interpret_ifd(entries)
221
+ width = entries[256]&.[](:value)
222
+ height = entries[257]&.[](:value)
223
+ bps = entries[258]&.[](:value) || 8
224
+ bps = bps.first if bps.is_a?(::Array)
225
+ spp = entries[277]&.[](:value) || 1
226
+ spp = spp.first if spp.is_a?(::Array)
227
+ photometric = entries[262]&.[](:value) || 1
228
+ compression = entries[259]&.[](:value) || COMPRESSION_NONE
229
+ strip_offsets = entries[273]&.[](:value)
230
+ strip_byte_counts = entries[279]&.[](:value)
231
+ rows_per_strip = entries[278]&.[](:value) || height
232
+ {
233
+ width: width,
234
+ height: height,
235
+ bits_per_sample: bps,
236
+ samples_per_pixel: spp,
237
+ photometric: photometric,
238
+ compression: compression,
239
+ strip_offsets: strip_offsets,
240
+ strip_byte_counts: strip_byte_counts,
241
+ rows_per_strip: rows_per_strip,
242
+ color_space: color_space_for_photometric(photometric),
243
+ }
244
+ end
245
+
246
+ def color_space_for_photometric(photometric)
247
+ case photometric
248
+ when 0, 1 then :DeviceGray
249
+ when 5 then :DeviceCMYK
250
+ # Default covers RGB (2), unknown photometric interpretations,
251
+ # and nil photometric (which we treat as RGB by convention).
252
+ else :DeviceRGB
253
+ end
254
+ end
255
+ end
256
+ end
257
+ end
@@ -10,6 +10,8 @@ module Pdfrb
10
10
  autoload :JPEG, "pdfrb/image_loader/jpeg"
11
11
  autoload :PNG, "pdfrb/image_loader/png"
12
12
  autoload :PDF, "pdfrb/image_loader/pdf"
13
+ autoload :TIFF, "pdfrb/image_loader/tiff"
14
+ autoload :GIF, "pdfrb/image_loader/gif"
13
15
 
14
16
  @loaders = []
15
17