hi-pdf-parser 0.0.4__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hi_pdf_parser/__init__.py +0 -0
- hi_pdf_parser/__main__.py +200 -0
- hi_pdf_parser/app.py +67 -0
- hi_pdf_parser/config.py +24 -0
- hi_pdf_parser/datamodel.py +52 -0
- hi_pdf_parser/parser.py +1227 -0
- hi_pdf_parser/settings.py +31 -0
- hi_pdf_parser/utils/__init__.py +0 -0
- hi_pdf_parser/utils/hash.py +51 -0
- hi_pdf_parser/utils/table.py +39 -0
- hi_pdf_parser-0.0.4.dist-info/METADATA +41 -0
- hi_pdf_parser-0.0.4.dist-info/RECORD +15 -0
- hi_pdf_parser-0.0.4.dist-info/WHEEL +5 -0
- hi_pdf_parser-0.0.4.dist-info/licenses/LICENSE +661 -0
- hi_pdf_parser-0.0.4.dist-info/top_level.txt +1 -0
|
File without changes
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
# Copyright (C) 2025 ByteDance Inc
|
|
2
|
+
# This program is free software: you can redistribute it and/or modify
|
|
3
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
4
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
5
|
+
# (at your option) any later version.
|
|
6
|
+
#
|
|
7
|
+
# This program is distributed in the hope that it will be useful,
|
|
8
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
9
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
10
|
+
# GNU Affero General Public License for more details.
|
|
11
|
+
#
|
|
12
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
13
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import logging
|
|
17
|
+
from collections.abc import Iterator
|
|
18
|
+
from enum import StrEnum
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
from typing import Annotated, Any
|
|
21
|
+
|
|
22
|
+
import typer
|
|
23
|
+
import uvicorn
|
|
24
|
+
|
|
25
|
+
from .app import create_app
|
|
26
|
+
from .config import PyMuPDFParserConfig
|
|
27
|
+
from .datamodel import Block
|
|
28
|
+
from .parser import PyMuPDFParser
|
|
29
|
+
from .settings import UvicornSettings
|
|
30
|
+
|
|
31
|
+
app = typer.Typer(name="hi-pdf-parser", help="PDF Parser CLI and server")
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _validate_pdf_file(file_path: str) -> Path:
|
|
35
|
+
p = Path(file_path)
|
|
36
|
+
if not p.exists():
|
|
37
|
+
raise typer.BadParameter(f"File does not exist: {file_path}")
|
|
38
|
+
if not p.is_file():
|
|
39
|
+
raise typer.BadParameter(f"Path is not a file: {file_path}")
|
|
40
|
+
return p
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class OutputFormat(StrEnum):
|
|
44
|
+
json = "json"
|
|
45
|
+
text = "text"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _output_results(
|
|
49
|
+
blocks: list[Block],
|
|
50
|
+
metadata: dict,
|
|
51
|
+
output_file: str | None,
|
|
52
|
+
output_format: OutputFormat,
|
|
53
|
+
) -> None:
|
|
54
|
+
def _text_lines() -> Iterator[str]:
|
|
55
|
+
prev_page = -1
|
|
56
|
+
for b in blocks:
|
|
57
|
+
page_num: int | None = None
|
|
58
|
+
if b.areas:
|
|
59
|
+
page_num = b.areas[0].page_num
|
|
60
|
+
if page_num is not None and page_num != prev_page:
|
|
61
|
+
yield f"-------------- Page {page_num} --------------"
|
|
62
|
+
prev_page = page_num
|
|
63
|
+
yield b.content
|
|
64
|
+
|
|
65
|
+
if output_format == OutputFormat.text:
|
|
66
|
+
if output_file:
|
|
67
|
+
Path(output_file).write_text("\n".join(_text_lines()), encoding="utf-8")
|
|
68
|
+
typer.echo(f"Results written to: {output_file}")
|
|
69
|
+
else:
|
|
70
|
+
for line in _text_lines():
|
|
71
|
+
typer.echo(line)
|
|
72
|
+
return
|
|
73
|
+
|
|
74
|
+
result = {"blocks": [b.model_dump() for b in blocks], "metadata": metadata}
|
|
75
|
+
if output_file:
|
|
76
|
+
Path(output_file).write_text(
|
|
77
|
+
json.dumps(result, ensure_ascii=False), encoding="utf-8"
|
|
78
|
+
)
|
|
79
|
+
typer.echo(f"Results written to: {output_file}")
|
|
80
|
+
else:
|
|
81
|
+
typer.echo(json.dumps(result, ensure_ascii=False))
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@app.command()
|
|
85
|
+
def parse(
|
|
86
|
+
pdf_file: Annotated[str, typer.Argument(help="Path to the PDF file to parse")],
|
|
87
|
+
output: Annotated[
|
|
88
|
+
str | None,
|
|
89
|
+
typer.Option("-o", "--output", help="Output file path (default: stdout)"),
|
|
90
|
+
] = None,
|
|
91
|
+
extract_images: Annotated[
|
|
92
|
+
bool,
|
|
93
|
+
typer.Option(
|
|
94
|
+
"--extract-images/--no-extract-images", help="Extract images from the PDF"
|
|
95
|
+
),
|
|
96
|
+
] = True,
|
|
97
|
+
extract_tables: Annotated[
|
|
98
|
+
bool,
|
|
99
|
+
typer.Option(
|
|
100
|
+
"--extract-tables/--no-extract-tables", help="Extract tables from the PDF"
|
|
101
|
+
),
|
|
102
|
+
] = True,
|
|
103
|
+
skip_header_footer: Annotated[
|
|
104
|
+
bool,
|
|
105
|
+
typer.Option(
|
|
106
|
+
"--skip-header-footer/--no-skip-header-footer",
|
|
107
|
+
help="Skip header and footer detection",
|
|
108
|
+
),
|
|
109
|
+
] = True,
|
|
110
|
+
max_pages: Annotated[
|
|
111
|
+
int | None,
|
|
112
|
+
typer.Option(
|
|
113
|
+
"--max-pages",
|
|
114
|
+
help="Maximum number of pages to process (default: all pages)",
|
|
115
|
+
),
|
|
116
|
+
] = None,
|
|
117
|
+
password: Annotated[
|
|
118
|
+
str | None,
|
|
119
|
+
typer.Option("--password", help="Password for encrypted PDF files"),
|
|
120
|
+
] = None,
|
|
121
|
+
format: Annotated[
|
|
122
|
+
OutputFormat, typer.Option("--format", help="Output format")
|
|
123
|
+
] = OutputFormat.text,
|
|
124
|
+
verbose: Annotated[
|
|
125
|
+
bool, typer.Option("-v", "--verbose", help="Enable verbose logging")
|
|
126
|
+
] = False,
|
|
127
|
+
) -> None:
|
|
128
|
+
if verbose:
|
|
129
|
+
logging.basicConfig(level=logging.INFO)
|
|
130
|
+
pdf_path = _validate_pdf_file(pdf_file)
|
|
131
|
+
cfg: dict[str, Any] = {
|
|
132
|
+
"extract_images": extract_images,
|
|
133
|
+
"extract_tables": extract_tables,
|
|
134
|
+
"skip_header_footer": skip_header_footer,
|
|
135
|
+
}
|
|
136
|
+
if max_pages is not None:
|
|
137
|
+
cfg["max_pages"] = max_pages
|
|
138
|
+
config = PyMuPDFParserConfig(**cfg)
|
|
139
|
+
parser = PyMuPDFParser(config)
|
|
140
|
+
try:
|
|
141
|
+
typer.echo(f"Parsing PDF: {pdf_file}", err=True)
|
|
142
|
+
blocks, metadata = parser.parse(
|
|
143
|
+
str(pdf_path),
|
|
144
|
+
extract_images=extract_images,
|
|
145
|
+
extract_tables=extract_tables,
|
|
146
|
+
password=password,
|
|
147
|
+
)
|
|
148
|
+
typer.echo(f"Extracted {len(blocks)} blocks", err=True)
|
|
149
|
+
_output_results(blocks, metadata, output, format)
|
|
150
|
+
except FileNotFoundError as e:
|
|
151
|
+
typer.echo(f"Error: {e}", err=True)
|
|
152
|
+
raise typer.Exit(1)
|
|
153
|
+
except PermissionError as e:
|
|
154
|
+
typer.echo(f"Error: {e}", err=True)
|
|
155
|
+
raise typer.Exit(1)
|
|
156
|
+
except Exception as e:
|
|
157
|
+
typer.echo(f"Error parsing PDF: {e}", err=True)
|
|
158
|
+
raise typer.Exit(1)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
@app.command()
|
|
162
|
+
def serve(
|
|
163
|
+
host: Annotated[str | None, typer.Option("--host", help="Server host")] = None,
|
|
164
|
+
port: Annotated[int | None, typer.Option("--port", help="Server port")] = None,
|
|
165
|
+
reload: Annotated[
|
|
166
|
+
bool | None, typer.Option("--reload/--no-reload", help="Enable auto-reload")
|
|
167
|
+
] = None,
|
|
168
|
+
workers: Annotated[
|
|
169
|
+
int | None, typer.Option("--workers", help="Number of worker processes")
|
|
170
|
+
] = None,
|
|
171
|
+
root_path: Annotated[
|
|
172
|
+
str | None, typer.Option("--root-path", help="Root path for the app")
|
|
173
|
+
] = None,
|
|
174
|
+
proxy_headers: Annotated[
|
|
175
|
+
bool | None,
|
|
176
|
+
typer.Option("--proxy-headers/--no-proxy-headers", help="Use proxy headers"),
|
|
177
|
+
] = None,
|
|
178
|
+
timeout_keep_alive: Annotated[
|
|
179
|
+
int | None,
|
|
180
|
+
typer.Option("--timeout-keep-alive", help="Keep-alive timeout seconds"),
|
|
181
|
+
] = None,
|
|
182
|
+
) -> None:
|
|
183
|
+
settings = UvicornSettings()
|
|
184
|
+
uvicorn.run(
|
|
185
|
+
app=create_app,
|
|
186
|
+
factory=True,
|
|
187
|
+
host=host or settings.host,
|
|
188
|
+
port=port or settings.port,
|
|
189
|
+
reload=reload if reload is not None else settings.reload,
|
|
190
|
+
workers=workers or settings.workers,
|
|
191
|
+
root_path=root_path or settings.root_path,
|
|
192
|
+
proxy_headers=proxy_headers
|
|
193
|
+
if proxy_headers is not None
|
|
194
|
+
else settings.proxy_headers,
|
|
195
|
+
timeout_keep_alive=timeout_keep_alive or settings.timeout_keep_alive,
|
|
196
|
+
)
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
if __name__ == "__main__":
|
|
200
|
+
app()
|
hi_pdf_parser/app.py
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# Copyright (C) 2025 ByteDance Inc
|
|
2
|
+
# This program is free software: you can redistribute it and/or modify
|
|
3
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
4
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
5
|
+
# (at your option) any later version.
|
|
6
|
+
#
|
|
7
|
+
# This program is distributed in the hope that it will be useful,
|
|
8
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
9
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
10
|
+
# GNU Affero General Public License for more details.
|
|
11
|
+
#
|
|
12
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
13
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
14
|
+
|
|
15
|
+
import logging
|
|
16
|
+
|
|
17
|
+
from fastapi import FastAPI, HTTPException
|
|
18
|
+
from fastapi.middleware.gzip import GZipMiddleware
|
|
19
|
+
|
|
20
|
+
from .config import PyMuPDFParserConfig
|
|
21
|
+
from .datamodel import HealthCheckResponse, ParseRequest, ParseResponse
|
|
22
|
+
from .parser import PyMuPDFParser
|
|
23
|
+
|
|
24
|
+
logging.basicConfig(
|
|
25
|
+
level=logging.INFO,
|
|
26
|
+
format="%(levelname)s:\t%(asctime)s - %(name)s - %(message)s",
|
|
27
|
+
datefmt="%H:%M:%S",
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
_logger = logging.getLogger(__name__)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def create_app() -> FastAPI:
|
|
34
|
+
parser = PyMuPDFParser(PyMuPDFParserConfig())
|
|
35
|
+
app = FastAPI(
|
|
36
|
+
title="PDF Parser Serve",
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
app.add_middleware(GZipMiddleware, minimum_size=5 * 1024, compresslevel=5)
|
|
40
|
+
|
|
41
|
+
@app.get("/health")
|
|
42
|
+
def health() -> HealthCheckResponse:
|
|
43
|
+
return HealthCheckResponse()
|
|
44
|
+
|
|
45
|
+
@app.post("/parse", response_model=ParseResponse)
|
|
46
|
+
def parse(request: ParseRequest):
|
|
47
|
+
try:
|
|
48
|
+
blocks, metadata = parser.parse(
|
|
49
|
+
request.file,
|
|
50
|
+
password=request.password,
|
|
51
|
+
extract_images=request.extract_images,
|
|
52
|
+
extract_tables=request.extract_tables,
|
|
53
|
+
)
|
|
54
|
+
except PermissionError as e:
|
|
55
|
+
raise HTTPException(
|
|
56
|
+
status_code=400, detail=f"Can not open encrypted file: {e}"
|
|
57
|
+
)
|
|
58
|
+
except FileNotFoundError:
|
|
59
|
+
raise HTTPException(
|
|
60
|
+
status_code=404, detail=f"File not found: {request.file}"
|
|
61
|
+
)
|
|
62
|
+
except Exception as e:
|
|
63
|
+
_logger.exception(f"Parse file fail: {request.file}")
|
|
64
|
+
raise HTTPException(status_code=500, detail=f"Parse extraction: {e}")
|
|
65
|
+
return ParseResponse(blocks=blocks, metadata=metadata)
|
|
66
|
+
|
|
67
|
+
return app
|
hi_pdf_parser/config.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Copyright (C) 2025 ByteDance Inc
|
|
2
|
+
# This program is free software: you can redistribute it and/or modify
|
|
3
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
4
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
5
|
+
# (at your option) any later version.
|
|
6
|
+
#
|
|
7
|
+
# This program is distributed in the hope that it will be useful,
|
|
8
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
9
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
10
|
+
# GNU Affero General Public License for more details.
|
|
11
|
+
#
|
|
12
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
13
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
14
|
+
|
|
15
|
+
from pydantic import BaseModel
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class PyMuPDFParserConfig(BaseModel):
|
|
19
|
+
"""Configuration for PDF parser."""
|
|
20
|
+
|
|
21
|
+
extract_images: bool = True
|
|
22
|
+
extract_tables: bool = True
|
|
23
|
+
max_pages: int = 0 # 0 means no limit
|
|
24
|
+
skip_header_footer: bool = True
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# Copyright (C) 2025 ByteDance Inc
|
|
2
|
+
# This program is free software: you can redistribute it and/or modify
|
|
3
|
+
# it under the terms of the GNU Affero General Public License as published by
|
|
4
|
+
# the Free Software Foundation, either version 3 of the License, or
|
|
5
|
+
# (at your option) any later version.
|
|
6
|
+
#
|
|
7
|
+
# This program is distributed in the hope that it will be useful,
|
|
8
|
+
# but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
9
|
+
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
10
|
+
# GNU Affero General Public License for more details.
|
|
11
|
+
#
|
|
12
|
+
# You should have received a copy of the GNU Affero General Public License
|
|
13
|
+
# along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
14
|
+
|
|
15
|
+
from enum import StrEnum
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
from pydantic import BaseModel, Field
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class HealthCheckResponse(BaseModel):
|
|
22
|
+
status: str = "ok"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ContentType(StrEnum):
|
|
26
|
+
image = "image"
|
|
27
|
+
text = "text"
|
|
28
|
+
table = "table"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class BlockArea(BaseModel):
|
|
32
|
+
rect: tuple[float, float, float, float]
|
|
33
|
+
page_num: int
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
class Block(BaseModel):
|
|
37
|
+
type: ContentType
|
|
38
|
+
areas: list[BlockArea]
|
|
39
|
+
content: str
|
|
40
|
+
font_sizes: list[float] = []
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class ParseRequest(BaseModel):
|
|
44
|
+
file: str = Field(description="file path", examples=["/path/my.pdf"])
|
|
45
|
+
password: str | None = Field(None, description="file password")
|
|
46
|
+
extract_images: bool = Field(True, description="enable image extraction")
|
|
47
|
+
extract_tables: bool = Field(True, description="enable table extraction")
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class ParseResponse(BaseModel):
|
|
51
|
+
blocks: list[Block]
|
|
52
|
+
metadata: dict[str, Any]
|