Skip to content

Commit 3d1c459

Browse files
Merge pull request #9 from datalogics-kam/pdfcloud-5523-extract-text
PDFCLOUD-5523 Direct extract text to Pydantic models
2 parents 16120cd + d64fb28 commit 3d1c459

11 files changed

Lines changed: 1340 additions & 0 deletions

File tree

.gitignore

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -454,3 +454,4 @@ $RECYCLE.BIN/
454454

455455
!pyrightconfig.json
456456
/coverage/
457+
/coverage.json

examples/README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,3 +18,10 @@ that via `.env` if desired), upload the checked-in sample assets under
1818
`examples/resources/`, and exercise the async client end-to-end. Use
1919
`uvx nox -s examples` when you want to execute every example across the
2020
supported interpreter matrix.
21+
22+
## Available Examples
23+
24+
- `examples/delete/delete_example.py` – demonstrate file deletion (sync + async
25+
variants).
26+
- `examples/extract_text/extract_pdf_text_example.py` – run `extract_pdf_text`
27+
with word coordinates/style enabled and render the output as a Rich table.
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
# /// script
2+
# requires-python = ">=3.10"
3+
# dependencies = ["pdfrest", "python-dotenv", "rich"]
4+
# ///
5+
"""Render extracted words with coordinates and style metadata.
6+
7+
This sample demonstrates how to:
8+
9+
1. Upload the bundled ``examples/resources/report.pdf`` resource.
10+
2. Request JSON output from :func:`PdfRestClient.extract_pdf_text` while turning on
11+
word-level coordinates and styling data.
12+
3. Display the returned metadata as a Rich table.
13+
14+
Run with ``uv run --project ../.. python extract_pdf_text_example.py`` after
15+
setting ``PDFREST_API_KEY`` (``python-dotenv`` will also load `.env` if present).
16+
"""
17+
18+
from __future__ import annotations
19+
20+
from pathlib import Path
21+
22+
from dotenv import load_dotenv
23+
from rich.console import Console
24+
from rich.table import Table
25+
26+
from pdfrest import PdfRestClient
27+
from pdfrest.models import (
28+
ExtractedTextDocument,
29+
ExtractedTextPoint,
30+
ExtractedTextWord,
31+
ExtractedTextWordCoordinates,
32+
)
33+
34+
RESOURCE = Path(__file__).resolve().parents[1] / "resources" / "report.pdf"
35+
36+
37+
def _format_point(point: ExtractedTextPoint | None) -> str:
38+
if point is None:
39+
return "—"
40+
return f"({point.x:.2f}, {point.y:.2f})"
41+
42+
43+
def _format_color(word: ExtractedTextWord) -> str:
44+
style = word.style
45+
if style is None or style.color is None:
46+
return "—"
47+
color = style.color
48+
values = ", ".join(str(value) for value in color.values)
49+
return f"{color.space}: {values}"
50+
51+
52+
def _format_font(word: ExtractedTextWord) -> str:
53+
style = word.style
54+
if style is None:
55+
return "—"
56+
font = style.font
57+
return f"{font.name} ({font.size:.1f} pt)"
58+
59+
60+
def _build_word_table(document: ExtractedTextDocument) -> Table:
61+
table = Table(title="Extracted Words with Coordinates and Style")
62+
table.add_column("Word", style="bold")
63+
table.add_column("Page", justify="right")
64+
table.add_column("Top Left")
65+
table.add_column("Top Right")
66+
table.add_column("Bottom Left")
67+
table.add_column("Bottom Right")
68+
table.add_column("Color")
69+
table.add_column("Font")
70+
71+
for word in document.words or []:
72+
coords: ExtractedTextWordCoordinates | None = word.coordinates
73+
table.add_row(
74+
word.text,
75+
str(word.page),
76+
_format_point(coords.top_left if coords else None),
77+
_format_point(coords.top_right if coords else None),
78+
_format_point(coords.bottom_left if coords else None),
79+
_format_point(coords.bottom_right if coords else None),
80+
_format_color(word),
81+
_format_font(word),
82+
)
83+
return table
84+
85+
86+
def list_words_with_coordinates() -> None:
87+
load_dotenv()
88+
console = Console()
89+
90+
with PdfRestClient() as client:
91+
uploaded = client.files.create_from_paths([RESOURCE])[0]
92+
document = client.extract_pdf_text(
93+
uploaded,
94+
full_text="by_page",
95+
preserve_line_breaks=True,
96+
word_style=True,
97+
word_coordinates=True,
98+
)
99+
100+
words = document.words or []
101+
console.print(f"Extracted {len(words)} words from [bold]{uploaded.name}[/bold].")
102+
if not words:
103+
console.print("[yellow]This document did not include word metadata.[/yellow]")
104+
return
105+
106+
table = _build_word_table(document)
107+
console.print(table)
108+
109+
110+
if __name__ == "__main__": # pragma: no cover - manual example
111+
list_words_with_coordinates()

pyproject.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ dev = [
3535
"basedpyright>=1.34.0",
3636
"python-dotenv>=1.0.1",
3737
"diff-cover>=10.2.0",
38+
"rich>=14.1.0",
3839
]
3940

4041
[tool.pytest.ini_options]

