pdf-reader 2.15.1 → 2.16.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 680d773fb89a823854ce986d7e35c5313df55087d0b4e8bfc3c70c51d97a8130
4
- data.tar.gz: 7484cc4e28a01b9a74b869c2dede32a47bfd2519cded7ff6fc421c99d546a406
3
+ metadata.gz: a9f0772356c29687ffafffb6af1cdc5d08ae7ac54f2d3da60d426f07f5338045
4
+ data.tar.gz: c8df04fcae9e48cd6bfd3ca22819e5108abc75f3084f9c6a2cbc86db07f9a387
5
5
  SHA512:
6
- metadata.gz: e9b2ad3cfb37fb76f731d646bf5097e0334d88b3f7e5ba39abfe9530e51a7dbcfcf08a75d81b6cc1e17f2b18015a25f3ef9ef0d9b75b0d5763eb38470263ee49
7
- data.tar.gz: 7acdc84e89045708ac4c983deefa2bb1a6246f1a57d8d9a809efeb3b1477d69921238067b797f78d2b7a33eff9bd54c03e139fd4bed10102f365a4d48e8899a0
6
+ metadata.gz: f9bf3dc84620d087f2593af2c6027984ae51998ec6706a7f12cc5bee8eabc77589fbb5804d878e1cf43def5566eccc72c9cb39913b27a66b0353243a107cccd7
7
+ data.tar.gz: 2f35f101c0753c23094879e8bca382ca4daf6344a2922f096459ef31126e8180ec47a7584c2535807b347122539af131c6348a3aca48f9e775652cd208192a01
data/CHANGELOG CHANGED
@@ -1,3 +1,26 @@
1
+ v2.16.0 (9th August 2026)
2
+ - Add new method that use Point(x, y) instead of Array[Integer], for improved typing with sorbet (https://github.com/yob/pdf-reader/pull/578)
3
+ - Support extracting text via ActualText annotations (https://github.com/yob/pdf-reader/pull/587)
4
+ - Accept any IO-like object in PDF::Reader.new() (https://github.com/yob/pdf-reader/pull/607)
5
+ - Expanded type annotations
6
+ - Add dynamically generated RBS type signaturesd to published gem (https://github.com/yob/pdf-reader/pull/580)
7
+ - A few bug fixes to CMap parsing and Unicode extraction
8
+ - https://github.com/yob/pdf-reader/pull/582
9
+ - https://github.com/yob/pdf-reader/pull/593
10
+ - Fix occasional exception when parsing PDFDoc strings with invalid bytes (https://github.com/yob/pdf-reader/pull/590)
11
+ - Fox occasional exception parsing differnce tables with an unmatched surrgate pair
12
+ - https://github.com/yob/pdf-reader/pull/604
13
+ - https://github.com/yob/pdf-reader/pull/605
14
+ - Reduce object allocations when parsing many PDFs
15
+ - https://github.com/yob/pdf-reader/pull/595
16
+ - https://github.com/yob/pdf-reader/pull/596
17
+ - https://github.com/yob/pdf-reader/pull/598
18
+ - https://github.com/yob/pdf-reader/pull/599
19
+ - https://github.com/yob/pdf-reader/pull/600
20
+ - https://github.com/yob/pdf-reader/pull/601
21
+ - Add truffleruby to the test matrix in CI (https://github.com/yob/pdf-reader/pull/583)
22
+ - Add JRuby 10.1 to the test matrix (https://github.com/yob/pdf-reader/pull/597)
23
+
1
24
  v2.15.1 (28th December 2025)
2
25
  - Add ruby 4.0 to the CI matrix (https://github.com/yob/pdf-reader/pull/575)
3
26
  - Avoiding raising an error when ToUnicode poins to the wrong object type (https://github.com/yob/pdf-reader/pull/573)
@@ -92,7 +115,7 @@ v2.6.0 (12th November 2021)
92
115
 
93
116
  v2.5.0 (6th June 2021)
94
117
  - bump minimum ruby version to 2.0
95
- - Correctly handle trascoding to UTF-8 from some fonts that use a difference table [#344](https://github.com/yob/pdf-reader/pull/344/)
118
+ - Correctly handle trascoding to UTF-8 from some fonts that use a difference table [#344](https://github.com/yob/pdf-reader/pull/344/)
96
119
  - Fix some character spacing issues with the TJ operator [#343](https://github.com/yob/pdf-reader/pull/343)
97
120
  - Fix crash with some encrypted PDFs [#348](https://github.com/yob/pdf-reader/pull/348/)
98
121
  - Fix positions of text on some PDFs with pages rotated 90° [#350](https://github.com/yob/pdf-reader/pull/350/)
data/Rakefile CHANGED
@@ -14,7 +14,7 @@ desc "Run cane to check quality metrics"
14
14
  Cane::RakeTask.new(:quality) do |cane|
15
15
  cane.abc_max = 20
16
16
  cane.style_measure = 100
17
- cane.max_violations = 33
17
+ cane.max_violations = 32
18
18
 
19
19
  cane.use Morecane::EncodingCheck, :encoding_glob => "{app,lib,spec}/**/*.rb"
20
20
  end
@@ -10,7 +10,7 @@ class PDF::Reader
10
10
 
11
11
  #: (Array[PDF::Reader::TextRun], PDF::Reader::Rectangle) -> Array[PDF::Reader::TextRun]
12
12
  def self.runs_within_rect(runs, rect)
13
- runs.select { |run| rect.contains?(run.origin) }
13
+ runs.select { |run| rect.contains_xy?(run.x, run.y) }
14
14
  end
15
15
  end
16
16
  end
@@ -62,6 +62,9 @@ class PDF::Reader
62
62
  # must match whole tokens
63
63
  DIGITS_ONLY = %r{\A\d+\z} #: Regexp
64
64
 
65
+ # bytes that terminate a PDF name token (used in prepare_regular_token)
66
+ NAME_TERMINATOR_BYTES = ([nil, 0x20, 0x0A] + TOKEN_DELIMITER).freeze #: Array[Integer?]
67
+
65
68
  #: Integer
66
69
  attr_reader :pos
67
70
 
@@ -255,13 +258,25 @@ class PDF::Reader
255
258
 
256
259
  token_one = @tokens[0]
257
260
  token_two = @tokens[1]
258
- if token_one.is_a?(String) && token_two.is_a?(String) && token_one.match(DIGITS_ONLY) && token_two.match(DIGITS_ONLY)
261
+ if token_one.is_a?(String) && token_two.is_a?(String) && match?(token_one, DIGITS_ONLY) && match?(token_two, DIGITS_ONLY)
259
262
  @tokens[0] = PDF::Reader::Reference.new(token_one.to_i, token_two.to_i)
260
263
  @tokens.delete_at(2)
261
264
  @tokens.delete_at(1)
262
265
  end
263
266
  end
264
267
 
268
+ # Once min ruby version is >= 2.4, we can drop this method and just use String#match?
269
+ #
270
+ #: (String, Regexp) -> bool
271
+ def match?(str, regexp)
272
+ # We prefer match? because fewer objects are allocated, and this code path is hot
273
+ if str.respond_to?(:match?)
274
+ str.match?(regexp)
275
+ else
276
+ str.match(regexp) != nil
277
+ end
278
+ end
279
+
265
280
  # Extract data between ID and EI
266
281
  # If the EI follows white-space the space is dropped from the data
267
282
  # The EI must followed by white-space or end of buffer
@@ -327,8 +342,12 @@ class PDF::Reader
327
342
  # ignore it
328
343
  else
329
344
  @tokens << str if str.size > 0
330
- @tokens << ">" if byte != 0x3E # '>'
331
- @tokens << byte.chr
345
+ if byte == 0x3E
346
+ @tokens << ">"
347
+ else
348
+ @tokens << ">"
349
+ @tokens << byte.chr
350
+ end
332
351
  break
333
352
  end
334
353
  end
@@ -383,6 +402,7 @@ class PDF::Reader
383
402
 
384
403
  case byte
385
404
  when nil
405
+ @tokens << tok if tok.size > 0
386
406
  break
387
407
  when 0x25
388
408
  # comment, ignore everything until the next EOL char
@@ -390,63 +410,95 @@ class PDF::Reader
390
410
  commentbyte = @io.getbyte
391
411
  break if commentbyte.nil? || commentbyte == 0x0A || commentbyte == 0x0D
392
412
  end
393
- when *TOKEN_WHITESPACE
413
+ when 0x00, 0x09, 0x0A, 0x0C, 0x0D, 0x20 # TOKEN_WHITESPACE
394
414
  # white space, token finished
395
- @tokens << tok if tok.size > 0
396
-
397
- #If the token was empty, chomp the rest of the whitespace too
398
- while TOKEN_WHITESPACE.include?(peek_byte) && tok.size == 0
399
- @io.getbyte
415
+ if tok.size > 0
416
+ @tokens << tok
417
+ else
418
+ #If the token was empty, chomp the rest of the whitespace too
419
+ while TOKEN_WHITESPACE.include?(peek_byte)
420
+ @io.getbyte
421
+ end
400
422
  end
401
- tok = "".dup
402
423
  break
403
424
  when 0x3C
404
425
  # opening delimiter '<', start of new token
405
- @tokens << tok if tok.size > 0
426
+ if tok.size > 0
427
+ @tokens << tok
428
+ end
406
429
  if peek_byte == 0x3C # check if token is actually '<<'
407
430
  @io.getbyte
408
431
  @tokens << "<<"
409
432
  else
410
433
  @tokens << "<"
411
434
  end
412
- tok = "".dup
413
435
  break
414
436
  when 0x3E
415
437
  # closing delimiter '>', start of new token
416
- @tokens << tok if tok.size > 0
438
+ if tok.size > 0
439
+ @tokens << tok
440
+ end
417
441
  if peek_byte == 0x3E # check if token is actually '>>'
418
442
  @io.getbyte
419
443
  @tokens << ">>"
420
444
  else
421
445
  @tokens << ">"
422
446
  end
423
- tok = "".dup
424
447
  break
425
- when 0x28, 0x5B, 0x7B
426
- # opening delimiter, start of new token
427
- @tokens << tok if tok.size > 0
428
- @tokens << byte.chr
429
- tok = "".dup
448
+ when 0x28
449
+ # opening delimiter '(', start of new token
450
+ if tok.size > 0
451
+ @tokens << tok
452
+ end
453
+ @tokens << "("
430
454
  break
431
- when 0x29, 0x5D, 0x7D
432
- # closing delimiter
433
- @tokens << tok if tok.size > 0
434
- @tokens << byte.chr
435
- tok = "".dup
455
+ when 0x5B
456
+ # opening delimiter '[', start of new token
457
+ if tok.size > 0
458
+ @tokens << tok
459
+ end
460
+ @tokens << "["
461
+ break
462
+ when 0x7B
463
+ # opening delimiter '{', start of new token
464
+ if tok.size > 0
465
+ @tokens << tok
466
+ end
467
+ @tokens << "{"
468
+ break
469
+ when 0x29
470
+ # closing delimiter ')'
471
+ if tok.size > 0
472
+ @tokens << tok
473
+ end
474
+ @tokens << ")"
475
+ break
476
+ when 0x5D
477
+ # closing delimiter ']'
478
+ if tok.size > 0
479
+ @tokens << tok
480
+ end
481
+ @tokens << "]"
482
+ break
483
+ when 0x7D
484
+ # closing delimiter '}'
485
+ if tok.size > 0
486
+ @tokens << tok
487
+ end
488
+ @tokens << "}"
436
489
  break
437
490
  when 0x2F
438
491
  # PDF name, start of new token
439
- @tokens << tok if tok.size > 0
440
- @tokens << byte.chr
441
- @tokens << "" if byte == 0x2F && ([nil, 0x20, 0x0A] + TOKEN_DELIMITER).include?(peek_byte)
442
- tok = "".dup
492
+ if tok.size > 0
493
+ @tokens << tok
494
+ end
495
+ @tokens << "/"
496
+ @tokens << "" if NAME_TERMINATOR_BYTES.include?(peek_byte)
443
497
  break
444
498
  else
445
499
  tok << byte
446
500
  end
447
501
  end
448
-
449
- @tokens << tok if tok.size > 0
450
502
  end
451
503
 
452
504
  # peek at the next character in the io stream, leaving the stream position
@@ -50,6 +50,8 @@ class PDF::Reader
50
50
  # https://en.wikipedia.org/wiki/Universal_Character_Set_characters
51
51
  HIGH_SURROGATE_RANGE = (0xD800..0xDBFF) #: Range[Integer]
52
52
 
53
+ EMPTY_CODEPOINTS = [].freeze #: Array[Integer]
54
+
53
55
  #: Hash[Integer, Array[Integer]]
54
56
  attr_reader :map
55
57
 
@@ -70,7 +72,7 @@ class PDF::Reader
70
72
  #
71
73
  #: (Integer) -> Array[Integer]
72
74
  def decode(c)
73
- @map.fetch(c, [])
75
+ @map.fetch(c, EMPTY_CODEPOINTS)
74
76
  end
75
77
 
76
78
  private
@@ -81,7 +83,7 @@ class PDF::Reader
81
83
  mode = initial_mode
82
84
  instructions = []
83
85
 
84
- while token = parser.parse_token(CMAP_KEYWORDS)
86
+ while token = parser.parse_token
85
87
  if token.is_a?(String) || token.is_a?(Array)
86
88
  if token == "beginbfchar"
87
89
  mode = :char
@@ -108,7 +110,7 @@ class PDF::Reader
108
110
  #: (String) -> PDF::Reader::Parser
109
111
  def build_parser(instructions)
110
112
  buffer = Buffer.new(StringIO.new(instructions))
111
- Parser.new(buffer)
113
+ Parser.new(buffer, operators: CMAP_KEYWORDS, relaxed_dictionaries: true)
112
114
  end
113
115
 
114
116
  # The following includes some manual decoding of UTF-16BE strings into unicode codepoints. In
@@ -175,7 +177,7 @@ class PDF::Reader
175
177
  end_code = str_to_int(end_code).first
176
178
  dst = str_to_int(dst)
177
179
 
178
- return if start_code.nil? || end_code.nil?
180
+ return if start_code.nil? || end_code.nil? || dst.empty?
179
181
 
180
182
  # add all values in the range to our mapping
181
183
  (start_code..end_code).each_with_index do |val, idx|
@@ -34,15 +34,19 @@ class PDF::Reader
34
34
  CONTROL_CHARS = [0,1,2,3,4,5,6,7,8,11,12,14,15,16,17,18,19,20,21,22,23,
35
35
  24,25,26,27,28,29,30,31] #: Array[Integer]
36
36
  UNKNOWN_CHAR = 0x25AF #: Integer # ▯
37
+ DEFAULT_MAPPING = (0..255).each_with_object({}) { |i, h|
38
+ h[i] = CONTROL_CHARS.include?(i) ? UNKNOWN_CHAR : i
39
+ }.freeze #: Hash[Integer, Integer]
40
+
41
+ # Cache mapping files to avoid re-reading and re-parsing the same file for
42
+ # every Encoding that uses it (e.g. many fonts sharing StandardEncoding).
43
+ FILE_MAPPINGS = {} #: Hash[String, Hash[Integer, Integer]]
37
44
 
38
45
  #: String
39
46
  attr_reader :unpack
40
47
 
41
48
  #: (Hash[Symbol, untyped] | Symbol | nil) -> void
42
49
  def initialize(enc)
43
- # maps from character codes to Unicode codepoints
44
- @mapping = default_mapping #: Hash[Integer, Integer]
45
-
46
50
  # maps from character codes to UTF-8 strings.
47
51
  @string_cache = {} #: Hash[Integer, String]
48
52
 
@@ -58,7 +62,15 @@ class PDF::Reader
58
62
  @differences = nil #: Hash[Integer, Integer] | nil
59
63
  @glyphlist = nil #: PDF::Reader::GlyphHash | nil
60
64
 
61
- load_mapping(@map_file) if @map_file
65
+ # maps from character codes to Unicode codepoints
66
+ # If we have a mapping file, use the cached parsed version (loaded once per file)
67
+ if @map_file
68
+ @mapping = (
69
+ FILE_MAPPINGS[@map_file] ||= build_file_mapping(@map_file)
70
+ ).dup #: Hash[Integer, Integer]
71
+ else
72
+ @mapping = DEFAULT_MAPPING.dup
73
+ end #: Hash[Integer, Integer]
62
74
 
63
75
  if enc.is_a?(Hash) && enc[:Differences]
64
76
  self.differences = enc[:Differences]
@@ -149,25 +161,15 @@ class PDF::Reader
149
161
  private
150
162
 
151
163
  # returns a hash that:
152
- # - maps control chars and nil to the unicode "unknown character"
153
- # - leaves all other bytes <= 255 unchaged
154
- #
155
- # Each specific encoding will change this default as required for their glyphs
156
- #: () -> Hash[Integer, Integer]
157
- def default_mapping
158
- all_bytes = (0..255).to_a
159
- tuples = all_bytes.map {|i|
160
- CONTROL_CHARS.include?(i) ? [i, UNKNOWN_CHAR] : [i,i]
161
- }
162
- mapping = Hash[tuples]
163
- mapping
164
- end
165
-
166
164
  #: (Integer) -> String
167
165
  def internal_int_to_utf8_string(glyph_code)
168
- ret = [
169
- @mapping[glyph_code.to_i] || glyph_code.to_i
170
- ].pack("U*")
166
+ codepoint = @mapping[glyph_code.to_i] || glyph_code.to_i
167
+ if valid_unicode_scalar?(codepoint.to_i)
168
+ codepoints = [codepoint]
169
+ else
170
+ codepoints = [UNKNOWN_CHAR]
171
+ end
172
+ ret = codepoints.pack("U*")
171
173
  ret.force_encoding("UTF-8")
172
174
  ret
173
175
  end
@@ -187,11 +189,22 @@ class PDF::Reader
187
189
 
188
190
  #: (String) -> String
189
191
  def convert_to_utf8(str)
190
- ret = str.unpack(unpack).map! { |c| @mapping[c.to_i] || c }.pack("U*")
192
+ ret = str.unpack(unpack).map! { |c|
193
+ codepoint = @mapping[c.to_i] || c
194
+ valid_unicode_scalar?(codepoint.to_i) ? codepoint : UNKNOWN_CHAR
195
+ }.pack("U*")
191
196
  ret.force_encoding("UTF-8")
192
197
  ret
193
198
  end
194
199
 
200
+ # true if the integer is a Unicode scalar value that can be encoded as
201
+ # UTF-8. Surrogates (U+D800..U+DFFF) and code points above U+10FFFF are
202
+ # excluded, as packing them produces invalid UTF-8 byte sequences.
203
+ #: (Integer) -> bool
204
+ def valid_unicode_scalar?(codepoint)
205
+ codepoint.between?(0, 0xD7FF) || codepoint.between?(0xE000, 0x10FFFF)
206
+ end
207
+
195
208
  #: (Symbol) -> String
196
209
  def get_unpack(enc)
197
210
  case enc
@@ -229,14 +242,17 @@ class PDF::Reader
229
242
  @glyphlist ||= PDF::Reader::GlyphHash.new
230
243
  end
231
244
 
232
- #: (String) -> void
233
- def load_mapping(file)
245
+ #: (String) -> Hash[Integer, Integer]
246
+ def build_file_mapping(file)
247
+ mapping = DEFAULT_MAPPING.dup
234
248
  File.open(file, "r:BINARY") do |f|
235
249
  f.each do |l|
236
- _m, single_byte, unicode = *l.match(/\A([0-9A-Za-z]+);([0-9A-F]{4})/)
237
- @mapping["0x#{single_byte}".hex] = "0x#{unicode}".hex if single_byte
250
+ if l =~ /\A([0-9A-Za-z]+);([0-9A-F]{4})/
251
+ mapping[$1.to_i(16)] = $2.to_i(16)
252
+ end
238
253
  end
239
254
  end
255
+ mapping.freeze
240
256
  end
241
257
 
242
258
  end
@@ -0,0 +1,128 @@
1
+ # coding: utf-8
2
+ # typed: strict
3
+ # frozen_string_literal: true
4
+
5
+ module PDF
6
+ class Reader
7
+ # Utiliy class for some conversions to utf8 (the standard output encoding for pdf-reader).
8
+ # This is not used for general purpose of encoding management while parsing PDFs, that lives
9
+ # in PDF::Reader::Encoding
10
+ class EncodingUtils
11
+
12
+ UTF16_BOM = [254, 255] #: Array[Integer]
13
+
14
+ # PDFDocEncoding bytes that map to Unicode codepoints differing from their
15
+ # byte value. All other bytes map directly to their Unicode codepoint value
16
+ # (0x00-0x17/0x20-0x7F: ASCII; 0xA1-0xFF: Latin-1, which PDFDocEncoding
17
+ # matches exactly).
18
+ PDFDOC_CODEPOINTS = {
19
+ 0x18 => 0x02D8, # BREVE
20
+ 0x19 => 0x02C7, # CARON
21
+ 0x1A => 0x02C6, # MODIFIER LETTER CIRCUMFLEX ACCENT
22
+ 0x1B => 0x02D9, # DOT ABOVE
23
+ 0x1C => 0x02DD, # DOUBLE ACUTE ACCENT
24
+ 0x1D => 0x02DB, # OGONEK
25
+ 0x1E => 0x02DA, # RING ABOVE
26
+ 0x1F => 0x02DC, # SMALL TILDE
27
+ 0x80 => 0x2022, # BULLET
28
+ 0x81 => 0x2020, # DAGGER
29
+ 0x82 => 0x2021, # DOUBLE DAGGER
30
+ 0x83 => 0x2026, # HORIZONTAL ELLIPSIS
31
+ 0x84 => 0x2014, # EM DASH
32
+ 0x85 => 0x2013, # EN DASH
33
+ 0x86 => 0x0192, # LATIN SMALL LETTER F WITH HOOK
34
+ 0x87 => 0x2044, # FRACTION SLASH
35
+ 0x88 => 0x2039, # SINGLE LEFT-POINTING ANGLE QUOTATION MARK
36
+ 0x89 => 0x203A, # SINGLE RIGHT-POINTING ANGLE QUOTATION MARK
37
+ 0x8A => 0x2212, # MINUS SIGN
38
+ 0x8B => 0x2030, # PER MILLE SIGN
39
+ 0x8C => 0x201E, # DOUBLE LOW-9 QUOTATION MARK
40
+ 0x8D => 0x201C, # LEFT DOUBLE QUOTATION MARK
41
+ 0x8E => 0x201D, # RIGHT DOUBLE QUOTATION MARK
42
+ 0x8F => 0x2018, # LEFT SINGLE QUOTATION MARK
43
+ 0x90 => 0x2019, # RIGHT SINGLE QUOTATION MARK
44
+ 0x91 => 0x201A, # SINGLE LOW-9 QUOTATION MARK
45
+ 0x92 => 0x2122, # TRADE MARK SIGN
46
+ 0x93 => 0xFB01, # LATIN SMALL LIGATURE FI
47
+ 0x94 => 0xFB02, # LATIN SMALL LIGATURE FL
48
+ 0x95 => 0x0141, # LATIN CAPITAL LETTER L WITH STROKE
49
+ 0x96 => 0x0152, # LATIN CAPITAL LIGATURE OE
50
+ 0x97 => 0x0160, # LATIN CAPITAL LETTER S WITH CARON
51
+ 0x98 => 0x0178, # LATIN CAPITAL LETTER Y WITH DIAERESIS
52
+ 0x99 => 0x017D, # LATIN CAPITAL LETTER Z WITH CARON
53
+ 0x9A => 0x0131, # LATIN SMALL LETTER DOTLESS I
54
+ 0x9B => 0x0142, # LATIN SMALL LETTER L WITH STROKE
55
+ 0x9C => 0x0153, # LATIN SMALL LIGATURE OE
56
+ 0x9D => 0x0161, # LATIN SMALL LETTER S WITH CARON
57
+ 0x9E => 0x017E, # LATIN SMALL LETTER Z WITH CARON
58
+ 0xA0 => 0x20AC, # EURO SIGN
59
+ }.freeze #: Hash[Integer, Integer]
60
+
61
+ #: (untyped) -> untyped
62
+ def self.obj_to_utf8(obj)
63
+ new.obj_to_utf8(obj)
64
+ end
65
+
66
+ #: (String) -> String
67
+ def self.string_to_utf8(str)
68
+ new.string_to_utf8(str)
69
+ end
70
+
71
+ # Recursively convert Hashes, Arrays, and Strings to UTF-8
72
+ #
73
+ #: (untyped) -> untyped
74
+ def obj_to_utf8(obj)
75
+ case obj
76
+ when ::Hash then
77
+ {}.tap { |new_hash|
78
+ obj.each do |key, value|
79
+ new_hash[key] = obj_to_utf8(value)
80
+ end
81
+ }
82
+ when Array then
83
+ obj.map { |item| obj_to_utf8(item) }
84
+ when String then
85
+ string_to_utf8(obj)
86
+ else
87
+ obj
88
+ end
89
+ end
90
+
91
+ # Convert a String to UTF-8
92
+ #
93
+ #: (String) -> String
94
+ def string_to_utf8(str)
95
+ if has_utf16_bom?(str)
96
+ utf16_to_utf8(str)
97
+ else
98
+ pdfdoc_to_utf8(str)
99
+ end
100
+ end
101
+
102
+ private
103
+
104
+ #: (String) -> bool
105
+ def has_utf16_bom?(str)
106
+ first_bytes = str[0,2]
107
+
108
+ return false if first_bytes.nil?
109
+
110
+ first_bytes.unpack("C*") == UTF16_BOM
111
+ end
112
+
113
+ #: (String) -> String
114
+ def pdfdoc_to_utf8(str)
115
+ str.bytes.map { |b| PDFDOC_CODEPOINTS[b] || b }.pack("U*")
116
+ end
117
+
118
+ #: (String) -> String
119
+ def utf16_to_utf8(obj)
120
+ obj.dup.force_encoding(
121
+ ::Encoding::UTF_16
122
+ ).encode(
123
+ ::Encoding::UTF_8, invalid: :replace, replace: "\uFFFD"
124
+ )
125
+ end
126
+ end
127
+ end
128
+ end
@@ -33,16 +33,26 @@ class PDF::Reader
33
33
  ################################################################################
34
34
  #: (untyped, untyped, ?untyped) -> untyped
35
35
  def self.str_assert(lvalue, rvalue, chars=nil)
36
- raise MalformedPDFError, "PDF malformed, expected string but found #{lvalue.class} instead" if chars and !lvalue.kind_of?(String)
36
+ if chars and !lvalue.kind_of?(String)
37
+ raise MalformedPDFError, "PDF malformed, expected string but found #{lvalue.class} instead"
38
+ end
37
39
  lvalue = lvalue[0,chars] if chars
38
- raise MalformedPDFError, "PDF malformed, expected '#{rvalue}' but found '#{lvalue}' instead" if lvalue != rvalue
40
+
41
+ if lvalue != rvalue
42
+ raise MalformedPDFError, "PDF malformed, expected '#{rvalue}' but found '#{lvalue}' instead"
43
+ end
39
44
  end
40
45
  ################################################################################
41
46
  #: (untyped, untyped, ?untyped) -> untyped
42
47
  def self.str_assert_not(lvalue, rvalue, chars=nil)
43
- raise MalformedPDFError, "PDF malformed, expected string but found #{lvalue.class} instead" if chars and !lvalue.kind_of?(String)
48
+ if chars and !lvalue.kind_of?(String)
49
+ raise MalformedPDFError, "PDF malformed, expected string but found #{lvalue.class} instead"
50
+ end
44
51
  lvalue = lvalue[0,chars] if chars
45
- raise MalformedPDFError, "PDF malformed, expected '#{rvalue}' but found '#{lvalue}' instead" if lvalue == rvalue
52
+
53
+ if lvalue == rvalue
54
+ raise MalformedPDFError, "PDF malformed, expected '#{rvalue}' but found '#{lvalue}' instead"
55
+ end
46
56
  end
47
57
  ################################################################################
48
58
  #: (untyped, untyped) -> untyped
@@ -50,12 +60,12 @@ class PDF::Reader
50
60
  raise MalformedPDFError, "PDF malformed, expected '#{rvalue}' but found '#{lvalue}' instead" if lvalue != rvalue
51
61
  end
52
62
  ################################################################################
53
- #: (Object, String, Module) -> void
63
+ #: (Object, String, Module[untyped]) -> void
54
64
  def self.validate_type(object, name, klass)
55
65
  raise ArgumentError, "#{name} (#{object}) must be a #{klass}" unless object.is_a?(klass)
56
66
  end
57
67
  ################################################################################
58
- #: (Object, String, Module) -> void
68
+ #: (Object, String, Module[untyped]) -> void
59
69
  def self.validate_type_as_malformed(object, name, klass)
60
70
  raise MalformedPDFError, "#{name} (#{object}) must be a #{klass}" unless object.is_a?(klass)
61
71
  end
@@ -97,12 +97,21 @@ class PDF::Reader
97
97
  extract_descriptor(obj)
98
98
  extract_descendants(obj)
99
99
  @width_calc = build_width_calculator #: widthCalculator
100
+ @utf8_cache = {} #: Hash[Integer, String]
100
101
  end
101
102
 
102
103
  #: (Integer | String | Array[Integer | String]) -> String
103
104
  def to_utf8(params)
104
105
  if @tounicode
105
- to_utf8_via_cmap(params, @tounicode)
106
+ if params.is_a?(Integer)
107
+ cached = @utf8_cache[params]
108
+ return cached unless cached.nil?
109
+ result = to_utf8_via_cmap(params, @tounicode)
110
+ @utf8_cache[params] = result
111
+ result
112
+ else
113
+ to_utf8_via_cmap(params, @tounicode)
114
+ end
106
115
  else
107
116
  to_utf8_via_encoding(params)
108
117
  end
@@ -134,9 +143,9 @@ class PDF::Reader
134
143
  glyph_width_in_glyph_space = glyph_width(code_point)
135
144
 
136
145
  if @subtype == :Type3
137
- x1, _y1 = font_matrix_transform(0,0)
138
- x2, _y2 = font_matrix_transform(glyph_width_in_glyph_space, 0)
139
- (x2 - x1).abs.round(2)
146
+ pt1 = font_matrix_transform(Point::ZERO_ZERO)
147
+ pt2 = font_matrix_transform(Point.new(glyph_width_in_glyph_space, 0))
148
+ (pt2.x - pt1.x).abs.round(2)
140
149
  else
141
150
  glyph_width_in_glyph_space / 1000.0
142
151
  end
@@ -145,9 +154,13 @@ class PDF::Reader
145
154
  private
146
155
 
147
156
  # Only valid for Type3 fonts
148
- #: (Numeric, Numeric) -> [Numeric, Numeric]
149
- def font_matrix_transform(x, y)
150
- return x, y if @font_matrix.nil?
157
+ #
158
+ # transforming (0,0) is a really common case, so optimise for it to
159
+ # avoid unnecessary object allocations
160
+ #
161
+ #: (PDF::Reader::Point) -> PDF::Reader::Point
162
+ def font_matrix_transform(pt)
163
+ return pt if @font_matrix.nil?
151
164
 
152
165
  matrix = TransformationMatrix.new(
153
166
  @font_matrix[0] || 0, @font_matrix[1] || 0,
@@ -155,13 +168,13 @@ class PDF::Reader
155
168
  @font_matrix[4] || 0, @font_matrix[5] || 0,
156
169
  )
157
170
 
158
- if x == 0 && y == 0
159
- [matrix.e, matrix.f]
171
+ if pt.x == 0 && pt.y == 0
172
+ Point.new(matrix.e, matrix.f)
160
173
  else
161
- [
162
- (matrix.a * x) + (matrix.c * y) + (matrix.e),
163
- (matrix.b * x) + (matrix.d * y) + (matrix.f)
164
- ]
174
+ Point.new(
175
+ (matrix.a * pt.x) + (matrix.c * pt.y) + (matrix.e),
176
+ (matrix.b * pt.x) + (matrix.d * pt.y) + (matrix.f)
177
+ )
165
178
  end
166
179
  end
167
180
 
@@ -290,13 +303,11 @@ class PDF::Reader
290
303
  def to_utf8_via_cmap(params, cmap)
291
304
  case params
292
305
  when Integer
293
- [
294
- cmap.decode(params)
295
- ].flatten.pack("U*")
306
+ cmap.decode(params).pack("U*")
296
307
  when String
297
- unpack_string_to_array_of_ints(params, encoding.unpack).map { |code_point|
308
+ unpack_string_to_array_of_ints(params, encoding.unpack).flat_map { |code_point|
298
309
  cmap.decode(code_point)
299
- }.flatten.pack("U*")
310
+ }.pack("U*")
300
311
  when Array
301
312
  params.collect { |param| to_utf8_via_cmap(param, cmap) }.join("")
302
313
  end