edocapi 0.0.2__tar.gz → 0.1.0__tar.gz
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.
- {edocapi-0.0.2 → edocapi-0.1.0}/PKG-INFO +20 -9
- edocapi-0.1.0/PUBLISH.md +95 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/README.md +19 -8
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/__init__.py +1 -1
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/app.py +73 -6
- edocapi-0.1.0/edocapi/dashboard.py +46 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/document.py +26 -6
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/docx.py +8 -9
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/html.py +9 -9
- edocapi-0.1.0/edocapi/processors/image.py +149 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/markdown.py +8 -9
- edocapi-0.1.0/edocapi/processors/pdf.py +545 -0
- edocapi-0.1.0/edocapi/processors/simple_pdf.py +128 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/txt.py +8 -9
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/validation/files.py +6 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi.egg-info/SOURCES.txt +2 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/pyproject.toml +1 -1
- edocapi-0.0.2/PUBLISH.md +0 -51
- edocapi-0.0.2/edocapi/processors/image.py +0 -80
- edocapi-0.0.2/edocapi/processors/pdf.py +0 -231
- {edocapi-0.0.2 → edocapi-0.1.0}/AUTHORS.md +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/LICENSE +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/MANIFEST.in +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/cli/__init__.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/cli/main.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/config.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/exceptions.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/files.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/__init__.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/base.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/responses.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/storage/__init__.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/storage/temporary.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/validation/__init__.py +0 -0
- {edocapi-0.0.2 → edocapi-0.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: edocapi
|
|
3
|
-
Version: 0.0
|
|
3
|
+
Version: 0.1.0
|
|
4
4
|
Summary: A lightweight Python framework for building document-processing and document-automation APIs.
|
|
5
5
|
Author-email: EMMANUEL EMMANUEL ETIM <emmanuel224etim089@gmail.com>
|
|
6
6
|
Maintainer-email: EMMANUEL EMMANUEL ETIM <emmanuel224etim089@gmail.com>
|
|
@@ -54,7 +54,7 @@ Dynamic: license-file
|
|
|
54
54
|
|
|
55
55
|
> Simple API for the developer. Powerful document processing underneath.
|
|
56
56
|
|
|
57
|
-
Built on [Starlette](https://www.starlette.io/). Version **0.0
|
|
57
|
+
Built on [Starlette](https://www.starlette.io/). Version **0.1.0**.
|
|
58
58
|
|
|
59
59
|
---
|
|
60
60
|
|
|
@@ -66,13 +66,13 @@ Built on [Starlette](https://www.starlette.io/). Version **0.0.2**.
|
|
|
66
66
|
pip install edocapi
|
|
67
67
|
```
|
|
68
68
|
|
|
69
|
-
**With HTML/Markdown/DOCX → PDF** (pulls in WeasyPrint):
|
|
69
|
+
**With enhanced HTML/Markdown/DOCX → PDF layout** (pulls in WeasyPrint):
|
|
70
70
|
|
|
71
71
|
```bash
|
|
72
72
|
pip install edocapi[html]
|
|
73
73
|
```
|
|
74
74
|
|
|
75
|
-
WeasyPrint
|
|
75
|
+
WeasyPrint may need system libraries for enhanced layout. On Debian/Ubuntu:
|
|
76
76
|
|
|
77
77
|
```bash
|
|
78
78
|
sudo apt-get install -y libcairo2 libpango-1.0-0 libpangocairo-1.0-0 \
|
|
@@ -142,7 +142,7 @@ def merge(files):
|
|
|
142
142
|
|
|
143
143
|
@app.get("/")
|
|
144
144
|
def home():
|
|
145
|
-
return {"message": "eDocAPI
|
|
145
|
+
return {"message": f"eDocAPI v{__version__}", "supported": Document.supported_types()}
|
|
146
146
|
```
|
|
147
147
|
|
|
148
148
|
Run the development server:
|
|
@@ -153,6 +153,8 @@ edocapi run
|
|
|
153
153
|
edocapi run --reload
|
|
154
154
|
```
|
|
155
155
|
|
|
156
|
+
Open **http://127.0.0.1:8000** in your browser to see the eDocAPI developer dashboard for your app. It shows your registered routes, supported document types, upload limit, and available `Document` operations, with a Try it explorer for testing endpoints. The dashboard is enabled by default when you have not registered your own `GET /` route. Set `App(dashboard=False)` to disable it. The separate eDocAPI company site lives in `webapp/`; see [webapp/README.md](webapp/README.md) to run it.
|
|
157
|
+
|
|
156
158
|
Upload a document to `POST /convert` and receive a PDF.
|
|
157
159
|
|
|
158
160
|
---
|
|
@@ -206,9 +208,18 @@ Document.merge([file1, file2])
|
|
|
206
208
|
Document(file).info() # dict of metadata
|
|
207
209
|
```
|
|
208
210
|
|
|
211
|
+
`Document(file).compress(target_size=20 * 1024, level="medium")` optionally
|
|
212
|
+
targets a maximum output size in bytes. PDFs are optimized and their output
|
|
213
|
+
size is checked; text-based PDFs can be rebuilt with simplified layout and
|
|
214
|
+
without original graphics if needed. Image files with JPG/JPEG, PNG, or WebP
|
|
215
|
+
extensions are recompressed and retain their image format. Image compression
|
|
216
|
+
can reduce color detail. Some target sizes cannot be reached. Without a
|
|
217
|
+
target, the compressor returns the original if recompression would make it
|
|
218
|
+
larger. Other document types are not accepted by `compress()`.
|
|
219
|
+
|
|
209
220
|
---
|
|
210
221
|
|
|
211
|
-
## Supported Formats
|
|
222
|
+
## Supported Formats
|
|
212
223
|
|
|
213
224
|
| Input | to_pdf | to_text | to_html | to_images | Notes |
|
|
214
225
|
|-----------|--------|---------|---------|-----------|------------------------|
|
|
@@ -234,9 +245,9 @@ The package exposes an ASGI application. In your application's `main.py`, create
|
|
|
234
245
|
uvicorn main:app.asgi --host 0.0.0.0 --port ${PORT:-8000} --workers 2
|
|
235
246
|
```
|
|
236
247
|
|
|
237
|
-
Keep `debug=False` in hosted environments.
|
|
238
|
-
|
|
239
|
-
|
|
248
|
+
Keep `debug=False` in hosted environments. HTML, Markdown, TXT, and DOCX PDF
|
|
249
|
+
conversion have a Pillow text-rendering fallback; install WeasyPrint's
|
|
250
|
+
operating-system libraries for richer layout. Set the reverse proxy's request-body limit to match
|
|
240
251
|
`max_file_size`; for larger workloads, use a worker queue and monitor temporary
|
|
241
252
|
disk usage.
|
|
242
253
|
|
edocapi-0.1.0/PUBLISH.md
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
# Release checklist
|
|
2
|
+
|
|
3
|
+
This document describes the release process. Publishing requires the maintainer's
|
|
4
|
+
PyPI credentials and is a separate step from preparing the package.
|
|
5
|
+
|
|
6
|
+
## Before release
|
|
7
|
+
|
|
8
|
+
This change set intentionally keeps the package version at **0.1.0**. First
|
|
9
|
+
check whether that version has already been published to PyPI and whether the
|
|
10
|
+
GitHub tag `v0.1.0` already exists. PyPI versions cannot be overwritten. If
|
|
11
|
+
either release already exists, do not reuse its version; prepare a new version
|
|
12
|
+
before publishing.
|
|
13
|
+
|
|
14
|
+
1. Review the pending changes and release notes.
|
|
15
|
+
2. Run the project's checks on supported Python versions.
|
|
16
|
+
3. Build and inspect both distributions:
|
|
17
|
+
|
|
18
|
+
```bash
|
|
19
|
+
python -m pip install --upgrade build twine
|
|
20
|
+
python -m build
|
|
21
|
+
python -m twine check dist/*
|
|
22
|
+
```
|
|
23
|
+
|
|
24
|
+
4. Install the wheel in a clean environment and verify `import edocapi`, the
|
|
25
|
+
`edocapi --version` command, and the documented optional extras.
|
|
26
|
+
5. Build and verify a candidate locally. Upload to TestPyPI only if you want a
|
|
27
|
+
separate pre-release check; it is not required for the production upload.
|
|
28
|
+
|
|
29
|
+
## eDocAPI 0.1.0 release notes
|
|
30
|
+
|
|
31
|
+
Use [RELEASE_NOTES_0.1.0.md](RELEASE_NOTES_0.1.0.md) as the detailed GitHub
|
|
32
|
+
release description. This update includes the built-in developer dashboard,
|
|
33
|
+
PDF and image compression, safer image type checking, document conversion
|
|
34
|
+
fallbacks, and improvements to the separate example company web application.
|
|
35
|
+
|
|
36
|
+
### GitHub release note
|
|
37
|
+
|
|
38
|
+
Create a GitHub release for tag `v0.1.0` with title `eDocAPI 0.1.0` and use
|
|
39
|
+
the contents of [RELEASE_NOTES_0.1.0.md](RELEASE_NOTES_0.1.0.md) as its body.
|
|
40
|
+
|
|
41
|
+
### PyPI release note
|
|
42
|
+
|
|
43
|
+
The source metadata remains at version 0.1.0. Confirm that this exact version
|
|
44
|
+
is not already published before uploading. It includes the built-in developer
|
|
45
|
+
dashboard and API explorer, target-size PDF compression, JPG/JPEG/PNG/WebP
|
|
46
|
+
compression, conversion fallback support, and the standalone example company
|
|
47
|
+
site under `webapp/`.
|
|
48
|
+
|
|
49
|
+
### Build, check, and upload to PyPI
|
|
50
|
+
|
|
51
|
+
Build from a clean distribution directory and check the files. Only upload if
|
|
52
|
+
PyPI confirms `0.1.0` is still available:
|
|
53
|
+
|
|
54
|
+
```bash
|
|
55
|
+
python -m pip install --upgrade build twine
|
|
56
|
+
python -m build
|
|
57
|
+
python -m twine check dist/*
|
|
58
|
+
# Run only after confirming the version is not already published:
|
|
59
|
+
python -m twine upload dist/*
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Confirm the artifacts are `edocapi-0.1.0.tar.gz` and
|
|
63
|
+
`edocapi-0.1.0-py3-none-any.whl`. Do not upload stale artifacts from older
|
|
64
|
+
versions. If those filenames already exist on PyPI, stop and bump the package
|
|
65
|
+
version for a follow-up release.
|
|
66
|
+
|
|
67
|
+
## Publish
|
|
68
|
+
|
|
69
|
+
Use a PyPI API token and Trusted Publishing where configured. Otherwise upload
|
|
70
|
+
with Twine, entering `__token__` as the username and the token as the password:
|
|
71
|
+
|
|
72
|
+
```bash
|
|
73
|
+
python -m twine upload dist/*
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
Do not commit API tokens. A version already uploaded to PyPI cannot be replaced;
|
|
77
|
+
increment the version for every follow-up release.
|
|
78
|
+
|
|
79
|
+
For an upload, build with an empty `dist/` directory so the command cannot
|
|
80
|
+
accidentally include stale artifacts from an earlier version. Verify the
|
|
81
|
+
filenames match the intended release version before uploading.
|
|
82
|
+
|
|
83
|
+
## Hosted deployment
|
|
84
|
+
|
|
85
|
+
Create an application module exposing `app = App()` and run an ASGI server, for
|
|
86
|
+
example:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
uvicorn main:app.asgi --host 0.0.0.0 --port ${PORT:-8000} --workers 2
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Use `debug=False`, configure the proxy body-size limit to match the app's upload
|
|
93
|
+
limit, and install the required operating-system libraries for optional PDF
|
|
94
|
+
conversions. Monitor temporary disk usage and use a queue for CPU-heavy or
|
|
95
|
+
long-running document conversions.
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
|
|
5
5
|
> Simple API for the developer. Powerful document processing underneath.
|
|
6
6
|
|
|
7
|
-
Built on [Starlette](https://www.starlette.io/). Version **0.0
|
|
7
|
+
Built on [Starlette](https://www.starlette.io/). Version **0.1.0**.
|
|
8
8
|
|
|
9
9
|
---
|
|
10
10
|
|
|
@@ -16,13 +16,13 @@ Built on [Starlette](https://www.starlette.io/). Version **0.0.2**.
|
|
|
16
16
|
pip install edocapi
|
|
17
17
|
```
|
|
18
18
|
|
|
19
|
-
**With HTML/Markdown/DOCX → PDF** (pulls in WeasyPrint):
|
|
19
|
+
**With enhanced HTML/Markdown/DOCX → PDF layout** (pulls in WeasyPrint):
|
|
20
20
|
|
|
21
21
|
```bash
|
|
22
22
|
pip install edocapi[html]
|
|
23
23
|
```
|
|
24
24
|
|
|
25
|
-
WeasyPrint
|
|
25
|
+
WeasyPrint may need system libraries for enhanced layout. On Debian/Ubuntu:
|
|
26
26
|
|
|
27
27
|
```bash
|
|
28
28
|
sudo apt-get install -y libcairo2 libpango-1.0-0 libpangocairo-1.0-0 \
|
|
@@ -92,7 +92,7 @@ def merge(files):
|
|
|
92
92
|
|
|
93
93
|
@app.get("/")
|
|
94
94
|
def home():
|
|
95
|
-
return {"message": "eDocAPI
|
|
95
|
+
return {"message": f"eDocAPI v{__version__}", "supported": Document.supported_types()}
|
|
96
96
|
```
|
|
97
97
|
|
|
98
98
|
Run the development server:
|
|
@@ -103,6 +103,8 @@ edocapi run
|
|
|
103
103
|
edocapi run --reload
|
|
104
104
|
```
|
|
105
105
|
|
|
106
|
+
Open **http://127.0.0.1:8000** in your browser to see the eDocAPI developer dashboard for your app. It shows your registered routes, supported document types, upload limit, and available `Document` operations, with a Try it explorer for testing endpoints. The dashboard is enabled by default when you have not registered your own `GET /` route. Set `App(dashboard=False)` to disable it. The separate eDocAPI company site lives in `webapp/`; see [webapp/README.md](webapp/README.md) to run it.
|
|
107
|
+
|
|
106
108
|
Upload a document to `POST /convert` and receive a PDF.
|
|
107
109
|
|
|
108
110
|
---
|
|
@@ -156,9 +158,18 @@ Document.merge([file1, file2])
|
|
|
156
158
|
Document(file).info() # dict of metadata
|
|
157
159
|
```
|
|
158
160
|
|
|
161
|
+
`Document(file).compress(target_size=20 * 1024, level="medium")` optionally
|
|
162
|
+
targets a maximum output size in bytes. PDFs are optimized and their output
|
|
163
|
+
size is checked; text-based PDFs can be rebuilt with simplified layout and
|
|
164
|
+
without original graphics if needed. Image files with JPG/JPEG, PNG, or WebP
|
|
165
|
+
extensions are recompressed and retain their image format. Image compression
|
|
166
|
+
can reduce color detail. Some target sizes cannot be reached. Without a
|
|
167
|
+
target, the compressor returns the original if recompression would make it
|
|
168
|
+
larger. Other document types are not accepted by `compress()`.
|
|
169
|
+
|
|
159
170
|
---
|
|
160
171
|
|
|
161
|
-
## Supported Formats
|
|
172
|
+
## Supported Formats
|
|
162
173
|
|
|
163
174
|
| Input | to_pdf | to_text | to_html | to_images | Notes |
|
|
164
175
|
|-----------|--------|---------|---------|-----------|------------------------|
|
|
@@ -184,9 +195,9 @@ The package exposes an ASGI application. In your application's `main.py`, create
|
|
|
184
195
|
uvicorn main:app.asgi --host 0.0.0.0 --port ${PORT:-8000} --workers 2
|
|
185
196
|
```
|
|
186
197
|
|
|
187
|
-
Keep `debug=False` in hosted environments.
|
|
188
|
-
|
|
189
|
-
|
|
198
|
+
Keep `debug=False` in hosted environments. HTML, Markdown, TXT, and DOCX PDF
|
|
199
|
+
conversion have a Pillow text-rendering fallback; install WeasyPrint's
|
|
200
|
+
operating-system libraries for richer layout. Set the reverse proxy's request-body limit to match
|
|
190
201
|
`max_file_size`; for larger workloads, use a worker queue and monitor temporary
|
|
191
202
|
disk usage.
|
|
192
203
|
|
|
@@ -8,7 +8,7 @@ Author: EMMANUEL EMMANUEL ETIM <emmanuel224etim089@gmail.com>
|
|
|
8
8
|
Repository: https://github.com/emmanuelemmanueletim/edocApi
|
|
9
9
|
"""
|
|
10
10
|
|
|
11
|
-
__version__ = "0.0
|
|
11
|
+
__version__ = "0.1.0"
|
|
12
12
|
__author__ = "EMMANUEL EMMANUEL ETIM"
|
|
13
13
|
__author_email__ = "emmanuel224etim089@gmail.com"
|
|
14
14
|
__license__ = "MIT"
|
|
@@ -49,6 +49,8 @@ class App:
|
|
|
49
49
|
**kwargs,
|
|
50
50
|
)
|
|
51
51
|
self._routes: list[Route] = []
|
|
52
|
+
self._dashboard_enabled = bool(kwargs.get("dashboard", True))
|
|
53
|
+
self._dashboard_endpoint: Callable | None = None
|
|
52
54
|
self._temp_storage = TemporaryStorage(self.config.temp_dir)
|
|
53
55
|
self._starlette: Starlette | None = None
|
|
54
56
|
|
|
@@ -74,7 +76,48 @@ class App:
|
|
|
74
76
|
async def wrapper(request: Request) -> Response:
|
|
75
77
|
return await self._dispatch(endpoint, request)
|
|
76
78
|
|
|
77
|
-
|
|
79
|
+
wrapper.__name__ = endpoint.__name__
|
|
80
|
+
wrapper.__doc__ = endpoint.__doc__
|
|
81
|
+
wrapper.__edocapi_endpoint__ = endpoint
|
|
82
|
+
self._routes.append(
|
|
83
|
+
Route(path, endpoint=wrapper, methods=list(methods), name=endpoint.__name__)
|
|
84
|
+
)
|
|
85
|
+
|
|
86
|
+
def _dashboard_response(self) -> Response:
|
|
87
|
+
"""Build the interactive developer dashboard from this app's routes."""
|
|
88
|
+
from edocapi.document import Document
|
|
89
|
+
from edocapi.dashboard import render_dashboard
|
|
90
|
+
|
|
91
|
+
routes = [
|
|
92
|
+
{
|
|
93
|
+
"path": route.path,
|
|
94
|
+
"methods": sorted(route.methods or []),
|
|
95
|
+
"name": getattr(route.endpoint, "__name__", "endpoint"),
|
|
96
|
+
"description": inspect.getdoc(
|
|
97
|
+
getattr(route.endpoint, "__edocapi_endpoint__", route.endpoint)
|
|
98
|
+
) or "",
|
|
99
|
+
"parameters": [
|
|
100
|
+
{
|
|
101
|
+
"name": parameter.name,
|
|
102
|
+
"required": parameter.default is inspect.Parameter.empty,
|
|
103
|
+
"kind": "upload" if parameter.name in ("file", "files") else "parameter",
|
|
104
|
+
"multiple": parameter.name == "files",
|
|
105
|
+
"type": getattr(parameter.annotation, "__name__", "string")
|
|
106
|
+
if parameter.annotation is not inspect.Parameter.empty
|
|
107
|
+
else "string",
|
|
108
|
+
}
|
|
109
|
+
for parameter in inspect.signature(
|
|
110
|
+
getattr(route.endpoint, "__edocapi_endpoint__", route.endpoint)
|
|
111
|
+
).parameters.values()
|
|
112
|
+
if parameter.name != "request"
|
|
113
|
+
],
|
|
114
|
+
}
|
|
115
|
+
for route in self._routes
|
|
116
|
+
]
|
|
117
|
+
return Response(
|
|
118
|
+
render_dashboard(routes, Document.supported_types(), self.config.max_file_size),
|
|
119
|
+
media_type="text/html",
|
|
120
|
+
)
|
|
78
121
|
|
|
79
122
|
def get(self, path: str) -> Callable:
|
|
80
123
|
def decorator(func: Callable) -> Callable:
|
|
@@ -137,6 +180,12 @@ class App:
|
|
|
137
180
|
temp_storage=self._temp_storage,
|
|
138
181
|
)
|
|
139
182
|
|
|
183
|
+
form_data = None
|
|
184
|
+
if uploads and request.headers.get("content-type", "").startswith(
|
|
185
|
+
"multipart/form-data"
|
|
186
|
+
):
|
|
187
|
+
form_data = await request.form()
|
|
188
|
+
|
|
140
189
|
for name, param in sig.parameters.items():
|
|
141
190
|
if name == "request":
|
|
142
191
|
kwargs["request"] = request
|
|
@@ -150,12 +199,14 @@ class App:
|
|
|
150
199
|
kwargs["files"] = uploads
|
|
151
200
|
elif name in request.path_params:
|
|
152
201
|
kwargs[name] = request.path_params[name]
|
|
202
|
+
elif form_data is not None and name in form_data:
|
|
203
|
+
kwargs[name] = form_data[name]
|
|
204
|
+
elif name in request.query_params:
|
|
205
|
+
kwargs[name] = request.query_params[name]
|
|
153
206
|
elif param.default is not inspect.Parameter.empty:
|
|
154
207
|
continue
|
|
155
208
|
else:
|
|
156
|
-
|
|
157
|
-
if name in request.query_params:
|
|
158
|
-
kwargs[name] = request.query_params[name]
|
|
209
|
+
continue
|
|
159
210
|
|
|
160
211
|
# Call the endpoint (sync or async)
|
|
161
212
|
if inspect.iscoroutinefunction(endpoint):
|
|
@@ -192,9 +243,18 @@ class App:
|
|
|
192
243
|
if isinstance(result, Document):
|
|
193
244
|
# Document returned directly treat as file response
|
|
194
245
|
path = result.path
|
|
246
|
+
headers = {}
|
|
247
|
+
compression_mode = getattr(result, "_compression_mode", None)
|
|
248
|
+
if compression_mode:
|
|
249
|
+
headers["X-Edocapi-Compression-Mode"] = compression_mode
|
|
250
|
+
filename = path.name
|
|
251
|
+
if compression_mode == "image":
|
|
252
|
+
original_name = Path(getattr(result, "_original_name", path.name))
|
|
253
|
+
filename = f"compressed_{original_name.stem}{path.suffix.lower()}"
|
|
195
254
|
return FileResponse(
|
|
196
255
|
path,
|
|
197
|
-
filename=
|
|
256
|
+
filename=filename,
|
|
257
|
+
headers=headers,
|
|
198
258
|
background=self._cleanup_callback(path, result._temp),
|
|
199
259
|
)
|
|
200
260
|
|
|
@@ -232,9 +292,16 @@ class App:
|
|
|
232
292
|
def asgi(self) -> Starlette:
|
|
233
293
|
"""Return the underlying Starlette ASGI application."""
|
|
234
294
|
if self._starlette is None:
|
|
295
|
+
routes = list(self._routes)
|
|
296
|
+
if self._dashboard_enabled and not any(r.path == "/" and "GET" in (r.methods or []) for r in routes):
|
|
297
|
+
async def dashboard_endpoint(request: Request) -> Response:
|
|
298
|
+
return self._dashboard_response()
|
|
299
|
+
|
|
300
|
+
self._dashboard_endpoint = dashboard_endpoint
|
|
301
|
+
routes.insert(0, Route("/", endpoint=dashboard_endpoint, methods=["GET"]))
|
|
235
302
|
self._starlette = Starlette(
|
|
236
303
|
debug=self.config.debug,
|
|
237
|
-
routes=
|
|
304
|
+
routes=routes,
|
|
238
305
|
exception_handlers={
|
|
239
306
|
Exception: self._global_exception_handler,
|
|
240
307
|
},
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
"""Interactive API explorer dashboard for eDocAPI applications."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def render_dashboard(routes: list[dict], formats: list[str], max_file_size: int) -> str:
|
|
10
|
+
from edocapi import __version__
|
|
11
|
+
|
|
12
|
+
version = __version__
|
|
13
|
+
route_rows = "".join(
|
|
14
|
+
"<tr>"
|
|
15
|
+
f'<td><span class="method">{html.escape(", ".join(route["methods"]))}</span></td>'
|
|
16
|
+
f'<td><code>{html.escape(route["path"])}</code></td>'
|
|
17
|
+
f'<td>{html.escape(route["name"])}</td>'
|
|
18
|
+
f'<td><button class="try" data-route="{index}">Try it ↗</button></td></tr>'
|
|
19
|
+
for index, route in enumerate(routes)
|
|
20
|
+
) or '<tr><td colspan="4" class="empty">No custom routes registered yet.</td></tr>'
|
|
21
|
+
formats_html = "".join(
|
|
22
|
+
f'<span class="format">{html.escape(kind.upper())}</span>' for kind in formats
|
|
23
|
+
)
|
|
24
|
+
size = f"{max_file_size / 1024 / 1024:g} MB"
|
|
25
|
+
route_json = json.dumps(routes).replace("<", "\\u003c").replace(">", "\\u003e").replace("&", "\\u0026")
|
|
26
|
+
return f'''<!doctype html>
|
|
27
|
+
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="theme-color" content="#101411"><title>eDocAPI · API Explorer</title>
|
|
28
|
+
<style>
|
|
29
|
+
:root{{--bg:#101411;--panel:#171d19;--line:#29332d;--muted:#94a399;--text:#edf5ef;--green:#a8f078;--mint:#6ee7b7;--orange:#ffc078}}
|
|
30
|
+
*{{box-sizing:border-box}}body{{margin:0;background:radial-gradient(ellipse at 82% 0%,#24392c 0,transparent 36%),var(--bg);color:var(--text);font:15px/1.5 Inter,ui-sans-serif,system-ui,-apple-system,"Segoe UI",sans-serif}}a{{color:var(--green);text-decoration:none}}.shell{{max-width:1180px;margin:auto;padding:28px 24px 60px}}header{{display:flex;align-items:center;justify-content:space-between;margin-bottom:58px}}.brand{{display:flex;gap:12px;align-items:center;font-weight:750;font-size:19px}}.mark{{width:38px;height:38px;border-radius:12px;background:linear-gradient(135deg,var(--green),var(--mint));color:#102016;display:grid;place-items:center;font-weight:900}}.badge,.format{{border:1px solid var(--line);border-radius:99px;padding:6px 12px;color:var(--muted);font-size:12px}}.hero{{max-width:760px;margin-bottom:34px}}.eyebrow{{color:var(--green);font-size:12px;text-transform:uppercase;font-weight:800;letter-spacing:.15em}}h1{{font-size:clamp(38px,6vw,66px);line-height:1.02;letter-spacing:-.065em;margin:13px 0 17px}}h1 span,.code{{color:var(--green)}}.lead{{color:#aab8ae;font-size:17px;max-width:630px}}.actions{{display:flex;gap:10px;margin-top:24px;flex-wrap:wrap}}.button,.try{{border:1px solid var(--line);border-radius:9px;padding:9px 13px;background:#1d2720;color:var(--text);font-weight:650;cursor:pointer}}.button.primary{{background:var(--green);color:#122014}}.stats{{display:grid;grid-template-columns:repeat(3,1fr);gap:14px;margin:32px 0}}.stat,.panel{{background:linear-gradient(145deg,#1a211c,#151a17);border:1px solid var(--line);border-radius:15px}}.stat{{padding:18px 20px}}.stat small,.muted{{color:var(--muted)}}.stat strong{{display:block;font-size:27px;margin-top:4px}}.grid{{display:grid;grid-template-columns:1.15fr .85fr;gap:16px}}.panel{{padding:22px}}.panel h2{{font-size:16px;margin:0 0 12px}}table{{width:100%;border-collapse:collapse;text-align:left}}th{{color:var(--muted);font-size:11px;text-transform:uppercase;letter-spacing:.1em}}td,th{{padding:12px 8px;border-bottom:1px solid var(--line)}}td{{font-size:13px}}td code,.code{{font-family:ui-monospace,Consolas,monospace}}.method{{font-size:10px;font-weight:800;color:var(--mint);background:#16352c;padding:4px 7px;border-radius:5px}}.try{{font-size:11px;color:var(--green);padding:6px 9px}}.empty{{color:var(--muted)}}.formats{{display:flex;gap:7px;flex-wrap:wrap;margin:15px 0 24px}}.format{{border-radius:7px;background:#202a23;color:#c4d7c8;padding:5px 9px;font-weight:750}}.codebox{{background:#0e120f;border:1px solid var(--line);border-radius:10px;padding:14px;margin-top:13px;overflow:auto;color:#c5d3c8;font:12px/1.8 ui-monospace,Consolas,monospace}}.span2{{grid-column:1/-1}}.footer{{color:#718078;font-size:12px;margin-top:26px;display:flex;justify-content:space-between;gap:12px}}.explorer{{display:none;position:fixed;inset:0;z-index:5;background:#000b;place-items:center;padding:20px}}.explorer.open{{display:grid}}.dialog{{width:min(760px,100%);max-height:90vh;overflow:auto;background:#171d19;border:1px solid #35433a;border-radius:16px;padding:24px;box-shadow:0 25px 100px #0009}}.dialog-head{{display:flex;justify-content:space-between;gap:16px}}.close{{background:none;border:0;color:var(--muted);font-size:24px;cursor:pointer}}.field{{display:grid;gap:6px;margin:14px 0;color:#b6c4ba;font-size:13px}}.field input{{width:100%;background:#0e120f;border:1px solid var(--line);padding:10px;border-radius:8px;color:var(--text)}}.requestline{{display:flex;gap:10px;align-items:center;margin:18px 0}}.result{{white-space:pre-wrap;word-break:break-word;max-height:300px;overflow:auto}}.send{{background:var(--green);border:0;border-radius:9px;color:#142014;padding:11px 18px;font-weight:800;cursor:pointer}}@media(max-width:760px){{header{{margin-bottom:38px}}.grid{{grid-template-columns:1fr}}.span2{{grid-column:auto}}.shell{{padding:20px 16px 40px}}.route-table{{overflow:auto}}}}
|
|
31
|
+
</style></head><body><main class="shell">
|
|
32
|
+
<header><div class="brand"><div class="mark">e</div><span>eDocAPI <span class="muted">/ explorer</span></span></div><div class="badge">DOCUMENT API FRAMEWORK · v{html.escape(version)}</div></header>
|
|
33
|
+
<section class="hero"><div class="eyebrow">Your document workflow, at a glance</div><h1>Build APIs.<br><span>Move documents.</span></h1><p class="lead">Explore your endpoints, send live requests, and inspect responses from one developer console.</p><div class="actions"><a class="button primary" href="#routes">Explore endpoints ↓</a><a class="button" href="https://github.com/emmanuelemmanueletim/edocApi">Framework docs ↗</a></div></section>
|
|
34
|
+
<section class="stats"><div class="stat"><small>Registered routes</small><strong>{len(routes):02d}</strong></div><div class="stat"><small>Supported formats</small><strong>{len(formats):02d}</strong></div><div class="stat"><small>Upload limit</small><strong>{html.escape(size)}</strong></div></section>
|
|
35
|
+
<section class="grid"><div class="panel" id="routes"><h2>⌘ Your API routes</h2><p class="muted" style="font-size:13px">Choose an endpoint to try it and view its response.</p><div class="route-table"><table><thead><tr><th>Method</th><th>Path</th><th>Handler</th><th></th></tr></thead><tbody>{route_rows}</tbody></table></div></div>
|
|
36
|
+
<div class="panel"><h2>▧ Supported formats</h2><div class="formats">{formats_html}</div><h2>✳ Document toolkit</h2><div class="codebox">to_pdf() · to_text() · to_html()<br>to_images() · extract_text() · info()<br><span style="color:var(--orange)">PDF:</span> compress() · split() · extract_pages()<br>rotate() · Document.merge(files)</div></div>
|
|
37
|
+
<div class="panel span2"><h2>⚡ Quick start</h2><div class="codebox">from edocapi import App, Document<br><br>app = App()<br><br>@app.post("/convert")<br>def convert(file):<br> return Document(file).to_pdf()<br><br># Terminal: py -m edocapi.cli.main run --reload</div></div></section>
|
|
38
|
+
<footer class="footer"><span>eDocAPI · Simple API for the developer. Powerful document processing underneath.</span><span>Auto-generated from your app configuration</span></footer></main>
|
|
39
|
+
<div class="explorer" id="explorer" role="dialog" aria-modal="true" aria-labelledby="dialog-title"><div class="dialog"><div class="dialog-head"><div><div class="eyebrow">API explorer</div><h2 id="dialog-title" style="font-size:24px;margin:6px 0"></h2><p id="dialog-description" class="muted"></p></div><button class="close" id="close" aria-label="Close">×</button></div><div class="requestline"><span id="dialog-method" class="method"></span><code id="dialog-path" class="code"></code></div><form id="request-form"><div id="fields"></div><button class="send" type="submit">Send request</button></form><div id="response-wrap" hidden><h3>Result <span id="response-status" class="muted"></span></h3><details><summary>Show response details</summary><div class="codebox result" id="response-body"></div></details></div></div></div>
|
|
40
|
+
<script type="application/json" id="edocapi-routes">{route_json}</script><script>
|
|
41
|
+
const routes=JSON.parse(document.getElementById('edocapi-routes').textContent),modal=document.getElementById('explorer');let active=null;
|
|
42
|
+
document.querySelectorAll('.try').forEach(button=>button.addEventListener('click',()=>openExplorer(routes[Number(button.dataset.route)])));
|
|
43
|
+
function openExplorer(route){{active=route;document.getElementById('dialog-title').textContent=route.name;document.getElementById('dialog-description').textContent=route.description||'Send a request and inspect the endpoint response.';document.getElementById('dialog-method').textContent=route.methods.join(', ');document.getElementById('dialog-path').textContent=route.path;const fields=document.getElementById('fields');fields.replaceChildren();for(const p of route.parameters||[]){{const wrap=document.createElement('label');wrap.className='field';wrap.textContent=['target_size','target_size_value'].includes(p.name)?'Preferred maximum size (optional)':p.name+(p.required?' *':'');let input;if(p.name==='target_unit'){{input=document.createElement('select');for(const unit of ['KB','MB']){{const option=document.createElement('option');option.value=unit;option.textContent=unit;input.appendChild(option)}}input.value='MB'}}else{{input=document.createElement('input');if(p.kind==='upload'){{input.type='file';if(p.multiple)input.multiple=true}}else{{input.type='text';input.placeholder=p.required?'Required value':'Optional value';if(p.required)input.required=true;if(['target_size','target_size_value'].includes(p.name)){{input.type='number';input.min='1';input.placeholder='Enter target size'}}}}}}input.name=p.name;wrap.appendChild(input);fields.appendChild(wrap)}}document.getElementById('response-wrap').hidden=true;modal.classList.add('open')}}
|
|
44
|
+
document.getElementById('close').onclick=()=>modal.classList.remove('open');modal.addEventListener('click',e=>{{if(e.target===modal)modal.classList.remove('open')}});document.addEventListener('keydown',e=>{{if(e.key==='Escape')modal.classList.remove('open')}});
|
|
45
|
+
document.getElementById('request-form').addEventListener('submit',async e=>{{e.preventDefault();const values=new FormData(e.currentTarget),url=new URL(active.path,location.origin),pathParams=new Set((active.path.match(/{{[^}}]+}}/g)||[]).map(x=>x.slice(1,-1))),upload=[];for(const p of active.parameters||[]){{const v=values.getAll(p.name);if(p.kind==='upload'){{for(const f of v)if(f&&f.size)upload.push([p.name,f])}}else if(v[0]){{if(pathParams.has(p.name))url.pathname=url.pathname.replace('{{'+p.name+'}}',encodeURIComponent(v[0]));else url.searchParams.set(p.name,v[0])}}}}const method=active.methods.includes('GET')?'GET':active.methods.includes('POST')?'POST':active.methods[0],options={{method}};if(upload.length){{const body=new FormData();for(const [key,file]of upload)body.append(key,file,file.name);options.body=body}}document.getElementById('response-wrap').hidden=false;const status=document.getElementById('response-status'),output=document.getElementById('response-body');status.textContent='Sending…';output.textContent='';try{{const response=await fetch(url,options),type=response.headers.get('content-type')||'',body=type.includes('json')?JSON.stringify(await response.json(),null,2):await response.text();status.textContent=response.ok?response.status+' OK':'HTTP '+response.status+' '+response.statusText;output.textContent=body||'(empty response)'}}catch(error){{status.textContent='Request failed';output.textContent=String(error)}}}});
|
|
46
|
+
</script></body></html>'''
|
|
@@ -146,8 +146,15 @@ class Document:
|
|
|
146
146
|
out_path = self._processor().to_pdf()
|
|
147
147
|
return Document(out_path, temp_storage=self._temp)
|
|
148
148
|
except Exception as exc:
|
|
149
|
+
message = str(exc)
|
|
150
|
+
if "libgobject" in message.lower() or "cannot load library" in message.lower():
|
|
151
|
+
message = (
|
|
152
|
+
"WeasyPrint's native GTK libraries are missing. On Windows, "
|
|
153
|
+
"install the GTK3 runtime, add its bin directory to PATH, "
|
|
154
|
+
"then restart the terminal and server."
|
|
155
|
+
)
|
|
149
156
|
raise ConversionError(
|
|
150
|
-
|
|
157
|
+
message, source=self.type, target="pdf"
|
|
151
158
|
) from exc
|
|
152
159
|
|
|
153
160
|
def to_text(self) -> str:
|
|
@@ -215,15 +222,28 @@ class Document:
|
|
|
215
222
|
"mime_type": self.mime_type,
|
|
216
223
|
}
|
|
217
224
|
|
|
218
|
-
def compress(self, level: str = "medium") -> "Document":
|
|
219
|
-
"""Compress a PDF
|
|
225
|
+
def compress(self, level: str = "medium", target_size: int | None = None) -> "Document":
|
|
226
|
+
"""Compress a PDF or supported raster image; optionally set a byte target."""
|
|
227
|
+
if self.type in {"jpeg", "jpg", "png", "webp"}:
|
|
228
|
+
from edocapi.processors.image import ImageProcessor
|
|
229
|
+
|
|
230
|
+
proc = ImageProcessor(self.path, temp_storage=self._temp)
|
|
231
|
+
out = proc.compress(level=level, target_size=target_size)
|
|
232
|
+
result = Document(out, temp_storage=self._temp)
|
|
233
|
+
result._original_name = self.name
|
|
234
|
+
result._compression_mode = "image"
|
|
235
|
+
return result
|
|
220
236
|
if self.type != "pdf":
|
|
221
|
-
raise ProcessingError(
|
|
237
|
+
raise ProcessingError(
|
|
238
|
+
"compress() supports PDF, JPG, JPEG, PNG, and WebP files only."
|
|
239
|
+
)
|
|
222
240
|
from edocapi.processors.pdf import PDFProcessor
|
|
223
241
|
|
|
224
242
|
proc = PDFProcessor(self.path, temp_storage=self._temp)
|
|
225
|
-
out = proc.compress(level=level)
|
|
226
|
-
|
|
243
|
+
out = proc.compress(level=level, target_size=target_size)
|
|
244
|
+
result = Document(out, temp_storage=self._temp)
|
|
245
|
+
result._compression_mode = getattr(proc, "_last_compression_mode", "optimized")
|
|
246
|
+
return result
|
|
227
247
|
|
|
228
248
|
def split(self) -> list["Document"]:
|
|
229
249
|
"""Split a PDF into individual pages."""
|
|
@@ -82,18 +82,17 @@ class DOCXProcessor(BaseProcessor):
|
|
|
82
82
|
html = self.to_html()
|
|
83
83
|
try:
|
|
84
84
|
from weasyprint import HTML
|
|
85
|
-
except ImportError
|
|
86
|
-
|
|
87
|
-
"DOCX -> PDF requires weasyprint. "
|
|
88
|
-
"Install with: pip install edocapi[html]",
|
|
89
|
-
source="docx",
|
|
90
|
-
target="pdf",
|
|
91
|
-
) from exc
|
|
85
|
+
except (ImportError, OSError):
|
|
86
|
+
HTML = None
|
|
92
87
|
|
|
93
88
|
storage = self.temp_storage or TemporaryStorage()
|
|
94
89
|
out = storage.create_file(suffix=".pdf", prefix="docx_")
|
|
95
|
-
|
|
96
|
-
|
|
90
|
+
from edocapi.processors.simple_pdf import html_to_text, render_html_pdf, render_text_pdf
|
|
91
|
+
|
|
92
|
+
fallback_text = self.to_text()
|
|
93
|
+
if HTML is None:
|
|
94
|
+
return render_text_pdf(fallback_text, out)
|
|
95
|
+
return render_html_pdf(html, out, fallback_text or html_to_text(html))
|
|
97
96
|
|
|
98
97
|
def info(self) -> dict[str, Any]:
|
|
99
98
|
core = self._doc.core_properties
|
|
@@ -38,18 +38,18 @@ class HTMLProcessor(BaseProcessor):
|
|
|
38
38
|
"""Convert HTML to PDF using WeasyPrint."""
|
|
39
39
|
try:
|
|
40
40
|
from weasyprint import HTML
|
|
41
|
-
except ImportError
|
|
42
|
-
|
|
43
|
-
"HTML -> PDF requires weasyprint. "
|
|
44
|
-
"Install with: pip install edocapi[html]",
|
|
45
|
-
source="html",
|
|
46
|
-
target="pdf",
|
|
47
|
-
) from exc
|
|
41
|
+
except (ImportError, OSError):
|
|
42
|
+
HTML = None
|
|
48
43
|
|
|
49
44
|
storage = self.temp_storage or TemporaryStorage()
|
|
50
45
|
out = storage.create_file(suffix=".pdf", prefix="html_")
|
|
51
|
-
|
|
52
|
-
|
|
46
|
+
from edocapi.processors.simple_pdf import html_to_text, render_html_pdf, render_text_pdf
|
|
47
|
+
|
|
48
|
+
source = self.path.read_text(encoding="utf-8", errors="replace")
|
|
49
|
+
text = html_to_text(source)
|
|
50
|
+
if HTML is None:
|
|
51
|
+
return render_text_pdf(text, out)
|
|
52
|
+
return render_html_pdf(source, out, text)
|
|
53
53
|
|
|
54
54
|
def info(self) -> dict[str, Any]:
|
|
55
55
|
content = self.path.read_text(encoding="utf-8", errors="replace")
|