src/pdfrest/client.py

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,7 @@
6060
translate_httpx_error,
6161
)
6262
from .models import (
63+
ExtractedTextDocument,
6364
PdfRestDeletionResponse,
6465
PdfRestErrorResponse,
6566
PdfRestFile,
@@ -2403,6 +2404,48 @@ def extract_images(
24032404
timeout=timeout,
24042405
)
24052406

2407+
def extract_pdf_text(
2408+
self,
2409+
file: PdfRestFile | Sequence[PdfRestFile],
2410+
*,
2411+
pages: PdfPageSelection | None = None,
2412+
full_text: Literal["off", "by_page", "document"] = "document",
2413+
preserve_line_breaks: bool = False,
2414+
word_style: bool = False,
2415+
word_coordinates: bool = False,
2416+
extra_query: Query | None = None,
2417+
extra_headers: AnyMapping | None = None,
2418+
extra_body: Body | None = None,
2419+
timeout: TimeoutTypes | None = None,
2420+
) -> ExtractedTextDocument:
2421+
"""Extract text content from a PDF and return parsed JSON results."""
2422+
2423+
payload: dict[str, Any] = {
2424+
"files": file,
2425+
"full_text": full_text,
2426+
"preserve_line_breaks": preserve_line_breaks,
2427+
"word_style": word_style,
2428+
"word_coordinates": word_coordinates,
2429+
"output_type": "json",
2430+
}
2431+
if pages is not None:
2432+
payload["pages"] = pages
2433+
2434+
validated_payload = ExtractTextPayload.model_validate(payload)
2435+
request = self.prepare_request(
2436+
"POST",
2437+
"/extracted-text",
2438+
json_body=validated_payload.model_dump(
2439+
mode="json", by_alias=True, exclude_none=True, exclude_unset=True
2440+
),
2441+
extra_query=extra_query,
2442+
extra_headers=extra_headers,
2443+
extra_body=extra_body,
2444+
timeout=timeout,
2445+
)
2446+
raw_payload = self._send_request(request)
2447+
return ExtractedTextDocument.model_validate(raw_payload)
2448+
24062449
def extract_pdf_text_to_file(
24072450
self,
24082451
file: PdfRestFile | Sequence[PdfRestFile],
@@ -3606,6 +3649,48 @@ async def extract_images(
36063649
timeout=timeout,
36073650
)
36083651

3652+
async def extract_pdf_text(
3653+
self,
3654+
file: PdfRestFile | Sequence[PdfRestFile],
3655+
*,
3656+
pages: PdfPageSelection | None = None,
3657+
full_text: Literal["off", "by_page", "document"] = "document",
3658+
preserve_line_breaks: bool = False,
3659+
word_style: bool = False,
3660+
word_coordinates: bool = False,
3661+
extra_query: Query | None = None,
3662+
extra_headers: AnyMapping | None = None,
3663+
extra_body: Body | None = None,
3664+
timeout: TimeoutTypes | None = None,
3665+
) -> ExtractedTextDocument:
3666+
"""Extract text content from a PDF and return parsed JSON results."""
3667+
3668+
payload: dict[str, Any] = {
3669+
"files": file,
3670+
"full_text": full_text,
3671+
"preserve_line_breaks": preserve_line_breaks,
3672+
"word_style": word_style,
3673+
"word_coordinates": word_coordinates,
3674+
"output_type": "json",
3675+
}
3676+
if pages is not None:
3677+
payload["pages"] = pages
3678+
3679+
validated_payload = ExtractTextPayload.model_validate(payload)
3680+
request = self.prepare_request(
3681+
"POST",
3682+
"/extracted-text",
3683+
json_body=validated_payload.model_dump(
3684+
mode="json", by_alias=True, exclude_none=True, exclude_unset=True
3685+
),
3686+
extra_query=extra_query,
3687+
extra_headers=extra_headers,
3688+
extra_body=extra_body,
3689+
timeout=timeout,
3690+
)
3691+
raw_payload = await self._send_request(request)
3692+
return ExtractedTextDocument.model_validate(raw_payload)
3693+
36093694
async def extract_pdf_text_to_file(
36103695
self,
36113696
file: PdfRestFile | Sequence[PdfRestFile],

src/pdfrest/models/__init__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,15 @@
11
from .public import (
2+
ExtractedTextDocument,
3+
ExtractedTextFullText,
4+
ExtractedTextFullTextPage,
5+
ExtractedTextFullTextPages,
6+
ExtractedTextPoint,
7+
ExtractedTextWord,
8+
ExtractedTextWordColor,
9+
ExtractedTextWordCoordinates,
10+
ExtractedTextWordFont,
11+
ExtractedTextWordStyle,
12+
ExtractTextResponse,
213
PdfRestDeletionResponse,
314
PdfRestErrorResponse,
415
PdfRestFile,
@@ -12,6 +23,17 @@
1223
)
1324

1425
__all__ = [
26+
"ExtractTextResponse",
27+
"ExtractedTextDocument",
28+
"ExtractedTextFullText",
29+
"ExtractedTextFullTextPage",
30+
"ExtractedTextFullTextPages",
31+
"ExtractedTextPoint",
32+
"ExtractedTextWord",
33+
"ExtractedTextWordColor",
34+
"ExtractedTextWordCoordinates",
35+
"ExtractedTextWordFont",
36+
"ExtractedTextWordStyle",
1537
"PdfRestDeletionResponse",
1638
"PdfRestErrorResponse",
1739
"PdfRestFile",

0 commit comments

Comments
 (0)