Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 11 additions & 23 deletions src/Document/ContentStream/ContentStream.php
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,13 @@ public function getPositionedTextElements(): array {
/** @throws PdfParserException */
public function getText(Document $document, Page $page, LineGroupingStrategy $lineGroupingStrategy): string {
$text = '';
foreach ($lineGroupingStrategy->group($this->getPositionedTextElements()) as $i => $positionedTextElementsForLine) {
if ($i !== 0) {
$isFirstLine = true;
foreach ($lineGroupingStrategy->group($this->getPositionedTextElements()) as $positionedTextElementsForLine) {
if (!$isFirstLine) {
$text .= "\n";
}

$isFirstLine = false;
$previousTextElementOnLine = null;
foreach ($positionedTextElementsForLine as $positionedTextElement) {
$elementText = $positionedTextElement->getText($document, $page);
Expand All @@ -93,27 +95,13 @@ public function getText(Document $document, Page $page, LineGroupingStrategy $li
continue;
}

if ($previousTextElementOnLine !== null) {
// The gap between two elements is what remains of the horizontal distance once the previous
// element's own advance is subtracted. That advance is reconstructed by getAdvanceWidth() because
// Tj/TJ do not move the text matrix here; ignoring it (as the old next-element-font width did) left
// the TJ kerning term in the gap and forced a slack threshold.
$gap = $positionedTextElement->absoluteMatrix->offsetX
- $previousTextElementOnLine->absoluteMatrix->offsetX
- $previousTextElementOnLine->getAdvanceWidth($document, $page);

$wordBreakThreshold = ($previousTextElementOnLine->textState->fontSize ?? 10)
* $previousTextElementOnLine->absoluteMatrix->scaleX
* ($previousTextElementOnLine->textState->scale / 100)
* PositionedTextElement::WORD_BREAK_THRESHOLD_EM;

if (
$gap >= $wordBreakThreshold
&& str_ends_with($text, ' ') === false
&& str_starts_with($elementText, ' ') === false
) {
$text .= ' ';
}
if (
$previousTextElementOnLine !== null
&& $lineGroupingStrategy->requiresSpaceBetween($previousTextElementOnLine, $positionedTextElement, $document, $page)
&& str_ends_with($text, ' ') === false
&& str_starts_with($elementText, ' ') === false
) {
$text .= ' ';
}

$text .= $elementText;
Expand Down

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
<?php declare(strict_types=1);

namespace PrinsFrank\PdfParser\Document\ContentStream\PositionedText\LineGroupingStrategy;

use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\Vector;

/**
* A group of parallel {@see Line}s that read as one unit -- a paragraph, a column, or a rotated overlay -- sharing
* a single baseline normal, with the highest point and earliest content-stream position across its lines computed
* once so ordering the blocks against each other never rescans them.
*/
readonly class Block {
public float $top; // the highest point on the page reached by any line in the block (largest offsetY)
public int $documentPosition; // the earliest content-stream position of any line in the block

/** @param list<Line> $lines */
public function __construct(
public Vector $normal,
public array $lines,
) {
$top = null;
$documentPosition = PHP_INT_MAX;
foreach ($lines as $line) {
$top = $top === null ? $line->top : max($top, $line->top);
$documentPosition = min($documentPosition, $line->documentPosition);
}

$this->top = $top ?? 0.0;
$this->documentPosition = $documentPosition;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
<?php declare(strict_types=1);

namespace PrinsFrank\PdfParser\Document\ContentStream\PositionedText\LineGroupingStrategy;

use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\PositionedTextElement;
use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\Vector;

/**
* A run of text elements sharing a single baseline, ordered along it in reading order, together with the geometry
* that places it on the page: its baseline direction, a reference origin, its height, its highest point, and its
* earliest position in the content stream.
*/
readonly class Line {
/** @param list<PositionedTextElement> $elements ordered along the baseline (reading direction) */
private function __construct(
public array $elements,
public Vector $direction, // unit baseline (advance) direction shared by every element on the line
public Vector $reference, // a reference origin on the page for the whole line
public float $height, // the tallest glyph extent on the line
public float $top, // the highest point the line reaches on the page (largest offsetY)
public int $documentPosition, // the earliest content-stream position of any element on the line
) {}

/**
* Collect $elements into a line: order them along $direction (reading direction) and capture the line's
* highest point on the page and earliest content-stream position.
*
* @param list<PositionedTextElement> $elements
* @param array<int, int> $documentOrder spl_object_id() => position in the content stream
*/
public static function fromElements(array $elements, Vector $direction, Vector $reference, float $height, array $documentOrder): self {
usort(
$elements,
static function (PositionedTextElement $a, PositionedTextElement $b) use ($direction): int {
$projectionA = $a->absoluteMatrix->offsetX * $direction->x + $a->absoluteMatrix->offsetY * $direction->y;
$projectionB = $b->absoluteMatrix->offsetX * $direction->x + $b->absoluteMatrix->offsetY * $direction->y;

return $projectionA <=> $projectionB;
},
);

$top = $reference->y;
$documentPosition = PHP_INT_MAX;
foreach ($elements as $element) {
$top = max($top, $element->absoluteMatrix->offsetY);
$documentPosition = min($documentPosition, $documentOrder[spl_object_id($element)]);
}

return new self($elements, $direction, $reference, $height, $top, $documentPosition);
}

/** Signed coordinate of the line along $normal: its position in a stack of parallel lines. */
public function positionAlong(Vector $normal): float {
return $this->reference->dot($normal);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,22 @@
namespace PrinsFrank\PdfParser\Document\ContentStream\PositionedText\LineGroupingStrategy;

use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\PositionedTextElement;
use PrinsFrank\PdfParser\Document\Document;
use PrinsFrank\PdfParser\Document\Object\Decorator\Page;
use PrinsFrank\PdfParser\Exception\PdfParserException;

interface LineGroupingStrategy {
/**
* @param list<PositionedTextElement> $positionedTextElements
* @return iterable<list<PositionedTextElement>>
*/
public function group(array $positionedTextElements): iterable;

/**
* Whether a space belongs between two consecutive runs on the same line. The strategy owns this decision
* because the gap is meaningful only relative to how that strategy laid the runs out.
*
* @throws PdfParserException
*/
public function requiresSpaceBetween(PositionedTextElement $previous, PositionedTextElement $current, Document $document, Page $page): bool;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
<?php declare(strict_types=1);

namespace PrinsFrank\PdfParser\Document\ContentStream\PositionedText\LineGroupingStrategy;

use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\PositionedTextElement;
use PrinsFrank\PdfParser\Document\Document;
use PrinsFrank\PdfParser\Document\Object\Decorator\Page;
use PrinsFrank\PdfParser\Exception\PdfParserException;

/**
* Axis-aligned space-insertion heuristic shared by the strategies that lay text out along page X
* ({@see TextOverlapStrategy}, {@see StrictLineGrouping}). A space belongs between two runs when the gap between
* their X offsets, less the reconstructed advance width of the previous run, reaches a single
* {@see PositionedTextElement::WORD_BREAK_THRESHOLD_EM} fraction of the em. With an accurate advance the within-word residual
* collapses near zero, so one threshold separates word breaks from kerning across producers. Holds only for
* upright text; {@see BaselineClusterStrategy} measures the same comparison along an arbitrary baseline.
*/
trait MatrixOffsetSpacing {
/** @throws PdfParserException */
public function requiresSpaceBetween(PositionedTextElement $previous, PositionedTextElement $current, Document $document, Page $page): bool {
$gap = $current->absoluteMatrix->offsetX
- $previous->absoluteMatrix->offsetX
- $previous->getAdvanceWidth($document, $page);

$threshold = $previous->textState->getFontSize()
* $previous->absoluteMatrix->scaleX
* ($previous->textState->scale / 100)
* PositionedTextElement::WORD_BREAK_THRESHOLD_EM;

return $gap >= $threshold;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

/** Line grouping is done on _exact_ offsetY */
class StrictLineGrouping implements LineGroupingStrategy {
use MatrixOffsetSpacing;

#[Override]
public function group(array $positionedTextElements): iterable {
usort(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@
* And for each text element check if there is significant overlap above a threshold. Continue until all elements are processed
*/
class TextOverlapStrategy implements LineGroupingStrategy {
use MatrixOffsetSpacing;

/** @param int<0, 100> $overlapPercentage */
public function __construct(
private readonly int $overlapPercentage = 90,
Expand Down
28 changes: 17 additions & 11 deletions src/Document/ContentStream/PositionedText/PositionedTextElement.php
Original file line number Diff line number Diff line change
Expand Up @@ -63,28 +63,34 @@ public function getCodePoints(): array {
}

public function getHeight(): float {
return ($this->textState->fontSize ?? 12)
* abs($this->absoluteMatrix->scaleY)
return $this->textState->getFontSize()
* hypot($this->absoluteMatrix->shearY, $this->absoluteMatrix->scaleY) // Length of the glyph's vertical axis: its extent perpendicular to the baseline
* ($this->textState->scale / 100);
}

/**
* The horizontal distance, in device space, that showing this element advances the text cursor, per the
* displacement formula in the PDF spec §9.4.4:
* The distance, in device space, that showing this element advances the text cursor, per the displacement
* formula in the PDF spec §9.4.4:
*
* ((w0 − Tj/1000)·Tfs + Tc + Tw·[single-byte code 32]) · Th , transformed by the text rendering matrix.
*
* Reconstructed here because Tj/TJ do not advance the text matrix in this parser.
* Reconstructed here because Tj/TJ do not advance the text matrix in this parser. The text-space advance is
* horizontal; transformed by the matrix it becomes a page-space vector along the baseline. This returns that
* vector's signed projection onto the unit vector $direction -- a dot product, so it keeps its sign. The
* default direction is page X (Vector(1, 0)), giving the advance · scaleX exactly as before; pass a baseline
* unit vector to measure the advance along a rotated baseline.
*/
public function getAdvanceWidth(Document $document, Page $page): float {
public function getAdvanceWidth(Document $document, Page $page, Vector $direction = new Vector(1.0, 0.0)): float {
$font = $this->getFont($document, $page);
$scaleX = $this->absoluteMatrix->scaleX;
$fontSize = $this->textState->fontSize ?? 10;
$fontSize = $this->textState->getFontSize();

$glyphAdvance = $font->getWidthForChars($this->getCodePoints(), $this->textState, $this->absoluteMatrix); // Σ (w0·Tfs + Tc + Tw·[code 32]) · scaleX
$offsetAdvance = -($this->getTotalOffset() / 1000) * $fontSize * $scaleX; // − Σ(Tj)/1000 · Tfs · scaleX
$glyphAdvance = $font->getWidthForChars($this->getCodePoints(), $this->textState); // Σ (w0·Tfs + Tc + Tw·[code 32])
$offsetAdvance = -($this->getTotalOffset() / 1000) * $fontSize; // − Σ(Tj)/1000 · Tfs

return ($glyphAdvance + $offsetAdvance) * ($this->textState->scale / 100); // · Th
$textSpaceAdvance = ($glyphAdvance + $offsetAdvance) * ($this->textState->scale / 100); // · Th

// Project the page-space advance vector (the text-space advance along the baseline) onto $direction.
return $textSpaceAdvance * $this->absoluteMatrix->baselineVector()->dot($direction);
}

/** The sum of the TJ adjustment numbers in this element's segments, in thousandths of an em. */
Expand Down
8 changes: 8 additions & 0 deletions src/Document/ContentStream/PositionedText/TextState.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,9 @@
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryKey\ExtendedDictionaryKey;

readonly class TextState {
/** @var float Assumed font size (Tfs) when a content stream shows text without ever setting one; the PDF spec defines no default. */
private const DEFAULT_FONT_SIZE = 10.0;
Comment thread
splitbrain marked this conversation as resolved.

public function __construct(
public DictionaryKey|ExtendedDictionaryKey|null $fontName, // Tf
public ?float $fontSize, // Tfs
Expand All @@ -17,6 +20,11 @@ public function __construct(
public float $rise = 0, // Trise
) {}

/** The effective font size (Tfs), falling back to an assumed default when the content stream never set one. */
public function getFontSize(): float {
return $this->fontSize ?? self::DEFAULT_FONT_SIZE;
}

public function withFont(DictionaryKey|ExtendedDictionaryKey|null $fontName, ?float $fontSize): self {
return new TextState(
$fontName,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ public function __construct(
public float $offsetY, // f
) {}

/** The baseline (text advance) direction in page space: the matrix's first column (scaleX, shearX). */
public function baselineVector(): Vector {
return new Vector($this->scaleX, $this->shearX);
}

/** Please note that a concatenated transformation matrix of A B !== B A */
public function multiplyWith(self $other): self {
return new self(
Expand Down
36 changes: 36 additions & 0 deletions src/Document/ContentStream/PositionedText/Vector.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
<?php declare(strict_types=1);

namespace PrinsFrank\PdfParser\Document\ContentStream\PositionedText;

/** A 2D vector in page (device) space. */
readonly class Vector {
public function __construct(
public float $x,
public float $y,
) {}

/** Euclidean length of the vector. */
public function length(): float {
return hypot($this->x, $this->y);
}

/** The unit vector in the same direction, or the zero vector when there is no direction to preserve. */
public function normalized(): self {
$length = $this->length();
if ($length === 0.0) {
return new self(0.0, 0.0);
}

return new self($this->x / $length, $this->y / $length);
}

/** Dot (scalar) product with $other; for unit vectors this is the cosine of the angle between them. */
public function dot(self $other): float {
return $this->x * $other->x + $this->y * $other->y;
}

/** This vector rotated 90 degrees counter-clockwise: (x, y) -> (-y, x). */
public function normal(): self {
return new self(-$this->y, $this->x);
}
}
10 changes: 5 additions & 5 deletions src/Document/Object/Decorator/Font.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
use PrinsFrank\PdfParser\Document\CMap\ToUnicode\ToUnicodeCMap;
use PrinsFrank\PdfParser\Document\CMap\ToUnicode\ToUnicodeCMapParser;
use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\TextState;
use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\TransformationMatrix;
use PrinsFrank\PdfParser\Document\Dictionary\Dictionary;
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryKey\DictionaryKey;
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryKey\ExtendedDictionaryKey;
Expand Down Expand Up @@ -138,7 +137,8 @@ public function getLastChar(): ?int {
?->value;
}

public function getWidthForChar(int $characterCode, TextState $textState, TransformationMatrix $transformationMatrix): float {
/** The advance width of a single character code in unscaled text space (w0·Tfs + Tc + Tw·[code 32]); the caller applies the matrix. */
public function getWidthForChar(int $characterCode, TextState $textState): float {
$fontWidths = $this->getWidths();
if ($fontWidths !== null && ($charWidth = $fontWidths->getWidthForCharacter($characterCode)) !== null) {
$characterWidth = $charWidth;
Expand All @@ -151,14 +151,14 @@ public function getWidthForChar(int $characterCode, TextState $textState, Transf
? $textState->wordSpace
: 0.0;

return ($characterWidth * ($textState->fontSize ?? 10) + $textState->charSpace + $wordSpace) * $transformationMatrix->scaleX;
return $characterWidth * $textState->getFontSize() + $textState->charSpace + $wordSpace;
}

/** @param list<int> $chars */
public function getWidthForChars(array $chars, TextState $textState, TransformationMatrix $transformationMatrix): float {
public function getWidthForChars(array $chars, TextState $textState): float {
$totalCharacterWidth = 0;
foreach ($chars as $char) {
$totalCharacterWidth += $this->getWidthForChar($char, $textState, $transformationMatrix);
$totalCharacterWidth += $this->getWidthForChar($char, $textState);
}

return $totalCharacterWidth;
Expand Down
4 changes: 2 additions & 2 deletions src/Document/Object/Decorator/Page.php
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

namespace PrinsFrank\PdfParser\Document\Object\Decorator;

use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\LineGroupingStrategy\TextOverlapStrategy;
use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\LineGroupingStrategy\BaselineClusterStrategy;
use PrinsFrank\PdfParser\Document\ContentStream\PositionedText\PositionedTextElement;
use PrinsFrank\PdfParser\Document\Dictionary\Dictionary;
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryKey\DictionaryKey;
Expand All @@ -27,7 +27,7 @@ public function getPositionedTextElements(): array {
/** @throws PdfParserException */
public function getText(): string {
return $this->getContentStream()
?->getText($this->document, $this, new TextOverlapStrategy()) ?? '';
?->getText($this->document, $this, new BaselineClusterStrategy()) ?? '';
}

/** @throws PdfParserException */
Expand Down
Loading
Loading