pdfrb 0.8.11 → 0.8.12

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: ff9335841bf5176c9489855fe26937637022fb139e2b212e6f5b4aba351aca21
4
- data.tar.gz: cbeaed5651257801ed201b45af40b510a28cdf65934b7d18b3347c7372ec5cfa
3
+ metadata.gz: 20aa9dfc7f1144740e4138b85909b2434e1652fcc88ea6d83445d0e76c40f68b
4
+ data.tar.gz: 8384f5165e823cc2077b290db27d74c2534b5f53951c4afa6d61a4eec277c340
5
5
  SHA512:
6
- metadata.gz: 5bd0ad5e3e50563ce7aeb42990fcae8e830fefd8cb10cc5270d6f281b570a620ab5bfa5e8df8c08aaa6fc8252b0e5e395a447e050a853fade8d75dd1fa6f99b1
7
- data.tar.gz: c3e35e0a4d2b44e723f171aafcc3a807f399e5b00041e52fb423658654849064047190f35e1d84abba9259f37f4cbfe48b69ab2eff56dfedf577c0f600c977e2
6
+ metadata.gz: e988fa5b3a1e691b49ebb716311daa0a30d67d1b4c20a1e18e4d32d827f791ffd3931abd7250fcd4732cb4ad53cd92f45b84ac030b380f6d8e32de4edd52f1aa
7
+ data.tar.gz: 9d280097604c785c8c39aee8defb1fb7979901cec4b965e4b76d8b553d5d620036a7efb2925cd7b473baf72538662aa17c0c1f0217f67d58efd7ba4b3d3185e6
data/CHANGELOG.md CHANGED
@@ -2,6 +2,160 @@
2
2
 
3
3
  All notable changes to the pdfrb gem will be documented in this file.
4
4
 
