Repository: PrinsFrank/pdfparser (php)

Issue:
[Bug]: Execution time is excessive
### Is there an existing issue for this?

- [x] I have searched the existing issues and found no similar reports

### Are you using the latest version of this package?

- [x] The issue I'm reporting exists in the latest release

### Can other PDF readers read the file?

- [x] The PDF I'm trying to read opens correctly in at least one other PDF reader

### When running this snippet

```PHP
$time = microtime(true);

$test = new PrinsFrank\PdfParser\PdfParser();

$document = $test->parseFile('./7707DT-mpk.pdf');

foreach ($document->getPages() as $index => $page)
  echo 'Text on page ' . $index . ' : ' . $page->getText(). "\n";

echo 'Time taken: '.(microtime(true) - $time).'s';
```

### I run into the following issue/exception (Please attach the pdf)

Using the publicly accessible PDF at: https://evertz.com/resources/7707DT-mpk.pdf downloaded and loaded locally.

Running the code above on my test server, using the **PrinsFrank PdfParser**, the process takes between 2.4s and 2.6s to complete and output the text.

Running the _same_ code using the **Smalot PdfParser** (for reference below), the process completes in just over 0.2s. That's less than 10% of the time taken by _this_ library for the same document. So there's definitely some unnecessary processing going on.

```php
$time = microtime(true);

$test = new \Smalot\PdfParser\Parser([], new \Smalot\PdfParser\Config());

$document = $test->parseFile('./7707DT-mpk.pdf');

foreach ($document->getPages() as $index => $page)
  echo 'Text on page ' . $index . ' : ' . $page->getText(). "\n";

echo 'Time taken: '.(microtime(true) - $time).'s';
```

### Do you allow attachment files to be used in tests to prevent regressions?

- [ ] Yes, I give permission to use this file as a test file to prevent future regressions (And am authorized to give this permission)

Pull request description:
Allow in memory parsing
Fixes #7.

After switching from file handle to in-memory PDF the runtime decreases by about 90% to within 30% of the smalot pdfparser for that specific document. For other documents, this package now also has a similar runtime, but in some cases is up to 4 times faster. I'll keep optimizing some methods, but the majority of speed was filesystem vs runtime, that bottleneck is now gone.

