Skip to content

Commit ae7179e

Browse files
committed
Detect word breaks with a single threshold against a reconstructed advance width
Word-break detection compares the gap between two adjacent text elements against a threshold. The gap is next.offsetX - prev.offsetX - advance(prev), so it is only as accurate as the reconstructed advance. PositionedTextElement::getAdvanceWidth() reconstructs the cursor advance per spec §9.4.4: per-glyph width and Tc, minus the TJ kerning numbers, plus Tw for single-byte code 32 (gated off for composite fonts), scaled by Th and the text matrix, and measured with the element's own font rather than the next element's. With an accurate advance, within-word gaps collapse near zero, so the gap that signals a word break is a roughly constant fraction of the em across producers: a single WORD_BREAK_THRESHOLD of 0.25 separates word breaks from kerning. The intra-element large-negative-offset rule reuses the same constant.
1 parent 5e2be93 commit ae7179e

15 files changed

Lines changed: 453 additions & 398 deletions

File tree

src/Document/ContentStream/ContentStream.php

Lines changed: 22 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,8 @@
2020

2121
/** @api */
2222
readonly class ContentStream {
23+
public const WORD_BREAK_THRESHOLD = 0.25;
24+
2325
/** @var list<TextObject|ContentStreamCommand> */
2426
public array $content;
2527

@@ -87,18 +89,32 @@ public function getText(Document $document, Page $page, LineGroupingStrategy $li
8789

8890
$previousTextElementOnLine = null;
8991
foreach ($positionedTextElementsForLine as $positionedTextElement) {
90-
if (($positionedTextElementText = $positionedTextElement->getText($document, $page)) === '') {
92+
$elementText = $positionedTextElement->getText($document, $page);
93+
if ($elementText === '') {
9194
$previousTextElementOnLine = $positionedTextElement;
9295
continue;
9396
}
9497

95-
if ($previousTextElementOnLine !== null
96-
&& ($positionedTextElement->absoluteMatrix->offsetX - $previousTextElementOnLine->absoluteMatrix->offsetX - $positionedTextElement->getFont($document, $page)->getWidthForChars($previousTextElementOnLine->getCodePoints(), $previousTextElementOnLine->textState, $previousTextElementOnLine->absoluteMatrix)) >= ($previousTextElementOnLine->textState->fontSize ?? 10) * $previousTextElementOnLine->absoluteMatrix->scaleX * 0.40
97-
&& str_ends_with($text, ' ') === false && str_starts_with($positionedTextElementText, ' ') === false) {
98-
$text .= ' ';
98+
if ($previousTextElementOnLine !== null) {
99+
$gap = $positionedTextElement->absoluteMatrix->offsetX
100+
- $previousTextElementOnLine->absoluteMatrix->offsetX
101+
- $previousTextElementOnLine->getAdvanceWidth($document, $page);
102+
103+
$wordBreakThreshold = ($previousTextElementOnLine->textState->fontSize ?? 10)
104+
* $previousTextElementOnLine->absoluteMatrix->scaleX
105+
* ($previousTextElementOnLine->textState->scale / 100)
106+
* self::WORD_BREAK_THRESHOLD;
107+
108+
if (
109+
$gap >= $wordBreakThreshold
110+
&& str_ends_with($text, ' ') === false
111+
&& str_starts_with($elementText, ' ') === false
112+
) {
113+
$text .= ' ';
114+
}
99115
}
100116

101-
$text .= $positionedTextElementText;
117+
$text .= $elementText;
102118
$previousTextElementOnLine = $positionedTextElement;
103119
}
104120
}

src/Document/ContentStream/PositionedText/PositionedTextElement.php

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,4 +55,35 @@ public function getHeight(): float {
5555
* abs($this->absoluteMatrix->scaleY)
5656
* ($this->textState->scale / 100);
5757
}
58+
59+
/**
60+
* The horizontal distance, in device space, that showing this element advances the text cursor, per the
61+
* displacement formula in the PDF spec §9.4.4:
62+
*
63+
* ((w0 − Tj/1000)·Tfs + Tc + Tw·[single-byte code 32]) · Th , transformed by the text rendering matrix.
64+
*
65+
* Reconstructed here because Tj/TJ do not advance the text matrix in this parser.
66+
*/
67+
public function getAdvanceWidth(Document $document, Page $page): float {
68+
$font = $this->getFont($document, $page);
69+
$scaleX = $this->absoluteMatrix->scaleX;
70+
$fontSize = $this->textState->fontSize ?? 10;
71+
72+
$glyphAdvance = $font->getWidthForChars($this->getCodePoints(), $this->textState, $this->absoluteMatrix); // Σ (w0·Tfs + Tc + Tw·[code 32]) · scaleX
73+
$offsetAdvance = -($this->getTotalOffset() / 1000) * $fontSize * $scaleX; // − Σ(Tj)/1000 · Tfs · scaleX
74+
75+
return ($glyphAdvance + $offsetAdvance) * ($this->textState->scale / 100); // · Th
76+
}
77+
78+
/** The sum of the TJ adjustment numbers in this element's segments, in thousandths of an em. */
79+
public function getTotalOffset(): float {
80+
$totalOffset = 0.0;
81+
foreach ($this->textSegments as $textSegment) {
82+
if ($textSegment->offset !== null) {
83+
$totalOffset += $textSegment->offset;
84+
}
85+
}
86+
87+
return $totalOffset;
88+
}
5889
}

src/Document/ContentStream/PositionedText/TextSegment/TextSegment.php

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
namespace PrinsFrank\PdfParser\Document\ContentStream\PositionedText\TextSegment;
44

55
use PrinsFrank\PdfParser\Document\CMap\ToUnicode\ToUnicodeCMap;
6+
use PrinsFrank\PdfParser\Document\ContentStream\ContentStream;
67
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Array\DifferencesArrayValue;
78
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\Name\EncodingNameValue;
89
use PrinsFrank\PdfParser\Document\Dictionary\DictionaryValue\TextString\TextStringValue;
@@ -29,7 +30,9 @@ public function getText(?DifferencesArrayValue $differences, ?EncodingNameValue
2930
$text = $binaryString;
3031
}
3132

32-
if ($this->offset !== null && $this->offset < -100) {
33+
// A large negative TJ adjustment inside a single run is the same word break as a between-element gap,
34+
// measured directly: −offset/1000 is the gap in em, so it is a word break above the same threshold.
35+
if ($this->offset !== null && $this->offset / 1000 <= -ContentStream::WORD_BREAK_THRESHOLD) {
3336
$text .= ' ';
3437
}
3538

src/Document/Object/Decorator/Font.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -146,7 +146,12 @@ public function getWidthForChar(int $characterCode, TextState $textState, Transf
146146
$characterWidth = $this->getDefaultWidth();
147147
}
148148

149-
return ($characterWidth * ($textState->fontSize ?? 10) + $textState->charSpace) * $transformationMatrix->scaleX;
149+
// Word spacing (Tw) applies only to the single-byte character code 32, and never to composite (Type0) fonts (spec §9.3.3).
150+
$wordSpace = ($textState->wordSpace !== 0.0 && $characterCode === 32 && $this->getDescendantFonts() === [])
151+
? $textState->wordSpace
152+
: 0.0;
153+
154+
return ($characterWidth * ($textState->fontSize ?? 10) + $textState->charSpace + $wordSpace) * $transformationMatrix->scaleX;
150155
}
151156

152157
/** @param list<int> $chars */

tests/Samples/files/gdocs-hello-world-simple-password/contents.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ creationDate: null
1111
modificationDate: null
1212
pages:
1313
-
14-
content: Helloworld
14+
content: 'Hello world'

tests/Samples/files/gdocs-hello-world-simple/contents.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,4 @@ creationDate: null
1111
modificationDate: null
1212
pages:
1313
-
14-
content: Helloworld
14+
content: 'Hello world'

tests/Samples/files/gdocs-lorem-ipsum-with-titles-and-formatting/contents.yml

Lines changed: 50 additions & 50 deletions
Original file line numberDiff line numberDiff line change
@@ -12,56 +12,56 @@ modificationDate: null
1212
pages:
1313
-
1414
content: |-
15-
Namquodmolestiasvelcorporis
15+
Nam quod molestias vel corporis
1616
aperiam.
17-
Loremipsumdolorsitamet.EtomnisperferendisEtquisquamquilaboriosamexplicaboet
18-
natuscorruptiautrepudiandaeiurequoinventoreitaqueetodioatque.Quinecessitatibus
19-
oditetcommodiaccusamusEtfuga!
20-
Quidistinctiopraesentiumsedcorporisreiciendiseum
21-
molestiaeeius.
22-
Quiaperiamaspernaturremconsecteturullamestquodquiarchitectocorporiset
23-
repellendusexpedita!ExfugaducimusEaipsametnobisarchitectosedrationedeseruntet
24-
iuresapientequianimiQuis.CumquodfaciliseosexercitationemquamEstullamest
25-
galisumassumendacumdistinctiotemporaquiquasirerumetpariaturharum.
26-
Estinciduntrepellatautiustoodit.
27-
EtmagnamincidunteumpossimusperspiciatisQuopariaturautperferendislaboriosam
28-
eumrepellatipsautdebitisipsautdistinctiodolores!EtrerumexpeditaAutullamadullam
29-
delenitiestfacilisconsequaturutarchitectonobis.
30-
Nondebitisexpeditaeareprehenderitasperioresetvoluptatemquos.
31-
NonminimasolutaSeddelectusquirepellendusdoloremetgalisumquia.Nonsuntdebitis
32-
abvitaeaspernaturExminuseumoptioassumendaestvoluptatemillumetharumipsam
33-
remveniamculpa.AutadipiscideseruntnoninventorerationeHicistevelsintundenon
34-
solutanullainculpaiureetquiarecusandae?QuiveniamquiaidmaioresexcepturiQui
35-
doloresetofficiaaperiamabsintexcepturi.
36-
Estmolestiasillumestdolorempraesentiumcumsolutanesciunt.
37-
UtvelitaspernaturutearumdebitisQuiullamautquoddolorumquomolestiaemagniet
38-
corruptiminima.IddolorerrorabdignissimoscorporisQuocommodi.Etsolutadoloribus
39-
Eosquaeinmagnampraesentiumautaccusantiumlaboriosamnonnemoreprehenderit.
40-
EtevenietducimusUtvoluptatemetporrovelitidsuscipitimpedit!VelculpaitaqueEosfuga
41-
auttotamlaudantiumquievenietmolestiaeveldoloresfugiat.
42-
EstsaepesolutaetverovoluptatemQuomaxime!EostemporaquasadnesciuntitaqueEa
43-
nihiletullamrepellatetvoluptatemdelectus.AutvoluptatumpariaturIdmolestiascum
44-
nobismolestias.UtvelitdistinctioExconsectetureosdebitisperspiciatisaminuscommodi
45-
eosdoloribusautemvelaccusamussequietquidemreiciendis.
46-
Utautemexcepturiutsequilaboriosamestquasdebitisetplaceatconsequatur!
17+
Lorem ipsum dolor sit amet. Et omnis perferendis Et quisquam qui laboriosam explicabo et
18+
natus corrupti aut repudiandae iure quo inventore itaque et odio atque. Qui necessitatibus
19+
odit et commodi accusamus Et fuga!
20+
Qui distinctio praesentium sed corporis reiciendis eum
21+
molestiae eius.
22+
Qui aperiam aspernatur rem consectetur ullamest quod qui architecto corporis et
23+
repellendus expedita! Ex fuga ducimus Ea ipsam et nobis architecto sed ratione deserunt et
24+
iure sapiente qui animi Quis. Cum quod facilis eos exercitationem quam Est ullam est
25+
galisum assumenda cum distinctio tempora qui quasi rerum et pariatur harum.
26+
Est incidunt repellat aut iusto odit.
27+
Et magnam incidunt eum possimus perspiciatis Quo pariatur aut perferendis laboriosam
28+
eum repellat ipsa ut debitis ipsa ut distinctio dolores! Et rerum expeditaAut ullam ad ullam
29+
deleniti est facilis consequatur ut architecto nobis.
30+
Non debitis expedita ea reprehenderit asperiores et voluptatem quos.
31+
Non minima soluta Sed delectus qui repellendus dolorem et galisum quia. Non sunt debitis
32+
ab vitae aspernatur Ex minus eum optio assumenda est voluptatem illum et harum ipsam
33+
rem veniam culpa. Aut adipisci deserunt non inventore ratione Hic iste vel sint unde non
34+
soluta nulla in culpa iure et quia recusandae? Qui veniam quia id maiores excepturiQui
35+
dolores et officia aperiam ab sint excepturi.
36+
Est molestias illum est dolorem praesentium cum soluta nesciunt.
37+
Ut velit aspernatur ut earum debitis Qui ullam aut quod dolorum quo molestiae magni et
38+
corrupti minima. Id dolor error ab dignissimos corporis Quo commodi. Et soluta doloribus
39+
Eos quae in magnam praesentium aut accusantium laboriosam non nemo reprehenderit.
40+
Et eveniet ducimus Ut voluptatem et porro velit id suscipit impedit! Vel culpa itaque Eos fuga
41+
aut totam laudantium qui eveniet molestiae vel dolores fugiat.
42+
Est saepe soluta et vero voluptatemQuo maxime! Eos tempora quas ad nesciunt itaque Ea
43+
nihil et ullam repellat et voluptatem delectus. Aut voluptatum pariatur Id molestias cum
44+
nobis molestias. Ut velit distinctioEx consectetur eos debitis perspiciatis a minus commodi
45+
eos doloribus autem vel accusamus sequi et quidem reiciendis.
46+
Ut autem excepturi ut sequi laboriosam est quas debitis et placeat consequatur!
4747
-
4848
content: |-
49-
Quiautemvoluptaseumdeseruntdolor.
50-
Innesciuntquiaquiasperiorescorruptisedperspiciatistemporaidminusarchitecto!
51-
Rembeataequosautfugitvero.
52-
Atadipiscienimidreiciendisofficiis.
53-
Sedsapientequiaquisintdoloremvelimpeditmagni.
54-
Etdictavoluptatemsitfugitsaepevelnullaetdebitisiureetipsavoluptas?Estfacereharum
55-
etinventoreevenietAutrerumestmolestiasquamquiperspiciatismagnam.
56-
QuieligendisaepeetnequeautemAomnisetpariaturrerumestdolorererumestfacere
57-
quasi.Sitdoloremquiaetveritatisremaliasdeleniti.Etvoluptatemlaborumetimpedit
58-
atqueautsuscipit.RemundequoscumreprehenderittemporeQuieaquesitrerumdistinctio
59-
utipsamculpaaberrorsuntetquaeratQuis.
60-
Inrecusandaedoloridlaudantiumomnisautvelitautperferendisomnisquitenetur
61-
explicabo.EtmaximeassumendaetipsamitaqueAutdelenitiutexcepturialiasetfugit
62-
facerenonmodilaudantium.QuisapientererumnonsuntipsumAdmaximeestvelit
63-
eligendiautquaeratofficiaestarchitectomagnam?
64-
1. Etsequiipsumetexpeditaipsumeumdolorelaborum.
65-
2. Idenimundeeoscommodiquidemeteiusquaerat.
66-
3. EsttemporeveritatissedaliquamQuis.
67-
4. Quiquastemporautvoluptatesdoloribusestfacilisdeserunt33distinctiointernos.
49+
Qui autem voluptas eum deserunt dolor.
50+
In nesciunt quia qui asperiores corrupti sed perspiciatis tempora id minus architecto!
51+
Rem beatae quos aut fugit vero.
52+
At adipisci enim id reiciendis officiis.
53+
Sed sapiente quia qui sint dolorem vel impedit magni.
54+
Et dicta voluptatem sit fugit saepevel nulla et debitis iure et ipsa voluptas? Est facere harum
55+
et inventore eveniet Aut rerum est molestias quam qui perspiciatis magnam.
56+
Qui eligendi saepe et neque autem A omnis et pariatur rerum est dolore rerum est facere
57+
quasi. Sit dolorem quiaet veritatis rem alias deleniti. Et voluptatem laborum et impedit
58+
atqueaut suscipit. Rem unde quos cum reprehenderit temporeQui eaque sit rerum distinctio
59+
ut ipsam culpa ab error sunt et quaerat Quis.
60+
In recusandae dolor id laudantium omnisaut velit aut perferendis omnis qui tenetur
61+
explicabo. Et maxime assumenda et ipsam itaqueAut deleniti ut excepturi alias et fugit
62+
facere non modi laudantium. Qui sapiente rerum non sunt ipsum Ad maxime est velit
63+
eligendi aut quaerat officia est architecto magnam?
64+
1. Et sequi ipsum et expedita ipsum eum dolore laborum.
65+
2. Id enim unde eos commodi quidem et eius quaerat.
66+
3. Est tempore veritatis sed aliquam Quis.
67+
4. Qui quas tempora ut voluptates doloribus est facilis deserunt 33 distinctio internos.

0 commit comments

Comments
 (0)