5
+ ## [0.8.11] — 2026-08-31
6
+
7
+ ### Added
8
+
9
+ * **Outline reading** — `Document::Outline#each` / `#to_a` walk an
10
+ existing outline depth-first (preorder), yielding typed
11
+ `Model::Type::OutlineItem` objects (title, count, destination,
12
+ bold/italic flags); `#empty?` for outline-less documents.
13
+ `OutlineItem#title` decodes via the shared TextString seam.
14
+
15
+ ## [0.8.10] — 2026-08-31
16
+
17
+ ### Added
18
+
19
+ * **Text-string decoding at every semantic accessor** — the shared
20
+ `Model::Cos::TextString.decode` seam now serves
21
+ `Annotation#contents`/`#name`, the `FormField` accessors, the
22
+ `Form` facade (`find`/`field_names`/`get_value`), and
23
+ `Document::Metadata#read_field`. UTF-16BE+BOM and
24
+ PDFDocEncoding bytes read back as UTF-8; non-strings pass through.
25
+
26
+ ## [0.8.9] — 2026-08-31
27
+
28
+ ### Fixed
29
+
30
+ * `Document::Metadata#read_field` relabelled string bytes with
31
+ `force_encoding("UTF-8")`, which never converts — UTF-16 titles
32
+ read back as garbage. Info values are text strings (s7.9.2.2) and
33
+ now decode properly.
34
+
35
+ ## [0.8.8] — 2026-08-31
36
+
37
+ ### Added
38
+
39
+ * **Read-path decryption** — `ObjectReader#load_oid` decrypts
40
+ strings and stream payloads with the per-object key (s7.6.1) as
41
+ objects enter the Model, via a memoized
42
+ `Document::Encryption#reader_handler` that verifies the configured
43
+ password and raises `EncryptionError` when it does not verify.
44
+ Round trips now close at 256/128/40-bit.
45
+
46
+ ### Fixed
47
+
48
+ * `pad_password` sliced the pad string at the password length
49
+ instead of its head (Algorithm 3.2), so handler-derived keys
50
+ diverged from `PasswordVerification`'s.
51
+ * Algorithm 3.5 existed twice with different RC4 round sets
52
+ (writer 0..18, verifier 19..1; the spec runs 1..19 ascending);
53
+ one implementation now serves both, and R2 verification compares
54
+ instead of returning true unconditionally.
55
+ * V4 /Encrypt dicts declared no `CF`/`StmF`/`StrF`, but absent
56
+ filters default to Identity (s7.6.3.2 Table 21) — conforming
57
+ readers saw AES ciphertext as cleartext. V4 now declares AESV2.
58
+ * The cipher is chosen from the resolved CFM (AESV2/V2/Identity),
59
+ direction-aware (`encrypt_string`/`encrypt_stream` + decrypt
60
+ twins), instead of `V >= 4` always meaning AES.
61
+ * AES helpers hard-coded aes-128-cbc and truncated 32-byte V5 object
62
+ keys to 16 bytes; the cipher now follows the key size.
63
+ * Cross-reference streams are never encrypted; the writer now skips
64
+ them alongside the /Encrypt dictionary.
65
+
66
+ **Compatibility:** 128-bit files written by 0.8.7 will not decrypt
67
+ under 0.8.8 (they were non-interoperable outside pdfrb); re-encrypt.
68
+
69
+ ## [0.8.7] — 2026-08-30
70
+
71
+ ### Fixed
72
+
73
+ * **Silent plaintext writes** — the Writer's encrypter construction
74
+ rescued `StandardError` to `{}`, so any handler failure (wrong
75
+ password, unsupported filter) wrote encrypted documents as
76
+ plaintext. Behind that rescue: calls to a nonexistent
77
+ `verify_user_password` and keyword-args construction where the
78
+ handler takes a positional trailer hash. A wrong password now
79
+ raises `EncryptionError`.
80
+
81
+ ### Changed
82
+
83
+ * `Writer.serializer_for(document)` is the single Serializer factory
84
+ (compression + encrypter); `Linearization::Writer` shares it
85
+ instead of a bare serializer that emitted encrypted documents
86
+ unencrypted.
87
+ * `serialize_indirect` encrypts dictionary strings with the
88
+ per-object key (s7.6.1), not just stream payloads; the /Encrypt
89
+ dictionary stays cleartext.
90
+ * Linearized trailers carry /Encrypt and /ID; the hint stream no
91
+ longer claims a fictitious `/Type /XRef`.
92
+ * Dead autoloads for nonexistent `Revision`/`Revisions` removed;
93
+ `DigitalSignature.handler_for` resolves handler classes lazily.
94
+
95
+ ## [0.8.6] — 2026-08-30
96
+
97
+ ### Fixed
98
+
99
+ * **Canvas `concat` with a block emitted `SaveGraphicsState` in its
100
+ `ensure` instead of `Restore`** — every block-form
101
+ translate/scale/rotate left an unbalanced `q` on the graphics
102
+ state stack. The block form now scopes the matrix inside
103
+ `save_graphics_state`; `with_transparency` reuses the same seam;
104
+ the draw_image trio folds onto one `invoke_xobject`.
105
+
106
+ ### Added
107
+
108
+ * `Pdfrb::PdfVersion.compare` / `.at_least?` (extracted from
109
+ byte-identical private copies in Writer and Conformance::PdfX).
110
+ * `Document#each_indirect_of(klass)` — type-filtered object
111
+ iteration, Enumerator when blockless; 15 mechanical call sites
112
+ migrated.
113
+
114
+ ## [0.8.5] — 2026-08-29
115
+
116
+ ### Changed
117
+
118
+ * Document/Font seams deepened: `Document#resolve` (dereference),
119
+ `Model::Object#ref`, `Pages#attach_resource`,
120
+ `Document::Fonts::Subsetting`, shared `Pdfrb::Font::Sfnt` module
121
+ (sfnt directory parsing/rebuild with real checksums) used by both
122
+ the TrueType and CFF subsetters.
123
+
124
+ ### Fixed
125
+
126
+ * TrueType subsetting regression: `rubocop -A` had rewritten
127
+ `sorted.each do |_tag, data|` on an Array to `each_value`,
128
+ silently falling back to the full font (773KB instead of 205KB).
129
+
130
+ ## [0.8.4] — 2026-08-29
131
+
132
+ ### Changed
133
+
134
+ * `Model::Type::NameMap` concern extracted from 17 hand-duplicated
135
+ resource-map classes (symbol-or-string `[]`, normalizing `add`,
136
+ `names`, `each_entry`).
137
+
138
+ ## [0.8.3] — 2026-08-28
139
+
140
+ ### Added
141
+
142
+ * PDF/A generation section in the executable USAGE cookbook
143
+ (`spec/pdfrb/usage_doc_spec.rb` runs every snippet).
144
+
145
+ ## [0.8.2] — 2026-08-28
146
+
147
+ ### Added
148
+
149
+ * veraPDF round-trip sweep over the fixture corpus.
150
+
151
+ ## [0.8.1] — 2026-08-27
152
+
153
+ ### Added
154
+
155
+ * veraPDF cross-check integration (local `rake verapdf` + macOS CI
156
+ job) with PDF/A conformance fixes: trailer /ID, explicit page
157
+ /Resources, parseable XMP.
158
+
5
159
  ## [0.8.0] — 2026-08-27
