Skip to content

Commit bb8f66f

Browse files
authored
Pre-parse text segments in positioned text elements (#432)
1 parent 6c167b1 commit bb8f66f

5 files changed

Lines changed: 130 additions & 95 deletions

File tree

src/Document/ContentStream/Command/Operator/State/TextShowingOperator.php

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@
88
use PrinsFrank\PdfParser\Document\ContentStream\Command\Operator\State\Interaction\InteractsWithTransformationMatrix;
99
use PrinsFrank\PdfParser\Document\ContentStream\Command\Operator\State\Interaction\ProducesPositionedTextElements;
1010
use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\PositionedTextElement;
11+
use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\TextSegment\TextSegment;
1112
use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\TransformationMatrix;
1213
use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\TextState;
14+
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\TextString\TextStringValue;
1315
use PrinsFrank\PdfParser\Exception\ParseFailureException;
1416

1517
/** @internal */
@@ -55,8 +57,17 @@ public function applyToTextState(string $operands, TextState $textState): TextSt
5557

5658
#[Override]
5759
public function getPositionedTextElement(string $operands, TransformationMatrix $textMatrix, TransformationMatrix $globalTransformationMatrix, TextState $textState): PositionedTextElement {
60+
if (($result = preg_match_all('/(?<chars>(<(\\\\>|[^>])*>)|(\((\\\\\)|[^)])*\)))(?<offset>-?[0-9]+(\.[0-9]+)?)?/', $operands, $matches, PREG_SET_ORDER | PREG_UNMATCHED_AS_NULL)) === false) {
61+
throw new ParseFailureException(sprintf('Error with regex'));
62+
} elseif ($result === 0) {
63+
throw new ParseFailureException(sprintf('Operands "%s" is not in a recognized format', $operands));
64+
}
65+
5866
return new PositionedTextElement(
59-
$operands,
67+
array_map(
68+
fn(array $match) => new TextSegment(new TextStringValue($match['chars']), $match['offset'] !== null ? (float) $match['offset'] : null),
69+
$matches,
70+
),
6071
$textMatrix->multiplyWith($globalTransformationMatrix), // 9.4.4, Note 2 on Trm calculation
6172
$textState,
6273
);

src/Document/ContentStream/PositionedText/PositionedTextElement.php

Lines changed: 8 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -2,16 +2,16 @@
22

33
namespace PrinsFrank\PdfParser\Document\ContentStream\PositionedText;
44

5-
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Name\EncodingNameValue;
5+
use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\TextSegment\TextSegment;
66
use PrinsFrank\PdfParser\Document\Document;
7-
use PrinsFrank\PdfParser\Document\Generic\Character\LiteralStringEscapeCharacter;
87
use PrinsFrank\PdfParser\Document\Object\Decorator\Font;
98
use PrinsFrank\PdfParser\Document\Object\Decorator\Page;
109
use PrinsFrank\PdfParser\Exception\ParseFailureException;
1110

1211
readonly class PositionedTextElement {
12+
/** @param list<TextSegment> $textSegments */
1313
public function __construct(
14-
public string $rawTextContent,
14+
public array $textSegments,
1515
public TransformationMatrix $absoluteMatrix,
1616
public TextState $textState,
1717
) {}
@@ -27,48 +27,11 @@ public function getFont(Document $document, Page $page): Font {
2727

2828
/** @throws ParseFailureException */
2929
public function getText(Document $document, Page $page): string {
30-
if (($result = preg_match_all('/(?<chars>(<(\\\\>|[^>])*>)|(\((\\\\\)|[^)])*\)))(?<offset>-?[0-9]+(\.[0-9]+)?)?/', $this->rawTextContent, $matches, PREG_SET_ORDER)) === false) {
31-
throw new ParseFailureException(sprintf('Error with regex'));
32-
} elseif ($result === 0) {
33-
throw new ParseFailureException(sprintf('Operands "%s" is not in a recognized format', $this->rawTextContent));
34-
}
35-
36-
$string = '';
3730
$font = $this->getFont($document, $page);
38-
foreach ($matches as $match) {
39-
if (str_starts_with($match['chars'], '(') && str_ends_with($match['chars'], ')')) {
40-
$unescapedChars = LiteralStringEscapeCharacter::unescapeCharacters(substr($match['chars'], 1, -1));
41-
if (preg_match('/^\\\\\d{3}$/', substr($match['chars'], 1, -1)) === 1 && ($glyph = $font->getDifferences()?->getGlyph((int) octdec(substr($match['chars'], 2, -1)))) !== null) {
42-
$chars = $glyph->getChar();
43-
} elseif (strlen($unescapedChars) === 1 && ($glyph = $font->getDifferences()?->getGlyph(ord($unescapedChars))) !== null) {
44-
$chars = $glyph->getChar();
45-
} elseif (in_array($encoding = $font->getEncoding(), [EncodingNameValue::MacExpertEncoding, EncodingNameValue::WinAnsiEncoding], true) && $font->getDifferences() === null) {
46-
$chars = $encoding->decodeString($unescapedChars);
47-
} elseif (($toUnicodeCMap = $font->getToUnicodeCMap() ?? $font->getToUnicodeCMapDescendantFont()) !== null) {
48-
$chars = $toUnicodeCMap->textToUnicode(bin2hex($unescapedChars));
49-
} elseif ($encoding !== null) {
50-
$chars = $encoding->decodeString($unescapedChars);
51-
} else {
52-
$chars = $unescapedChars;
53-
}
54-
55-
$string .= $chars;
56-
} elseif (str_starts_with($match['chars'], '<') && str_ends_with($match['chars'], '>')) {
57-
$chars = substr($match['chars'], 1, -1);
58-
if (($toUnicodeCMap = $font->getToUnicodeCMap() ?? $font->getToUnicodeCMapDescendantFont()) !== null) {
59-
$string .= $toUnicodeCMap->textToUnicode($chars);
60-
} elseif (($encoding = $font->getEncoding()) !== null) {
61-
$string .= $encoding->decodeString(implode('', array_map(fn(string $character) => mb_chr((int) hexdec($character)), str_split($chars, 2))));
62-
} else {
63-
$string .= EncodingNameValue::IdentityH->decodeString($chars);
64-
}
65-
} else {
66-
throw new ParseFailureException(sprintf('Unrecognized character group format "%s"', $match['chars']));
67-
}
6831

69-
if (isset($match['offset']) && (float) $match['offset'] < -100) {
70-
$string .= ' ';
71-
}
32+
$string = '';
33+
foreach ($this->textSegments as $textSegment) {
34+
$string .= $textSegment->getText($font);
7235
}
7336

7437
return $string;
@@ -77,27 +40,8 @@ public function getText(Document $document, Page $page): string {
7740
/** @return list<int> */
7841
public function getCodePoints(): array {
7942
$codePoints = [];
80-
if (($result = preg_match_all('/(?<chars>(<(\\\\>|[^>])*>)|(\((\\\\\)|[^)])*\)))(?<offset>-?[0-9]+(\.[0-9]+)?)?/', $this->rawTextContent, $matches, PREG_SET_ORDER)) === false) {
81-
throw new ParseFailureException(sprintf('Error with regex'));
82-
} elseif ($result === 0) {
83-
throw new ParseFailureException(sprintf('Operands "%s" is not in a recognized format', $this->rawTextContent));
84-
}
85-
86-
foreach ($matches as $match) {
87-
if (str_starts_with($match['chars'], '(') && str_ends_with($match['chars'], ')')) {
88-
$chars = str_replace(['\(', '\)', '\n', '\r'], ['(', ')', "\n", "\r"], substr($match['chars'], 1, -1));
89-
$chars = preg_replace_callback('/\\\\([0-7]{3})/', fn(array $matches) => mb_chr((int) octdec($matches[1])), $chars)
90-
?? throw new ParseFailureException();
91-
foreach (str_split($chars) as $char) {
92-
$codePoints[] = ord($char);
93-
}
94-
} elseif (str_starts_with($match['chars'], '<') && str_ends_with($match['chars'], '>')) {
95-
foreach (str_split(substr($match['chars'], 1, -1), 4) as $char) {
96-
$codePoints[] = is_int($codePoint = hexdec($char)) ? $codePoint : throw new ParseFailureException();
97-
}
98-
} else {
99-
throw new ParseFailureException(sprintf('Unrecognized character group format "%s"', $match['chars']));
100-
}
43+
foreach ($this->textSegments as $textSegment) {
44+
array_push($codePoints, ...$textSegment->getCodePoints());
10145
}
10246

10347
return $codePoints;
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
<?php declare(strict_types=1);
2+
3+
namespace PrinsFrank\PdfParser\Document\ContentStream\PositionedText\TextSegment;
4+
5+
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Name\EncodingNameValue;
6+
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\TextString\TextStringValue;
7+
use PrinsFrank\PdfParser\Document\Generic\Character\LiteralStringEscapeCharacter;
8+
use PrinsFrank\PdfParser\Document\Object\Decorator\Font;
9+
use PrinsFrank\PdfParser\Exception\ParseFailureException;
10+
11+
readonly class TextSegment {
12+
public function __construct(
13+
public TextStringValue $textString,
14+
public int|float|null $offset,
15+
) {}
16+
17+
public function getText(Font $font): string {
18+
$text = '';
19+
if (str_starts_with($this->textString->textStringValue, '(') && str_ends_with($this->textString->textStringValue, ')')) {
20+
$unescapedChars = LiteralStringEscapeCharacter::unescapeCharacters(substr($this->textString->textStringValue, 1, -1));
21+
if (preg_match('/^\\\\\d{3}$/', substr($this->textString->textStringValue, 1, -1)) === 1 && ($glyph = $font->getDifferences()?->getGlyph((int) octdec(substr($this->textString->textStringValue, 2, -1)))) !== null) {
22+
$chars = $glyph->getChar();
23+
} elseif (strlen($unescapedChars) === 1 && ($glyph = $font->getDifferences()?->getGlyph(ord($unescapedChars))) !== null) {
24+
$chars = $glyph->getChar();
25+
} elseif (in_array($encoding = $font->getEncoding(), [EncodingNameValue::MacExpertEncoding, EncodingNameValue::WinAnsiEncoding], true) && $font->getDifferences() === null) {
26+
$chars = $encoding->decodeString($unescapedChars);
27+
} elseif (($toUnicodeCMap = $font->getToUnicodeCMap() ?? $font->getToUnicodeCMapDescendantFont()) !== null) {
28+
$chars = $toUnicodeCMap->textToUnicode(bin2hex($unescapedChars));
29+
} elseif ($encoding !== null) {
30+
$chars = $encoding->decodeString($unescapedChars);
31+
} else {
32+
$chars = $unescapedChars;
33+
}
34+
35+
$text .= $chars;
36+
} elseif (str_starts_with($this->textString->textStringValue, '<') && str_ends_with($this->textString->textStringValue, '>')) {
37+
$chars = substr($this->textString->textStringValue, 1, -1);
38+
if (($toUnicodeCMap = $font->getToUnicodeCMap() ?? $font->getToUnicodeCMapDescendantFont()) !== null) {
39+
$text .= $toUnicodeCMap->textToUnicode($chars);
40+
} elseif (($encoding = $font->getEncoding()) !== null) {
41+
$text .= $encoding->decodeString(implode('', array_map(fn(string $character) => mb_chr((int) hexdec($character)), str_split($chars, 2))));
42+
} else {
43+
$text .= EncodingNameValue::IdentityH->decodeString($chars);
44+
}
45+
} else {
46+
throw new ParseFailureException(sprintf('Unrecognized character group format "%s"', $this->textString->textStringValue));
47+
}
48+
49+
if ($this->offset !== null && $this->offset < -100) {
50+
$text .= ' ';
51+
}
52+
53+
return $text;
54+
}
55+
56+
/** @return list<int> */
57+
public function getCodePoints(): array {
58+
$codePoints = [];
59+
if (str_starts_with($this->textString->textStringValue, '(') && str_ends_with($this->textString->textStringValue, ')')) {
60+
$chars = str_replace(['\(', '\)', '\n', '\r'], ['(', ')', "\n", "\r"], substr($this->textString->textStringValue, 1, -1));
61+
$chars = preg_replace_callback('/\\\\([0-7]{3})/', fn(array $matches) => mb_chr((int) octdec($matches[1])), $chars)
62+
?? throw new ParseFailureException();
63+
foreach (str_split($chars) as $char) {
64+
$codePoints[] = ord($char);
65+
}
66+
} elseif (str_starts_with($this->textString->textStringValue, '<') && str_ends_with($this->textString->textStringValue, '>')) {
67+
foreach (str_split(substr($this->textString->textStringValue, 1, -1), 4) as $char) {
68+
$codePoints[] = is_int($codePoint = hexdec($char)) ? $codePoint : throw new ParseFailureException();
69+
}
70+
} else {
71+
throw new ParseFailureException(sprintf('Unrecognized character group format "%s"', $this->textString->textStringValue));
72+
}
73+
74+
return $codePoints;
75+
}
76+
}

0 commit comments

Comments
 (0)