Skip to content

Commit 0aa3b93

Browse files
committed
fix: preserve object stream compatibility
1 parent 2f35243 commit 0aa3b93

2 files changed

Lines changed: 208 additions & 30 deletions

File tree

src/Smalot/PdfParser/Parser.php

Lines changed: 114 additions & 30 deletions
Original file line numberDiff line numberDiff line change
@@ -150,6 +150,7 @@ protected function parseTrailer(array $structure, ?Document $document)
150150
protected function parseObject(string $id, array $structure, ?Document $document)
151151
{
152152
$header = new Header([], $document);
153+
$headerStructure = [];
153154
$content = '';
154155

155156
foreach ($structure as $position => $part) {
@@ -170,28 +171,35 @@ protected function parseObject(string $id, array $structure, ?Document $document
170171
break;
171172

172173
case '<<':
174+
$headerStructure = $part[1];
173175
$header = $this->parseHeader($part[1], $document);
174176
break;
175177

176178
case 'stream':
177179
$content = isset($part[3][0]) ? $part[3][0] : $part[1];
178180

179181
if ($header->get('Type')->equals('ObjStm')) {
180-
$numberOfObjects = $this->objectStreamInteger($header, 'N');
181-
$firstObjectOffset = $this->objectStreamInteger($header, 'First');
182+
$numberOfObjects = $this->objectStreamInteger($headerStructure, 'N');
183+
$firstObjectOffset = $this->objectStreamInteger($headerStructure, 'First');
184+
185+
if (null === $numberOfObjects || null === $firstObjectOffset) {
186+
list($xrefs, $firstObjectOffset) = $this->parseObjectStreamIndexWithoutMetadata($content);
187+
$numberOfObjects = \count($xrefs);
188+
} else {
189+
if ($firstObjectOffset > \strlen($content)) {
190+
throw new \UnexpectedValueException('Object stream First offset exceeds its content length.');
191+
}
182192

183-
if ($firstObjectOffset > \strlen($content)) {
184-
throw new \UnexpectedValueException('Object stream First offset exceeds its content length.');
185-
}
193+
if ($numberOfObjects > $firstObjectOffset) {
194+
throw new \UnexpectedValueException('Object stream N exceeds its index length.');
195+
}
186196

187-
if ($numberOfObjects > $firstObjectOffset) {
188-
throw new \UnexpectedValueException('Object stream N exceeds its index length.');
197+
$xrefs = $this->parseObjectStreamIndex(
198+
substr($content, 0, $firstObjectOffset),
199+
$numberOfObjects
200+
);
189201
}
190202

191-
$xrefs = $this->parseObjectStreamIndex(
192-
substr($content, 0, $firstObjectOffset),
193-
$numberOfObjects
194-
);
195203
$content = substr($content, $firstObjectOffset);
196204
$table = [];
197205

@@ -251,46 +259,122 @@ protected function parseObject(string $id, array $structure, ?Document $document
251259
}
252260
}
253261

254-
private function objectStreamInteger(Header $header, string $name): int
262+
private function objectStreamInteger(array $headerStructure, string $name): ?int
255263
{
256-
$value = $header->get($name)->getContent();
264+
$count = \count($headerStructure);
257265

258-
if ((!\is_int($value) && !\is_float($value))
259-
|| !\is_finite((float) $value)
260-
|| $value < 0
261-
|| \floor((float) $value) !== (float) $value
262-
|| $value > \PHP_INT_MAX
263-
) {
264-
throw new \UnexpectedValueException('Object stream '.$name.' must be a non-negative integer.');
266+
for ($position = 0; $position + 1 < $count; $position += 2) {
267+
if ('/' !== $headerStructure[$position][0] || $name !== $headerStructure[$position][1]) {
268+
continue;
269+
}
270+
271+
if ('numeric' !== $headerStructure[$position + 1][0]) {
272+
return null;
273+
}
274+
275+
return $this->objectStreamToken($headerStructure[$position + 1][1]);
265276
}
266277

267-
return (int) $value;
278+
return null;
268279
}
269280

270281
/**
271282
* @return array<int, array{0: int, 1: int}>
272283
*/
273284
private function parseObjectStreamIndex(string $index, int $numberOfObjects): array
274285
{
275-
$trimmedIndex = trim($index);
276-
$tokens = '' === $trimmedIndex ? [] : preg_split('/\s+/', $trimmedIndex);
277-
278-
if (false === $tokens || \count($tokens) !== $numberOfObjects * 2) {
279-
throw new \UnexpectedValueException('Object stream index does not match its N value.');
280-
}
281-
282286
$xrefs = [];
287+
$cursor = 0;
288+
$length = \strlen($index);
289+
$this->skipObjectStreamWhitespace($index, $length, $cursor);
283290

284291
for ($position = 0; $position < $numberOfObjects; ++$position) {
285292
$xrefs[] = [
286-
$this->objectStreamToken($tokens[$position * 2]),
287-
$this->objectStreamToken($tokens[$position * 2 + 1]),
293+
$this->readObjectStreamInteger($index, $length, $cursor),
294+
$this->readObjectStreamInteger($index, $length, $cursor),
288295
];
289296
}
290297

298+
if ($cursor !== $length) {
299+
throw new \UnexpectedValueException('Object stream index does not match its N value.');
300+
}
301+
291302
return $xrefs;
292303
}
293304

305+
/**
306+
* @return array{0: array<int, array{0: int, 1: int}>, 1: int}
307+
*/
308+
private function parseObjectStreamIndexWithoutMetadata(string $content): array
309+
{
310+
$xrefs = [];
311+
$cursor = 0;
312+
$length = \strlen($content);
313+
$this->skipObjectStreamWhitespace($content, $length, $cursor);
314+
315+
while (null !== ($objectId = $this->tryReadObjectStreamInteger($content, $length, $cursor))) {
316+
$offset = $this->tryReadObjectStreamInteger($content, $length, $cursor);
317+
318+
if (null === $offset) {
319+
throw new \UnexpectedValueException('Object stream index does not contain complete object references.');
320+
}
321+
322+
$xrefs[] = [$objectId, $offset];
323+
}
324+
325+
return [$xrefs, $cursor];
326+
}
327+
328+
private function readObjectStreamInteger(string $content, int $length, int &$cursor): int
329+
{
330+
$value = $this->tryReadObjectStreamInteger($content, $length, $cursor);
331+
332+
if (null === $value) {
333+
throw new \UnexpectedValueException('Object stream index does not match its N value.');
334+
}
335+
336+
return $value;
337+
}
338+
339+
private function tryReadObjectStreamInteger(string $content, int $length, int &$cursor): ?int
340+
{
341+
if ($cursor >= $length || $content[$cursor] < '0' || $content[$cursor] > '9') {
342+
return null;
343+
}
344+
345+
$start = $cursor;
346+
347+
while ($cursor < $length && $content[$cursor] >= '0' && $content[$cursor] <= '9') {
348+
++$cursor;
349+
}
350+
351+
if ($cursor < $length && !$this->isObjectStreamWhitespace($content[$cursor])) {
352+
throw new \UnexpectedValueException('Object stream index values must be non-negative integers.');
353+
}
354+
355+
$value = $this->objectStreamToken(substr($content, $start, $cursor - $start));
356+
$this->skipObjectStreamWhitespace($content, $length, $cursor);
357+
358+
return $value;
359+
}
360+
361+
private function skipObjectStreamWhitespace(string $content, int $length, int &$cursor): void
362+
{
363+
while ($cursor < $length && $this->isObjectStreamWhitespace($content[$cursor])) {
364+
++$cursor;
365+
}
366+
}
367+
368+
private function isObjectStreamWhitespace(string $character): bool
369+
{
370+
return "\0" === $character
371+
|| "\t" === $character
372+
|| "\n" === $character
373+
|| "\f" === $character
374+
|| "\r" === $character
375+
|| ' ' === $character;
376+
}
377+
294378
private function objectStreamToken(string $token): int
295379
{
296380
$normalized = ltrim($token, '0');

tests/PHPUnit/Integration/ParserTest.php

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,100 @@ public function testIssue19(): void
159159
$this->assertArrayHasKey('17_0', $objects);
160160
}
161161

162+
/**
163+
* Object-stream index parsing accepts every PDF whitespace character, including NUL.
164+
*
165+
* @see https://github.com/smalot/pdfparser/issues/835
166+
*/
167+
public function testObjectStreamIndexAcceptsNulWhitespace(): void
168+
{
169+
$index = "17\0 0 ";
170+
$structure = [
171+
[
172+
'<<',
173+
[
174+
['/', 'Type', 0],
175+
['/', 'ObjStm', 0],
176+
['/', 'N', 0],
177+
['numeric', '1', 0],
178+
['/', 'First', 0],
179+
['numeric', (string) \strlen($index), 0],
180+
],
181+
],
182+
['stream', $index.'null'],
183+
];
184+
185+
$fixture = new ParserSub();
186+
$fixture->exposedParseObject('19_0', $structure, new Document());
187+
188+
$this->assertArrayHasKey('17_0', $fixture->getObjects());
189+
}
190+
191+
/**
192+
* Object streams with indirect metadata retain the parser's historic fallback behaviour.
193+
*
194+
* @see https://github.com/smalot/pdfparser/issues/835
195+
*/
196+
public function testObjectStreamAllowsIndirectMetadata(): void
197+
{
198+
$index = '17 0 ';
199+
$structure = [
200+
[
201+
'<<',
202+
[
203+
['/', 'Type', 0],
204+
['/', 'ObjStm', 0],
205+
['/', 'N', 0],
206+
['objref', '2_0', 0],
207+
['/', 'First', 0],
208+
['objref', '3_0', 0],
209+
],
210+
],
211+
['stream', $index.'null'],
212+
];
213+
214+
$fixture = new ParserSub();
215+
$fixture->exposedParseObject('19_0', $structure, new Document());
216+
217+
$this->assertArrayHasKey('17_0', $fixture->getObjects());
218+
}
219+
220+
/**
221+
* Out-of-range object-stream metadata fails without converting a float to an integer.
222+
*
223+
* @see https://github.com/smalot/pdfparser/issues/835
224+
*/
225+
public function testObjectStreamRejectsOverflowedMetadataWithoutWarning(): void
226+
{
227+
$structure = [
228+
[
229+
'<<',
230+
[
231+
['/', 'Type', 0],
232+
['/', 'ObjStm', 0],
233+
['/', 'N', 0],
234+
['numeric', '1', 0],
235+
['/', 'First', 0],
236+
['numeric', '9223372036854775808', 0],
237+
],
238+
],
239+
['stream', '17 0 null'],
240+
];
241+
242+
set_error_handler(static function ($severity, $message): void {
243+
throw new \ErrorException($message, 0, $severity);
244+
});
245+
246+
try {
247+
$this->expectException(\UnexpectedValueException::class);
248+
$this->expectExceptionMessage('Object stream index values must be non-negative integers.');
249+
250+
(new ParserSub())->exposedParseObject('19_0', $structure, new Document());
251+
} finally {
252+
restore_error_handler();
253+
}
254+
}
255+
162256
/**
163257
* Object stream indexes must not depend on recursive regular-expression matching.
164258
*

0 commit comments

Comments
 (0)