6
160
 
7
161
  ### Added
data/docs/USAGE.md CHANGED
@@ -153,18 +153,42 @@ File.binwrite("linearized.pdf", io.string)
153
153
  ```ruby
154
154
  doc = Pdfrb::Document.new
155
155
  doc.pages.add
156
+ doc.metadata[:Title] = "Confidential"
156
157
  doc.encrypt!(user_password: "secret", owner_password: "owner", bits: 128)
157
158
  doc.write("encrypted.pdf")
158
159
 
159
- # Opening an encrypted document:
160
+ # Opening an encrypted document — strings and streams decrypt on
161
+ # read; a wrong password raises Pdfrb::EncryptionError:
160
162
  doc = Pdfrb.open("encrypted.pdf",
161
163
  config: { "encryption.password" => "secret" })
164
+ doc.metadata[:Title] # => "Confidential"
162
165
 
163
166
  # Removing encryption from a parsed document:
164
167
  doc.decrypt!
165
168
  doc.write("decrypted.pdf")
166
169
  ```
167
170
 
171
+ ## Bookmarks (Outlines)
172
+
173
+ ```ruby
174
+ doc = Pdfrb::Document.new
175
+ page = doc.pages.add
176
+ font = doc.fonts.add("Helvetica")
177
+ page.canvas.text("Intro", at: [72, 720], font: font, size: 12)
178
+
179
+ chapter = doc.outline.add("Chapter 1", dest: :xyz)
180
+ chapter.add_child(Pdfrb::Document::OutlineEntry.new(title: "1.1", dest: :xyz))
181
+ doc.outline.add("Chapter 2", dest: :xyz)
182
+ doc.outline.build!
183
+ doc.write("bookmarked.pdf")
184
+
185
+ # Reading an outline back (depth-first, parents before children):
186
+ doc = Pdfrb.open("bookmarked.pdf")
187
+ doc.outline.each do |item|
188
+ puts item.title # "Chapter 1", "1.1", "Chapter 2"
189
+ end
190
+ ```
191
+
168
192
  ## Merging PDFs
169
193
 
