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.
Files changed (35) hide show
  1. {edocapi-0.0.2 → edocapi-0.1.0}/PKG-INFO +20 -9
  2. edocapi-0.1.0/PUBLISH.md +95 -0
  3. {edocapi-0.0.2 → edocapi-0.1.0}/README.md +19 -8
  4. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/__init__.py +1 -1
  5. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/app.py +73 -6
  6. edocapi-0.1.0/edocapi/dashboard.py +46 -0
  7. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/document.py +26 -6
  8. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/docx.py +8 -9
  9. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/html.py +9 -9
  10. edocapi-0.1.0/edocapi/processors/image.py +149 -0
  11. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/markdown.py +8 -9
  12. edocapi-0.1.0/edocapi/processors/pdf.py +545 -0
  13. edocapi-0.1.0/edocapi/processors/simple_pdf.py +128 -0
  14. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/txt.py +8 -9
  15. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/validation/files.py +6 -0
  16. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi.egg-info/SOURCES.txt +2 -0
  17. {edocapi-0.0.2 → edocapi-0.1.0}/pyproject.toml +1 -1
  18. edocapi-0.0.2/PUBLISH.md +0 -51
  19. edocapi-0.0.2/edocapi/processors/image.py +0 -80
  20. edocapi-0.0.2/edocapi/processors/pdf.py +0 -231
  21. {edocapi-0.0.2 → edocapi-0.1.0}/AUTHORS.md +0 -0
  22. {edocapi-0.0.2 → edocapi-0.1.0}/LICENSE +0 -0
  23. {edocapi-0.0.2 → edocapi-0.1.0}/MANIFEST.in +0 -0
  24. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/cli/__init__.py +0 -0
  25. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/cli/main.py +0 -0
  26. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/config.py +0 -0
  27. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/exceptions.py +0 -0
  28. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/files.py +0 -0
  29. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/__init__.py +0 -0
  30. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/processors/base.py +0 -0
  31. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/responses.py +0 -0
  32. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/storage/__init__.py +0 -0
  33. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/storage/temporary.py +0 -0
  34. {edocapi-0.0.2 → edocapi-0.1.0}/edocapi/validation/__init__.py +0 -0
  35. {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.2
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.2**.
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 needs system libraries. On Debian/Ubuntu:
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 v0.0.2", "supported": Document.supported_types()}
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 (v0.0.2)
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. If you enable HTML-to-PDF conversion,
238
- install WeasyPrint's operating-system libraries in the image or host as well as
239
- the `html` extra. Set the reverse proxy's request-body limit to match
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
 
@@ -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.2**.
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 needs system libraries. On Debian/Ubuntu:
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 v0.0.2", "supported": Document.supported_types()}
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 (v0.0.2)
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. If you enable HTML-to-PDF conversion,
188
- install WeasyPrint's operating-system libraries in the image or host as well as
189
- the `html` extra. Set the reverse proxy's request-body limit to match
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.2"
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
- self._routes.append(Route(path, endpoint=wrapper, methods=list(methods)))
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
- # Try query params as fallback
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=path.name,
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=self._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>⌘ &nbsp;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>▧ &nbsp;Supported formats</h2><div class="formats">{formats_html}</div><h2>✳ &nbsp;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>⚡ &nbsp;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>&nbsp;&nbsp;&nbsp;&nbsp;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
- str(exc), source=self.type, target="pdf"
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. Returns a new Document."""
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("compress() is only available for PDF documents.")
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
- return Document(out, temp_storage=self._temp)
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 as exc:
86
- raise ConversionError(
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
- HTML(string=html).write_pdf(str(out))
96
- return out
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 as exc:
42
- raise ConversionError(
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
- HTML(filename=str(self.path)).write_pdf(str(out))
52
- return out
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")