Patch:
diff --git a/composer.json b/composer.json
index 5990ea4..d167f2b 100644
--- a/composer.json
+++ b/composer.json
@@ -15,8 +15,8 @@
     },
     "require": {
         "php": "~8.1.0 || ~8.2.0 || ~8.3.0 || ~8.4.0",
-        "ext-zlib": "*",
-        "ext-iconv": "*"
+        "ext-iconv": "*",
+        "ext-zlib": "*"
     },
     "require-dev": {
         "friendsofphp/php-cs-fixer": "^3.65",
diff --git a/src/Document/CMap/ToUnicode/ToUnicodeCMapParser.php b/src/Document/CMap/ToUnicode/ToUnicodeCMapParser.php
index bdebaac..37de273 100644
--- a/src/Document/CMap/ToUnicode/ToUnicodeCMapParser.php
+++ b/src/Document/CMap/ToUnicode/ToUnicodeCMapParser.php
@@ -3,7 +3,7 @@
 namespace PrinsFrank\PdfParser\Document\CMap\ToUnicode;
 
 use PrinsFrank\PdfParser\Exception\ParseFailureException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class ToUnicodeCMapParser {
     public static function parse(Stream $stream, int $startOffset, int $nrOfBytes): ToUnicodeCMap {
diff --git a/src/Document/CrossReference/CrossReferenceSourceParser.php b/src/Document/CrossReference/CrossReferenceSourceParser.php
index efd8fd7..2c9ace9 100644
--- a/src/Document/CrossReference/CrossReferenceSourceParser.php
+++ b/src/Document/CrossReference/CrossReferenceSourceParser.php
@@ -11,7 +11,7 @@ use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Integer\IntegerValu
 use PrinsFrank\PdfParser\Document\Generic\Marker;
 use PrinsFrank\PdfParser\Exception\MarkerNotFoundException;
 use PrinsFrank\PdfParser\Exception\ParseFailureException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class CrossReferenceSourceParser {
     public static function parse(Stream $stream): CrossReferenceSource {
diff --git a/src/Document/CrossReference/Stream/CrossReferenceStreamParser.php b/src/Document/CrossReference/Stream/CrossReferenceStreamParser.php
index bbd371a..89a47fb 100644
--- a/src/Document/CrossReference/Stream/CrossReferenceStreamParser.php
+++ b/src/Document/CrossReference/Stream/CrossReferenceStreamParser.php
@@ -17,7 +17,7 @@ use PrinsFrank\PdfParser\Document\Generic\Marker;
 use PrinsFrank\PdfParser\Document\Object\Item\CompressedObject\CompressedObjectContent\CompressedObjectContentParser;
 use PrinsFrank\PdfParser\Exception\MarkerNotFoundException;
 use PrinsFrank\PdfParser\Exception\ParseFailureException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class CrossReferenceStreamParser {
     private const HEX_CHARS_IN_BYTE = 2;
diff --git a/src/Document/CrossReference/Table/CrossReferenceTableParser.php b/src/Document/CrossReference/Table/CrossReferenceTableParser.php
index cc547f4..b424472 100644
--- a/src/Document/CrossReference/Table/CrossReferenceTableParser.php
+++ b/src/Document/CrossReference/Table/CrossReferenceTableParser.php
@@ -11,7 +11,7 @@ use PrinsFrank\PdfParser\Document\Generic\Character\WhitespaceCharacter;
 use PrinsFrank\PdfParser\Document\Generic\Marker;
 use PrinsFrank\PdfParser\Exception\InvalidCrossReferenceLineException;
 use PrinsFrank\PdfParser\Exception\ParseFailureException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class CrossReferenceTableParser {
     public static function parse(Stream $stream, int $startPos, int $nrOfBytes): CrossReferenceSection {
diff --git a/src/Document/Dictionary/DictionaryParser.php b/src/Document/Dictionary/DictionaryParser.php
index 354ecf5..da60f0c 100644
--- a/src/Document/Dictionary/DictionaryParser.php
+++ b/src/Document/Dictionary/DictionaryParser.php
@@ -9,7 +9,7 @@ use PrinsFrank\PdfParser\Document\Generic\Character\DelimiterCharacter;
 use PrinsFrank\PdfParser\Document\Generic\Character\LiteralStringEscapeCharacter;
 use PrinsFrank\PdfParser\Document\Generic\Character\WhitespaceCharacter;
 use PrinsFrank\PdfParser\Document\Generic\Parsing\RollingCharBuffer;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class DictionaryParser {
     /**
diff --git a/src/Document/Document.php b/src/Document/Document.php
index 07f96ff..da4d5ff 100644
--- a/src/Document/Document.php
+++ b/src/Document/Document.php
@@ -10,8 +10,8 @@ use PrinsFrank\PdfParser\Document\Dictionary\DictionaryKey\DictionaryKey;
 use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Reference\ReferenceValue;
 use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Reference\ReferenceValueArray;
 use PrinsFrank\PdfParser\Document\Object\Decorator\Catalog;
-use PrinsFrank\PdfParser\Document\Object\Decorator\DecoratedObjectFactory;
 use PrinsFrank\PdfParser\Document\Object\Decorator\DecoratedObject;
+use PrinsFrank\PdfParser\Document\Object\Decorator\DecoratedObjectFactory;
 use PrinsFrank\PdfParser\Document\Object\Decorator\InformationDictionary;
 use PrinsFrank\PdfParser\Document\Object\Decorator\Page;
 use PrinsFrank\PdfParser\Document\Object\Item\UncompressedObject\UncompressedObject;
@@ -19,7 +19,7 @@ use PrinsFrank\PdfParser\Document\Object\Item\UncompressedObject\UncompressedObj
 use PrinsFrank\PdfParser\Document\Version\Version;
 use PrinsFrank\PdfParser\Exception\ParseFailureException;
 use PrinsFrank\PdfParser\Exception\RuntimeException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 final class Document {
     /** @var list<Page> */
diff --git a/src/Document/Object/Decorator/Font.php b/src/Document/Object/Decorator/Font.php
index 0732ad4..50d17e7 100644
--- a/src/Document/Object/Decorator/Font.php
+++ b/src/Document/Object/Decorator/Font.php
@@ -16,7 +16,7 @@ use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Reference\Reference
 use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\TextString\TextStringValue;
 use PrinsFrank\PdfParser\Document\Object\Item\UncompressedObject\UncompressedObject;
 use PrinsFrank\PdfParser\Exception\ParseFailureException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\FileStream;
 
 class Font extends DecoratedObject {
     private readonly ToUnicodeCMap|false $toUnicodeCMap;
@@ -67,7 +67,7 @@ class Font extends DecoratedObject {
         }
 
         return $this->toUnicodeCMap = ToUnicodeCMapParser::parse(
-            $stream = Stream::fromString($toUnicodeObject->objectItem->getStreamContent($this->document->stream)),
+            $stream = FileStream::fromString($toUnicodeObject->objectItem->getStreamContent($this->document->stream)),
             0,
             $stream->getSizeInBytes()
         );
diff --git a/src/Document/Object/Item/CompressedObject/CompressedObject.php b/src/Document/Object/Item/CompressedObject/CompressedObject.php
index 415594b..3d75585 100644
--- a/src/Document/Object/Item/CompressedObject/CompressedObject.php
+++ b/src/Document/Object/Item/CompressedObject/CompressedObject.php
@@ -11,7 +11,8 @@ use PrinsFrank\PdfParser\Document\Object\Item\ObjectItem;
 use PrinsFrank\PdfParser\Document\Object\Item\UncompressedObject\UncompressedObject;
 use PrinsFrank\PdfParser\Exception\InvalidArgumentException;
 use PrinsFrank\PdfParser\Exception\RuntimeException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\FileStream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class CompressedObject implements ObjectItem {
     private readonly Dictionary $dictionary;
@@ -36,7 +37,7 @@ class CompressedObject implements ObjectItem {
         $first = $this->storedInObject->getDictionary($stream)->getValueForKey(DictionaryKey::FIRST, IntegerValue::class)
             ?? throw new RuntimeException('Expected a dictionary entry for "First", none found');
 
-        $objectContent = Stream::fromString(
+        $objectContent = FileStream::fromString(
             substr(
                 $this->storedInObject->getStreamContent($stream),
                 $first->value + $this->startByteOffsetInDecodedStream,
diff --git a/src/Document/Object/Item/CompressedObject/CompressedObjectByteOffsetParser.php b/src/Document/Object/Item/CompressedObject/CompressedObjectByteOffsetParser.php
index 56e26f5..c0172e1 100644
--- a/src/Document/Object/Item/CompressedObject/CompressedObjectByteOffsetParser.php
+++ b/src/Document/Object/Item/CompressedObject/CompressedObjectByteOffsetParser.php
@@ -12,7 +12,7 @@ use PrinsFrank\PdfParser\Document\Object\Item\CompressedObject\CompressedObjectC
 use PrinsFrank\PdfParser\Exception\MarkerNotFoundException;
 use PrinsFrank\PdfParser\Exception\ParseFailureException;
 use PrinsFrank\PdfParser\Exception\RuntimeException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class CompressedObjectByteOffsetParser {
     public static function parse(Stream $stream, int $startOffsetObject, int $endOffsetObject, Dictionary $dictionary): CompressedObjectByteOffsets {
diff --git a/src/Document/Object/Item/CompressedObject/CompressedObjectContent/CompressedObjectContentParser.php b/src/Document/Object/Item/CompressedObject/CompressedObjectContent/CompressedObjectContentParser.php
index d3c98b2..db8200e 100644
--- a/src/Document/Object/Item/CompressedObject/CompressedObjectContent/CompressedObjectContentParser.php
+++ b/src/Document/Object/Item/CompressedObject/CompressedObjectContent/CompressedObjectContentParser.php
@@ -9,7 +9,7 @@ use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Array\ArrayValue;
 use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Name\FilterNameValue;
 use PrinsFrank\PdfParser\Exception\ParseFailureException;
 use PrinsFrank\PdfParser\Exception\RuntimeException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class CompressedObjectContentParser {
     public static function parse(Stream $stream, int $startPos, int $nrOfBytes, Dictionary $dictionary): string {
diff --git a/src/Document/Object/Item/ObjectItem.php b/src/Document/Object/Item/ObjectItem.php
index dc4abd7..bad26ee 100644
--- a/src/Document/Object/Item/ObjectItem.php
+++ b/src/Document/Object/Item/ObjectItem.php
@@ -3,7 +3,7 @@
 namespace PrinsFrank\PdfParser\Document\Object\Item;
 
 use PrinsFrank\PdfParser\Document\Dictionary\Dictionary;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 interface ObjectItem {
     public function getDictionary(Stream $stream): Dictionary;
diff --git a/src/Document/Object/Item/UncompressedObject/UncompressedObject.php b/src/Document/Object/Item/UncompressedObject/UncompressedObject.php
index f90cb00..e1c2edd 100644
--- a/src/Document/Object/Item/UncompressedObject/UncompressedObject.php
+++ b/src/Document/Object/Item/UncompressedObject/UncompressedObject.php
@@ -17,7 +17,7 @@ use PrinsFrank\PdfParser\Document\Object\Item\ObjectItem;
 use PrinsFrank\PdfParser\Exception\InvalidArgumentException;
 use PrinsFrank\PdfParser\Exception\MarkerNotFoundException;
 use PrinsFrank\PdfParser\Exception\ParseFailureException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class UncompressedObject implements ObjectItem {
     private readonly Dictionary $dictionary;
diff --git a/src/Document/Object/Item/UncompressedObject/UncompressedObjectParser.php b/src/Document/Object/Item/UncompressedObject/UncompressedObjectParser.php
index 39f94a1..c727092 100644
--- a/src/Document/Object/Item/UncompressedObject/UncompressedObjectParser.php
+++ b/src/Document/Object/Item/UncompressedObject/UncompressedObjectParser.php
@@ -6,13 +6,13 @@ use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\Entry
 use PrinsFrank\PdfParser\Document\Generic\Character\WhitespaceCharacter;
 use PrinsFrank\PdfParser\Document\Generic\Marker;
 use PrinsFrank\PdfParser\Exception\ParseFailureException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class UncompressedObjectParser {
     public static function parseObject(
         CrossReferenceEntryInUseObject $crossReferenceEntry,
-        int $objectNumber,
-        Stream $stream,
+        int                            $objectNumber,
+        Stream                 $stream,
     ): UncompressedObject {
         $endObj = $stream->firstPos(Marker::END_OBJ, $crossReferenceEntry->byteOffsetInDecodedStream, $stream->getSizeInBytes()) ?? throw new ParseFailureException('Unable to locate end of object');
         $startObj = $stream->firstPos(Marker::OBJ, $crossReferenceEntry->byteOffsetInDecodedStream, $endObj) ?? throw new ParseFailureException('Unable to locate start of object');
diff --git a/src/Document/Version/VersionParser.php b/src/Document/Version/VersionParser.php
index 9cd4ad3..dcc6d8a 100644
--- a/src/Document/Version/VersionParser.php
+++ b/src/Document/Version/VersionParser.php
@@ -6,7 +6,7 @@ namespace PrinsFrank\PdfParser\Document\Version;
 use PrinsFrank\PdfParser\Document\Generic\Marker;
 use PrinsFrank\PdfParser\Exception\UnsupportedFileFormatException;
 use PrinsFrank\PdfParser\Exception\UnsupportedPdfVersionException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 class VersionParser {
     /**
diff --git a/src/PdfParser.php b/src/PdfParser.php
index 21c999b..22e34ab 100644
--- a/src/PdfParser.php
+++ b/src/PdfParser.php
@@ -6,7 +6,11 @@ namespace PrinsFrank\PdfParser;
 use PrinsFrank\PdfParser\Document\CrossReference\CrossReferenceSourceParser;
 use PrinsFrank\PdfParser\Document\Document;
 use PrinsFrank\PdfParser\Document\Version\VersionParser;
+use PrinsFrank\PdfParser\Exception\InvalidArgumentException;
 use PrinsFrank\PdfParser\Exception\PdfParserException;
+use PrinsFrank\PdfParser\Stream\FileStream;
+use PrinsFrank\PdfParser\Stream\InMemoryStream;
+use PrinsFrank\PdfParser\Stream\Stream;
 
 final class PdfParser {
     /** @throws PdfParserException */
@@ -18,15 +22,30 @@ final class PdfParser {
         );
     }
 
-    public function parseFile(string $filePath): Document {
-        return $this->parse(
-            Stream::openFile($filePath)
-        );
+    /** @param bool $useInMemoryStream if set to false, a handle to the file itself will be used. This uses less memory, but will be significantly slower */
+    public function parseFile(string $filePath, bool $useInMemoryStream = true): Document {
+        if ($useInMemoryStream) {
+            $fileContent = file_get_contents($filePath);
+            if ($fileContent === false) {
+                throw new InvalidArgumentException(sprintf('Failed to open file at path "%s"', $filePath));
+            }
+
+            $stream = new InMemoryStream($fileContent);
+        } else {
+            $stream = FileStream::openFile($filePath);
+        }
+
+        return $this->parse($stream);
     }
 
-    public function parseString(string $content): Document {
-        return $this->parse(
-            Stream::fromString($content)
-        );
+    /** @param bool $useFileCache if set to true, the file will be cached to a temporary file. This will use less memory, but will be significantly slower */
+    public function parseString(string $content, bool $useFileCache = false): Document {
+        if ($useFileCache) {
+            $stream = FileStream::fromString($content);
+        } else {
+            $stream = new InMemoryStream($content);
+        }
+
+        return $this->parse($stream);
     }
 }
diff --git a/src/Stream/AbstractStream.php b/src/Stream/AbstractStream.php
new file mode 100644
index 0000000..bd1a44f
--- /dev/null
+++ b/src/Stream/AbstractStream.php
@@ -0,0 +1,60 @@
+<?php declare(strict_types=1);
+
+namespace PrinsFrank\PdfParser\Stream;
+
+use Override;
+use PrinsFrank\PdfParser\Document\CMap\ToUnicode\ToUnicodeCMapOperator;
+use PrinsFrank\PdfParser\Document\Generic\Character\DelimiterCharacter;
+use PrinsFrank\PdfParser\Document\Generic\Character\WhitespaceCharacter;
+use PrinsFrank\PdfParser\Document\Generic\Marker;
+
+abstract class AbstractStream implements Stream {
+    #[Override]
+    public function getStartNextLineAfter(WhitespaceCharacter|Marker|DelimiterCharacter|ToUnicodeCMapOperator $needle, int $offsetFromStart, int $before): ?int {
+        $markerPos = $this->firstPos($needle, $offsetFromStart, $before);
+        if ($markerPos === null) {
+            return null;
+        }
+
+        return $this->getStartOfNextLine($markerPos, $before);
+    }
+
+    #[Override]
+    public function getStartOfNextLine(int $byteOffset, int $before): ?int {
+        $firstLineFeedPos = $this->firstPos(WhitespaceCharacter::LINE_FEED, $byteOffset, $before);
+        $firstCarriageReturnPos = $this->firstPos(WhitespaceCharacter::CARRIAGE_RETURN, $byteOffset, $before);
+        if ($firstLineFeedPos === null && $firstCarriageReturnPos === null) {
+            return null;
+        }
+
+        if ($firstCarriageReturnPos === null) {
+            return $firstLineFeedPos + 1;
+        }
+
+        if ($firstLineFeedPos === null) {
+            return $firstCarriageReturnPos + 1;
+        }
+
+        return min($firstLineFeedPos, $firstCarriageReturnPos)
+            + (abs($firstCarriageReturnPos - $firstLineFeedPos) === 1 ? 2 : 1); // If the CR and LF are next to each other, we need to add 2 bytes, otherwise 1
+    }
+
+    #[Override]
+    public function getEndOfCurrentLine(int $byteOffset, int $before): ?int {
+        $firstLineFeedPos = $this->firstPos(WhitespaceCharacter::LINE_FEED, $byteOffset, $before);
+        $firstCarriageReturnPos = $this->firstPos(WhitespaceCharacter::CARRIAGE_RETURN, $byteOffset, $before);
+        if ($firstLineFeedPos === null && $firstCarriageReturnPos === null) {
+            return null;
+        }
+
+        if ($firstCarriageReturnPos === null) {
+            return $firstLineFeedPos;
+        }
+
+        if ($firstLineFeedPos === null) {
+            return $firstCarriageReturnPos;
+        }
+
+        return min($firstLineFeedPos, $firstCarriageReturnPos);
+    }
+}
diff --git a/src/Stream.php b/src/Stream/FileStream.php
similarity index 70%
rename from src/Stream.php
rename to src/Stream/FileStream.php
index e8c0c00..bd35b26 100644
--- a/src/Stream.php
+++ b/src/Stream/FileStream.php
@@ -1,7 +1,8 @@
 <?php declare(strict_types=1);
 
-namespace PrinsFrank\PdfParser;
+namespace PrinsFrank\PdfParser\Stream;
 
+use Override;
 use PrinsFrank\PdfParser\Document\CMap\ToUnicode\ToUnicodeCMapOperator;
 use PrinsFrank\PdfParser\Document\Generic\Character\DelimiterCharacter;
 use PrinsFrank\PdfParser\Document\Generic\Character\WhitespaceCharacter;
@@ -10,7 +11,7 @@ use PrinsFrank\PdfParser\Document\Generic\Parsing\RollingCharBuffer;
 use PrinsFrank\PdfParser\Exception\InvalidArgumentException;
 use PrinsFrank\PdfParser\Exception\RuntimeException;
 
-class Stream {
+class FileStream extends AbstractStream {
     /** @var resource */
     private readonly mixed $handle;
 
@@ -44,6 +45,7 @@ class Stream {
         return new self($handle);
     }
 
+    #[Override]
     public function getSizeInBytes(): int {
         $stats = fstat($this->handle);
         if ($stats === false) {
@@ -53,7 +55,7 @@ class Stream {
         return $stats['size'];
     }
 
-    /** @phpstan-assert int<1, max> $nrOfBytes */
+    #[Override]
     public function read(int $from, int $nrOfBytes): string {
         if ($nrOfBytes <= 0) {
             throw new InvalidArgumentException(sprintf('$nrOfBytes must be greater than 0, %d given', $nrOfBytes));
@@ -69,10 +71,7 @@ class Stream {
         return $bytes;
     }
 
-    /**
-     * @phpstan-assert int<0, max> $startByteOffset
-     * @phpstan-assert int<0, max> $endByteOffset
-     */
+    #[Override]
     public function slice(int $startByteOffset, int $endByteOffset): string {
         if ($startByteOffset <= 0) {
             throw new InvalidArgumentException(sprintf('$nrOfBytes must be greater than 0, %d given', $startByteOffset));
@@ -92,12 +91,7 @@ class Stream {
         return $bytes;
     }
 
-    /**
-     * @phpstan-assert int<0, max> $from
-     * @phpstan-assert int<1, max> $nrOfBytes
-     *
-     * @return iterable<string>
-     */
+    #[Override]
     public function chars(int $from, int $nrOfBytes): iterable {
         if ($from < 0) {
             throw new InvalidArgumentException(sprintf('StartOffset should be greater than zero, %d given', $from));
@@ -119,6 +113,7 @@ class Stream {
         }
     }
 
+    #[Override]
     public function firstPos(WhitespaceCharacter|Marker|DelimiterCharacter|ToUnicodeCMapOperator $needle, int $offsetFromStart, int $before): ?int {
         $rollingCharBuffer = new RollingCharBuffer($needleLength = strlen($needle->value));
         while ($offsetFromStart < $before) {
@@ -137,15 +132,7 @@ class Stream {
         return null;
     }
 
-    public function getStartNextLineAfter(WhitespaceCharacter|Marker|DelimiterCharacter|ToUnicodeCMapOperator $needle, int $offsetFromStart, int $before): ?int {
-        $markerPos = $this->firstPos($needle, $offsetFromStart, $before);
-        if ($markerPos === null) {
-            return null;
-        }
-
-        return $this->getStartOfNextLine($markerPos, $before);
-    }
-
+    #[Override]
     public function lastPos(WhitespaceCharacter|Marker|DelimiterCharacter|ToUnicodeCMapOperator $needle, int $offsetFromEnd): ?int {
         $rollingCharBuffer = new RollingCharBuffer(strlen($needle->value));
         $offsetFromEnd++;
@@ -164,43 +151,6 @@ class Stream {
         return null;
     }
 
-    public function getStartOfNextLine(int $byteOffset, int $before): ?int {
-        $firstLineFeedPos = $this->firstPos(WhitespaceCharacter::LINE_FEED, $byteOffset, $before);
-        $firstCarriageReturnPos = $this->firstPos(WhitespaceCharacter::CARRIAGE_RETURN, $byteOffset, $before);
-        if ($firstLineFeedPos === null && $firstCarriageReturnPos === null) {
-            return null;
-        }
-
-        if ($firstCarriageReturnPos === null) {
-            return $firstLineFeedPos + 1;
-        }
-
-        if ($firstLineFeedPos === null) {
-            return $firstCarriageReturnPos + 1;
-        }
-
-        return min($firstLineFeedPos, $firstCarriageReturnPos)
-            + (abs($firstCarriageReturnPos - $firstLineFeedPos) === 1 ? 2 : 1); // If the CR and LF are next to each other, we need to add 2 bytes, otherwise 1
-    }
-
-    public function getEndOfCurrentLine(int $byteOffset, int $before): ?int {
-        $firstLineFeedPos = $this->firstPos(WhitespaceCharacter::LINE_FEED, $byteOffset, $before);
-        $firstCarriageReturnPos = $this->firstPos(WhitespaceCharacter::CARRIAGE_RETURN, $byteOffset, $before);
-        if ($firstLineFeedPos === null && $firstCarriageReturnPos === null) {
-            return null;
-        }
-
-        if ($firstCarriageReturnPos === null) {
-            return $firstLineFeedPos;
-        }
-
-        if ($firstLineFeedPos === null) {
-            return $firstCarriageReturnPos;
-        }
-
-        return min($firstLineFeedPos, $firstCarriageReturnPos);
-    }
-
     public function __destruct() {
         fclose($this->handle);
     }
diff --git a/src/Stream/InMemoryStream.php b/src/Stream/InMemoryStream.php
new file mode 100644
index 0000000..747a0a2
--- /dev/null
+++ b/src/Stream/InMemoryStream.php
@@ -0,0 +1,79 @@
+<?php declare(strict_types=1);
+
+namespace PrinsFrank\PdfParser\Stream;
+
+use Override;
+use PrinsFrank\PdfParser\Document\CMap\ToUnicode\ToUnicodeCMapOperator;
+use PrinsFrank\PdfParser\Document\Generic\Character\DelimiterCharacter;
+use PrinsFrank\PdfParser\Document\Generic\Character\WhitespaceCharacter;
+use PrinsFrank\PdfParser\Document\Generic\Marker;
+use PrinsFrank\PdfParser\Exception\InvalidArgumentException;
+
+class InMemoryStream extends AbstractStream {
+    public function __construct(
+        private readonly string $content
+    ) {
+    }
+
+    #[Override]
+    public function getSizeInBytes(): int {
+        return strlen($this->content);
+    }
+
+    #[Override]
+    public function read(int $from, int $nrOfBytes): string {
+        if ($nrOfBytes <= 0) {
+            throw new InvalidArgumentException(sprintf('$nrOfBytes must be greater than 0, %d given', $nrOfBytes));
+        }
+
+        return substr($this->content, $from, $nrOfBytes);
+    }
+
+    #[Override]
+    public function slice(int $startByteOffset, int $endByteOffset): string {
+        if ($startByteOffset <= 0) {
+            throw new InvalidArgumentException(sprintf('$nrOfBytes must be greater than 0, %d given', $startByteOffset));
+        }
+
+        if ($endByteOffset - $startByteOffset < 1) {
+            throw new InvalidArgumentException(sprintf('End byte offset %d should be bigger than start byte offset %d', $endByteOffset, $startByteOffset));
+        }
+
+        return substr($this->content, $startByteOffset, $endByteOffset - $startByteOffset);
+    }
+
+    #[Override]
+    public function chars(int $from, int $nrOfBytes): iterable {
+        if ($from < 0) {
+            throw new InvalidArgumentException(sprintf('StartOffset should be greater than zero, %d given', $from));
+        }
+
+        if ($nrOfBytes <= 0) {
+            throw new InvalidArgumentException(sprintf('$nrOfBytes to read must be greater than 0, %d given', $nrOfBytes));
+        }
+
+        foreach (str_split(substr($this->content, $from, $nrOfBytes)) as $char) {
+            yield $char;
+        }
+    }
+
+    #[Override]
+    public function firstPos(WhitespaceCharacter|DelimiterCharacter|ToUnicodeCMapOperator|Marker $needle, int $offsetFromStart, int $before): ?int {
+        $firstPos = strpos($this->content, $needle->value, $offsetFromStart);
+        if ($firstPos === false || $firstPos > $before) {
+            return null;
+        }
+
+        return $firstPos;
+    }
+
+    #[Override]
+    public function lastPos(WhitespaceCharacter|DelimiterCharacter|ToUnicodeCMapOperator|Marker $needle, int $offsetFromEnd): ?int {
+        $pos = strrpos($this->content, $needle->value, -$offsetFromEnd);
+        if ($pos === false) {
+            return null;
+        }
+
+        return $pos;
+    }
+}
diff --git a/src/Stream/Stream.php b/src/Stream/Stream.php
new file mode 100644
index 0000000..d430b45
--- /dev/null
+++ b/src/Stream/Stream.php
@@ -0,0 +1,39 @@
+<?php declare(strict_types=1);
+
+namespace PrinsFrank\PdfParser\Stream;
+
+use PrinsFrank\PdfParser\Document\CMap\ToUnicode\ToUnicodeCMapOperator;
+use PrinsFrank\PdfParser\Document\Generic\Character\DelimiterCharacter;
+use PrinsFrank\PdfParser\Document\Generic\Character\WhitespaceCharacter;
+use PrinsFrank\PdfParser\Document\Generic\Marker;
+
+interface Stream {
+    public function getSizeInBytes(): int;
+
+    /** @phpstan-assert int<1, max> $nrOfBytes */
+    public function read(int $from, int $nrOfBytes): string;
+
+    /**
+     * @phpstan-assert int<0, max> $startByteOffset
+     * @phpstan-assert int<0, max> $endByteOffset
+     */
+    public function slice(int $startByteOffset, int $endByteOffset): string;
+
+    /**
+     * @phpstan-assert int<0, max> $from
+     * @phpstan-assert int<1, max> $nrOfBytes
+     *
+     * @return iterable<string>
+     */
+    public function chars(int $from, int $nrOfBytes): iterable;
+
+    public function firstPos(WhitespaceCharacter|Marker|DelimiterCharacter|ToUnicodeCMapOperator $needle, int $offsetFromStart, int $before): ?int;
+
+    public function lastPos(WhitespaceCharacter|Marker|DelimiterCharacter|ToUnicodeCMapOperator $needle, int $offsetFromEnd): ?int;
+
+    public function getStartNextLineAfter(WhitespaceCharacter|Marker|DelimiterCharacter|ToUnicodeCMapOperator $needle, int $offsetFromStart, int $before): ?int;
+
+    public function getStartOfNextLine(int $byteOffset, int $before): ?int ;
+
+    public function getEndOfCurrentLine(int $byteOffset, int $before): ?int ;
+}


Test patch:
diff --git a/tests/Unit/Document/CMap/ToUnicode/ToUnicodeCMapParserTest.php b/tests/Unit/Document/CMap/ToUnicode/ToUnicodeCMapParserTest.php
index 14c6b8c..5b0181d 100644
--- a/tests/Unit/Document/CMap/ToUnicode/ToUnicodeCMapParserTest.php
+++ b/tests/Unit/Document/CMap/ToUnicode/ToUnicodeCMapParserTest.php
@@ -8,7 +8,7 @@ use PrinsFrank\PdfParser\Document\CMap\ToUnicode\BFChar;
 use PrinsFrank\PdfParser\Document\CMap\ToUnicode\BFRange;
 use PrinsFrank\PdfParser\Document\CMap\ToUnicode\ToUnicodeCMap;
 use PrinsFrank\PdfParser\Document\CMap\ToUnicode\ToUnicodeCMapParser;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\InMemoryStream;
 
 #[CoversClass(ToUnicodeCMapParser::class)]
 class ToUnicodeCMapParserTest extends TestCase {
@@ -24,7 +24,7 @@ class ToUnicodeCMapParserTest extends TestCase {
                 new BFChar(0x3A51, 0xD840DC3E),
             ),
             ToUnicodeCMapParser::parse(
-                $stream = Stream::fromString(
+                $stream = new InMemoryStream(
                     <<<EOD
                     /CIDInit /ProcSet findresource begin
                     12 dict begin
diff --git a/tests/Unit/Document/CrossReference/Table/CrossReferenceTableParserTest.php b/tests/Unit/Document/CrossReference/Table/CrossReferenceTableParserTest.php
index badbbc5..3997869 100644
--- a/tests/Unit/Document/CrossReference/Table/CrossReferenceTableParserTest.php
+++ b/tests/Unit/Document/CrossReference/Table/CrossReferenceTableParserTest.php
@@ -10,7 +10,7 @@ use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\Entry
 use PrinsFrank\PdfParser\Document\CrossReference\Source\Section\SubSection\Entry\CrossReferenceEntryInUseObject;
 use PrinsFrank\PdfParser\Document\CrossReference\Table\CrossReferenceTableParser;
 use PrinsFrank\PdfParser\Document\Dictionary\Dictionary;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\InMemoryStream;
 
 #[CoversClass(CrossReferenceTableParser::class)]
 class CrossReferenceTableParserTest extends TestCase {
@@ -19,7 +19,7 @@ class CrossReferenceTableParserTest extends TestCase {
      * The following line introduces a subsection containing five objects numbered consecutively from 28 to 32.
      */
     public function testParseExample1(): void {
-        $stream = Stream::fromString(
+        $stream = new InMemoryStream(
             <<<EOD
             28 5
             trailer
@@ -42,7 +42,7 @@ class CrossReferenceTableParserTest extends TestCase {
      * 3 has been deleted, and the next object created with that object number is given a generation number of 7.
      */
     public function testParseExample2(): void {
-        $stream = Stream::fromString(
+        $stream = new InMemoryStream(
             <<<EOD
             0 6
             0000000003 65535 f
@@ -83,7 +83,7 @@ class CrossReferenceTableParserTest extends TestCase {
      * which is in use.
      */
     public function testParseExample3(): void {
-        $stream = Stream::fromString(
+        $stream = new InMemoryStream(
             <<<EOD
             0 1
             0000000000 65535 f
diff --git a/tests/Unit/Document/Dictionary/DictionaryParserTest.php b/tests/Unit/Document/Dictionary/DictionaryParserTest.php
index 1c4f531..8031eed 100644
--- a/tests/Unit/Document/Dictionary/DictionaryParserTest.php
+++ b/tests/Unit/Document/Dictionary/DictionaryParserTest.php
@@ -23,7 +23,7 @@ use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Name\TypeNameValue;
 use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Rectangle\Rectangle;
 use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Reference\ReferenceValue;
 use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\TextString\TextStringValue;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\InMemoryStream;
 
 #[CoversClass(DictionaryParser::class)]
 class DictionaryParserTest extends TestCase {
@@ -41,7 +41,7 @@ class DictionaryParserTest extends TestCase {
                 new DictionaryEntry(DictionaryKey::FILTER, FilterNameValue::FLATE_DECODE),
             ),
             DictionaryParser::parse(
-                $stream = Stream::fromString(
+                $stream = new InMemoryStream(
                     <<<EOD
                     15 0 obj
                     <<
@@ -70,7 +70,7 @@ class DictionaryParserTest extends TestCase {
                 new DictionaryEntry(DictionaryKey::INDEX, new ArrayValue([0, 16])),
             ),
             DictionaryParser::parse(
-                $stream = Stream::fromString(
+                $stream = new InMemoryStream(
                     <<<EOD
                     <<
                     /Index [ 0 16 ]
@@ -102,7 +102,7 @@ class DictionaryParserTest extends TestCase {
                 new DictionaryEntry(DictionaryKey::W, new WValue(1, 4, 0)),
             ),
             DictionaryParser::parse(
-                $stream = Stream::fromString(
+                $stream = new InMemoryStream(
                     '<<
                         /DecodeParms
                                 <<
@@ -146,7 +146,7 @@ class DictionaryParserTest extends TestCase {
                 new DictionaryEntry(DictionaryKey::W, new WValue(1, 4, 0)),
             ),
             DictionaryParser::parse(
-                $stream = Stream::fromString('<</DecodeParms<</Columns 5/Predictor 12>>/Filter/FlateDecode/ID[<9A27A23F6A2546448EBB340FF38477BD><C5C4714E306446ABAE40FE784477D322>]/Index[2460 1 4311 1 4317 2 4414 1 4717 21]/Info 4318 0 R/Length 106/Prev 46153797/Root 4320 0 R/Size 4738/Type/XRef/W[1 4 0]>>stream'),
+                $stream = new InMemoryStream('<</DecodeParms<</Columns 5/Predictor 12>>/Filter/FlateDecode/ID[<9A27A23F6A2546448EBB340FF38477BD><C5C4714E306446ABAE40FE784477D322>]/Index[2460 1 4311 1 4317 2 4414 1 4717 21]/Info 4318 0 R/Length 106/Prev 46153797/Root 4320 0 R/Size 4738/Type/XRef/W[1 4 0]>>stream'),
                 0,
                 $stream->getSizeInBytes(),
             )
@@ -170,7 +170,7 @@ class DictionaryParserTest extends TestCase {
                 new DictionaryEntry(DictionaryKey::FONT_FILE, new TextStringValue('11 0 R')),
             ),
             DictionaryParser::parse(
-                $stream = Stream::fromString(
+                $stream = new InMemoryStream(
                     <<<EOD
                     <<
                     /Type /FontDescriptor
@@ -206,7 +206,7 @@ class DictionaryParserTest extends TestCase {
                 new DictionaryEntry(DictionaryKey::PTEX_FULLBANNER, new TextStringValue('(This is pdfTeX, Version 3.14159265-2.6-1.40.18 (TeX Live 2017/Debian) kpathsea version 6.2.3)')),
             ),
             DictionaryParser::parse(
-                $stream = Stream::fromString(
+                $stream = new InMemoryStream(
                     <<<EOD
                     <<
                     /Producer (pdfTeX-1.40.18)
@@ -230,7 +230,7 @@ class DictionaryParserTest extends TestCase {
                 new DictionaryEntry(DictionaryKey::PRODUCER, new TextStringValue('(pdfTeX-1.40.18)')),
             ),
             DictionaryParser::parse(
-                $stream = Stream::fromString(
+                $stream = new InMemoryStream(
                     <<<EOD
                     <<
                     /Producer (pdfTeX-1.40.18)
@@ -259,7 +259,7 @@ class DictionaryParserTest extends TestCase {
                 new DictionaryEntry(DictionaryKey::TYPE, TypeNameValue::CATALOG),
             ),
             DictionaryParser::parse(
-                $stream = Stream::fromString(
+                $stream = new InMemoryStream(
                     <<<EOD
                     <<
                     /OpenAction[3 0 R/Fit]
@@ -298,7 +298,7 @@ class DictionaryParserTest extends TestCase {
                 new DictionaryEntry(DictionaryKey::PARENT, new ReferenceValue(6, 0)),
             ),
             DictionaryParser::parse(
-                $stream = Stream::fromString(
+                $stream = new InMemoryStream(
                     <<<EOD
                     <</Type /Page
                     /Resources <<
diff --git a/tests/Unit/Document/Version/VersionParserTest.php b/tests/Unit/Document/Version/VersionParserTest.php
index 3094f76..6c2ec29 100644
--- a/tests/Unit/Document/Version/VersionParserTest.php
+++ b/tests/Unit/Document/Version/VersionParserTest.php
@@ -8,14 +8,14 @@ use PrinsFrank\PdfParser\Document\Version\Version;
 use PrinsFrank\PdfParser\Document\Version\VersionParser;
 use PrinsFrank\PdfParser\Exception\UnsupportedFileFormatException;
 use PrinsFrank\PdfParser\Exception\UnsupportedPdfVersionException;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\InMemoryStream;
 
 #[CoversClass(VersionParser::class)]
 class VersionParserTest extends TestCase {
     public function testParseThrowsExceptionWhenNoVersionMarker(): void {
         $this->expectException(UnsupportedFileFormatException::class);
         VersionParser::parse(
-            Stream::fromString(
+            new InMemoryStream(
                 'FOO'
             )
         );
@@ -25,7 +25,7 @@ class VersionParserTest extends TestCase {
         $this->expectException(UnsupportedPdfVersionException::class);
         $this->expectExceptionMessage('9.9');
         VersionParser::parse(
-            Stream::fromString(
+            new InMemoryStream(
                 '%PDF-9.9'
             )
         );
@@ -34,7 +34,7 @@ class VersionParserTest extends TestCase {
     public function testParse(): void {
         static::assertSame(
             Version::V1_0,
-            VersionParser::parse(Stream::fromString('%PDF-1.0'))
+            VersionParser::parse(new InMemoryStream('%PDF-1.0'))
         );
     }
 }
diff --git a/tests/Unit/StreamTest.php b/tests/Unit/StreamTest.php
index 3ce8e65..2edbd3d 100644
--- a/tests/Unit/StreamTest.php
+++ b/tests/Unit/StreamTest.php
@@ -5,12 +5,13 @@ namespace PrinsFrank\PdfParser\Tests\Unit;
 use PHPUnit\Framework\Attributes\CoversClass;
 use PHPUnit\Framework\TestCase;
 use PrinsFrank\PdfParser\Document\Generic\Marker;
-use PrinsFrank\PdfParser\Stream;
+use PrinsFrank\PdfParser\Stream\FileStream;
+use PrinsFrank\PdfParser\Stream\AbstractStream;
 
-#[CoversClass(Stream::class)]
+#[CoversClass(AbstractStream::class)]
 class StreamTest extends TestCase {
     public function testStrrpos(): void {
-        $stream = Stream::fromString('123objxref');
+        $stream = FileStream::fromString('123objxref');
         static::assertSame(
             3,
             $stream->lastPos(Marker::OBJ, 0)
@@ -22,7 +23,7 @@ class StreamTest extends TestCase {
     }
 
     public function testFileStructure(): void {
-        $stream = Stream::fromString(
+        $stream = FileStream::fromString(
             <<<EOD
             trailer
             startxref
@@ -31,17 +32,17 @@ class StreamTest extends TestCase {
             EOD
         );
         $eofMarkerPos = $stream->lastPos(Marker::EOF, 0);
-        static::assertNotNull($eofMarkerPos);
+        static::assertSame(23, $eofMarkerPos);
         static::assertSame(Marker::EOF->value, $stream->read($eofMarkerPos, strlen(Marker::EOF->value)));
 
         $startXrefPos = $stream->lastPos(Marker::START_XREF, $stream->getSizeInBytes() - $eofMarkerPos);
-        static::assertNotNull($startXrefPos);
+        static::assertSame(8, $startXrefPos);
         static::assertSame(Marker::START_XREF->value, $stream->read($startXrefPos, strlen(Marker::START_XREF->value)));
 
         $byteOffsetPos = $stream->getStartOfNextLine($startXrefPos, $stream->getSizeInBytes());
-        static::assertNotNull($byteOffsetPos);
+        static::assertSame(18, $byteOffsetPos);
         $byteOffsetEndPos = $stream->getEndOfCurrentLine($byteOffsetPos, $stream->getSizeInBytes());
-        static::assertNotNull($byteOffsetEndPos);
+        static::assertSame(22, $byteOffsetEndPos);
         static::assertSame('1234', $stream->read($byteOffsetPos, $byteOffsetEndPos - $byteOffsetPos));
     }
 }