170
194
  ```ruby
@@ -68,33 +68,60 @@ module Pdfrb
68
68
  hex_start = sig_info[:contents_hex_start]
69
69
  hex_end = sig_info[:contents_hex_end]
70
70
  contents_hex = pdf_bytes[hex_start...hex_end]
71
- contents_hex = contents_hex.sub(/0+$/, "")
72
-
73
- begin
74
- der = [contents_hex].pack("H*")
75
- pkcs7 = OpenSSL::PKCS7.new(der)
76
-
77
- store = OpenSSL::X509::Store.new
78
- trusted_certs.each { |c| store.add_cert(c) }
79
-
80
- valid = pkcs7.verify(nil, store, signed_data,
81
- OpenSSL::PKCS7::DETACHED |
82
- OpenSSL::PKCS7::BINARY)
83
-
84
- VerificationResult.new(
85
- signer: pkcs7.signers.first&.issuer&.to_s,
86
- valid?: valid,
87
- byte_range_ok?: true,
88
- cert_chain: pkcs7.certificates || [],
89
- trusted?: !trusted_certs.empty? && valid,
90
- error: valid ? nil : "signature verification failed",
91
- )
92
- rescue OpenSSL::PKCS7::PKCS7Error, ArgumentError => e
93
- VerificationResult.new(valid?: false,
94
- byte_range_ok?: true,
95
- cert_chain: [],
96
- error: e.message)
71
+
72
+ # The /Contents placeholder is zero-padded to its reserved
73
+ # size; DER is self-delimiting, so der_prefix reads the
74
+ # declared length instead of guessing where the signature
75
+ # ends (blindly trimming trailing zeros also eats legitimate
76
+ # 0x00 final bytes and can split a hex byte).
77
+ verify_pkcs7(der_prefix([contents_hex].pack("H*")),
78
+ signed_data, trusted_certs)
79
+ end
80
+
81
+ def verify_pkcs7(der, signed_data, trusted_certs)
82
+ pkcs7 = OpenSSL::PKCS7.new(der)
83
+
84
+ store = OpenSSL::X509::Store.new
85
+ trusted_certs.each { |c| store.add_cert(c) }
86
+
87
+ valid = pkcs7.verify(nil, store, signed_data,
88
+ OpenSSL::PKCS7::DETACHED |
89
+ OpenSSL::PKCS7::BINARY)
90
+
91
+ VerificationResult.new(
92
+ signer: pkcs7.signers.first&.issuer&.to_s,
93
+ valid?: valid,
94
+ byte_range_ok?: true,
95
+ cert_chain: pkcs7.certificates || [],
96
+ trusted?: !trusted_certs.empty? && valid,
97
+ error: valid ? nil : "signature verification failed",
98
+ )
99
+ rescue OpenSSL::PKCS7::PKCS7Error, ArgumentError => e
100
+ VerificationResult.new(valid?: false,
101
+ byte_range_ok?: true,
102
+ cert_chain: [],
103
+ error: e.message)
104
+ end
105
+
106
+ # The DER structure starting at +bytes+ (a SEQUENCE), exactly
107
+ # as long as its own declared length - any placeholder padding
108
+ # after it is dropped. Non-SEQUENCE input passes through.
109
+ def der_prefix(bytes)
110
+ return bytes if bytes.bytesize < 2 || bytes.getbyte(0) != 0x30
111
+
112
+ length = bytes.getbyte(1)
113
+ offset = 2
114
+ if length.anybits?(0x80)
115
+ count = length & 0x7F
116
+ return bytes if count.zero? || bytes.bytesize < 2 + count
117
+
118
+ length = 0
119
+ count.times do |i|
120
+ length = (length << 8) | bytes.getbyte(2 + i)
121
+ end
122
+ offset = 2 + count
97
123
  end
124
+ bytes.byteslice(0, offset + length)
98
125
  end
99
126
  end
100
127
  end
data/lib/pdfrb/version.rb CHANGED
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Pdfrb
4
- VERSION = "0.8.11"
4
+ VERSION = "0.8.12"
5
5
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: pdfrb
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.8.11
4
+ version: 0.8.12
5
5
  platform: ruby
6
6
  authors:
7
7
  - Ribose Inc.
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-08-31 00:00:00.000000000 Z
11
+ date: 2026-09-02 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: benchmark
@@ -1276,7 +1276,6 @@ files:
1276
1276
  - lib/pdfrb/source/recovery.rb
1277
1277
  - lib/pdfrb/source/token.rb
1278
1278
  - lib/pdfrb/source/tokenizer.rb
1279
- - lib/pdfrb/source/tokenizer.rb.bak
1280
1279
  - lib/pdfrb/source/trailer_reader.rb
1281
1280
  - lib/pdfrb/source/xref_stream_reader.rb
1282
1281
  - lib/pdfrb/source/xref_table_reader.rb
@@ -1,320 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- require "set"
4
-
5
- module Pdfrb
6
- module Source
7
- # Byte-level PDF lexer (s7.2). State machine that emits Token
8
- # values via +next_token+ / +peek+. Pull-based so the Parser can
9
- # stream through arbitrarily large PDFs without materialising the
10
- # whole token stream.
11
- #
12
- # States:
13
- # :top — between tokens; dispatch by first byte.
14
- # :string — inside (...) with depth tracking and \ escapes.
15
- # :hexstring — inside <...> (after distinguishing << as dict open).
16
- # :comment — inside %... until EOL.
17
- #
18
- # The keyword set per s7.2: obj/endobj/stream/endstream/xref/
19
- # startxref/trailer/true/false/null. The Tokenizer emits them with
20
- # type :keyword and value equal to the keyword string; the Parser
21
- # dispatches on the value.
22
- class Tokenizer
23
- WHITESPACE_BYTES = PdfConstants::WHITESPACE.bytes.to_set.freeze
24
- DELIMITER_BYTES = PdfConstants::DELIMITERS.bytes.to_set.freeze
25
-
26
- # Public so the Parser can recognise the same set when scanning
27
- # post-stream whitespace.
28
- public_constant :WHITESPACE_BYTES
29
- public_constant :DELIMITER_BYTES
30
-
31
- attr_reader :pos, :io
32
-
33
- def initialize(io)
34
- @io = io
35
- @pos = 0
36
- @pushback = []
37
- @lookahead = []
38
- end
39
-
40
- def next_token
41
- return @pushback.pop unless @pushback.empty?
42
-
43
- fill_lookahead(1) if @lookahead.empty?
44
- @lookahead.shift
45
- end
46
-
47
- def peek(offset = 0)
48
- fill_lookahead(offset + 1)
49
- @lookahead[offset]
50
- end
51
-
52
- def pushback(token)
53
- @pushback << token
54
- self
55
- end
56
-
57
- private
58
-
59
- def fill_lookahead(n)
60
- while @lookahead.length < n
61
- tok = lex_one
62
- break if tok.nil?
63
-
64
- @lookahead << tok
65
- end
66
- end
67
-
68
- def lex_one
69
- skip_whitespace_and_comments
70
- return nil if eos?
71
-
72
- start_pos = @pos
73
- b = peek_byte
74
- case b
75
- when 40 then read_string(start_pos)
76
- when 60
77
- peek_byte(1) == 60 ? read_two_byte_token(:dict_open, "<<", start_pos) : read_hex_string(start_pos)
78
- when 62
79
- peek_byte(1) == 62 ? read_two_byte_token(:dict_close, ">>", start_pos) : raise(lex_error("lone '>' at #{start_pos}"))
80
- when 91 then advance_byte && emit(:array_open, "[", start_pos)
81
- when 93 then advance_byte && emit(:array_close, "]", start_pos)
82
- when 47 then read_name(start_pos)
83
- when 43, 45, 46, *digit_bytes then read_number_or_keyword(start_pos)
84
- else read_keyword(start_pos)
85
- end
86
- end
87
-
88
- def digit_bytes
89
- (48..57).to_a
90
- end
91
- private :digit_bytes
92
-
93
- def eos?
94
- @io.eof?
95
- end
96
-
97
- def peek_byte(offset = 0)
98
- cur = @io.pos
99
- if offset.zero?
100
- b = @io.getbyte
101
- @io.seek(cur, IO::SEEK_SET) if b
102
- return b
103
- end
104
-
105
- @io.seek(cur + offset, IO::SEEK_SET)
106
- b = @io.getbyte
107
- @io.seek(cur, IO::SEEK_SET)
108
- b
109
- end
110
-
111
- def advance_byte
112
- @pos += 1
113
- @io.getbyte
114
- end
115
-
116
- # Append an Integer byte to +buf+ without re-encoding through
117
- # UTF-8 (which is the default for `+""` in a UTF-8 source file).
118
- def append_byte(buf, b)
119
- buf << b.chr(Encoding::BINARY)
120
- end
121
-
122
- def skip_whitespace_and_comments
123
- loop do
124
- break if eos?
125
-
126
- b = peek_byte
127
- if WHITESPACE_BYTES.include?(b)
128
- advance_byte
129
- elsif b == 37 # %
130
- skip_comment
131
- else
132
- break
133
- end
134
- end
135
- end
136
-
137
- def skip_comment
138
- until eos? || peek_byte == 10 # \n
139
- advance_byte
140
- end
141
- advance_byte if !eos? && peek_byte == 10
142
- nil
143
- end
144
-
145
- def read_two_byte_token(type, literal, start_pos)
146
- advance_byte
147
- advance_byte
148
- emit(type, literal, start_pos)
149
- end
150
-
151
- def read_string(start_pos)
152
- advance_byte # consume (
153
- depth = 1
154
- bytes = +""
155
- until eos?
156
- b = advance_byte
157
- case b
158
- when 92 # backslash
159
- esc = read_string_escape
160
- bytes << esc if esc >= 0
161
- when 40 # (
162
- depth += 1
163
- bytes << 40
164
- when 41 # )
165
- depth -= 1
166
- break if depth.zero?
167
-
168
- bytes << 41
169
- else
170
- bytes << b
171
- end
172
- end
173
- raise lex_error("unterminated string at #{start_pos}") if depth.positive?
174
-
175
- emit(:string, bytes.force_encoding(Encoding::BINARY), start_pos)
176
- end
177
-
178
- def read_string_escape
179
- b = advance_byte
180
- case b
181
- when 110 then 10 # \n
182
- when 114 then 13 # \r
183
- when 116 then 9 # \t
184
- when 98 then 8 # \b
185
- when 102 then 12 # \f
186
- when 40 then 40 # \(
187
- when 41 then 41 # \)
188
- when 92 then 92 # \\
189
- when 48..55 then read_octal_escape(b)
190
- when 10, 13
191
- # Line continuation — eat CRLF/LF/CR; emit no byte.
192
- advance_byte if b == 13 && peek_byte == 10
193
- -1
194
- else b
195
- end
196
- end
197
-
198
- def read_octal_escape(first)
199
- digits = [first]
200
- 2.times do
201
- break if eos?
202
-
203
- b = peek_byte
204
- break unless (48..55).cover?(b)
205
-
206
- digits << advance_byte
207
- end
208
- digits.map(&:chr).join.to_i(8) & 0xFF
209
- end
210
-
211
- def read_hex_string(start_pos)
212
- advance_byte # consume <
213
- bytes = +""
214
- hi = nil
215
- until eos?
216
- b = advance_byte
217
- break if b == 62 # >
218
-
219
- next if WHITESPACE_BYTES.include?(b)
220
-
221
- nibble = hex_value(b)
222
- raise lex_error("bad hex digit #{b.chr.inspect} at #{@pos}") unless nibble
223
-
224
- if hi.nil?
225
- hi = nibble
226
- else
227
- bytes << (hi * 16 + nibble)
228
- hi = nil
229
- end
230
- end
231
- bytes << (hi * 16 + 0) if hi # odd nibble padded with 0
232
- emit(:hex_string, bytes.force_encoding(Encoding::BINARY), start_pos)
233
- end
234
-
235
- def hex_value(b)
236
- return (b - 48) if (48..57).cover?(b)
237
- return (b - 55) if (65..70).cover?(b)
238
- return (b - 87) if (97..102).cover?(b)
239
-
240
- nil
241
- end
242
-
243
- def read_name(start_pos)
244
- advance_byte # consume /
245
- bytes = +""
246
- until eos?
247
- b = peek_byte
248
- break if WHITESPACE_BYTES.include?(b) || DELIMITER_BYTES.include?(b)
249
-
250
- advance_byte
251
- if b == 35 # # — expect two hex digits
252
- hi = peek_byte
253
- lo = peek_byte(1)
254
- if hi && lo && (n1 = hex_value(hi)) && (n2 = hex_value(lo))
255
- advance_byte; advance_byte
256
- bytes << (n1 * 16 + n2)
257
- else
258
- bytes << 35
259
- end
260
- else
261
- bytes << b
262
- end
263
- end
264
- emit(:name, bytes.force_encoding(Encoding::BINARY), start_pos)
265
- end
266
-
267
- def read_number_or_keyword(start_pos)
268
- bytes = +""
269
- bytes << advance_byte # first char
270
- until eos?
271
- b = peek_byte
272
- break if WHITESPACE_BYTES.include?(b) || DELIMITER_BYTES.include?(b)
273
-
274
- advance_byte
275
- bytes << b
276
- end
277
- emit_number_or_keyword(bytes, start_pos)
278
- end
279
-
280
- def read_keyword(start_pos)
281
- bytes = +""
282
- until eos?
283
- b = peek_byte
284
- break if WHITESPACE_BYTES.include?(b) || DELIMITER_BYTES.include?(b)
285
-
286
- advance_byte
287
- bytes << b
288
- end
289
- emit_keyword(bytes, start_pos)
290
- end
291
-
292
- def emit_number_or_keyword(bytes, start_pos)
293
- if bytes.match?(/\A[-+]?\d+\z/)
294
- emit(:integer, bytes.to_i, start_pos)
295
- elsif bytes.match?(/\A[-+]?(\d*\.\d+|\d+\.?\d*)(?:[eE][-+]?\d+)?\z/) && (bytes.include?(".") || bytes =~ /[eE]/)
296
- emit(:real, bytes.to_f, start_pos)
297
- else
298
- emit_keyword(bytes, start_pos)
299
- end
300
- end
301
-
302
- def emit_keyword(bytes, start_pos)
303
- case bytes
304
- when "true" then emit(:true, true, start_pos)
305
- when "false" then emit(:false, false, start_pos)
306
- when "null" then emit(:null, nil, start_pos)
307
- else emit(:keyword, bytes, start_pos)
308
- end
309
- end
310
-
311
- def emit(type, value, position)
312
- Token.new(type: type, value: value, position: position)
313
- end
314
-
315
- def lex_error(message)
316
- Pdfrb::LexError.new(message, source_position: @pos)
317
- end
318
- end
319
- end
320
- end