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.
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
@@ -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]