tailprint 1.3.0__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.
tailprint/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Official Tailprint Python Client SDK."""
2
+
3
+ from .client import Tailprint
4
+ from .errors import (
5
+ AuthenticationError,
6
+ RateLimitError,
7
+ TailprintError,
8
+ TimeoutError,
9
+ ValidationError,
10
+ )
11
+
12
+ __version__ = "1.3.0"
13
+ __all__ = [
14
+ "Tailprint",
15
+ "TailprintError",
16
+ "AuthenticationError",
17
+ "RateLimitError",
18
+ "ValidationError",
19
+ "TimeoutError",
20
+ "__version__",
21
+ ]
tailprint/client.py ADDED
@@ -0,0 +1,475 @@
1
+ """Official Tailprint Python Client SDK.
2
+
3
+ High-performance Tailwind HTML to Vector PDF, EU Factur-X / ZUGFeRD E-Invoicing,
4
+ PDF Operations (Merge, Split, Stamp, Compress, Fill Forms) & Asynchronous Batch Engine.
5
+
6
+ Documentation: https://tailprint.com/docs
7
+ """
8
+
9
+ import base64
10
+ import hashlib
11
+ import hmac
12
+ import json
13
+ import os
14
+ import time
15
+ import urllib.error
16
+ import urllib.request
17
+ from typing import Any, Dict, List, Optional, Union
18
+
19
+ from .errors import (
20
+ AuthenticationError,
21
+ RateLimitError,
22
+ TailprintError,
23
+ TimeoutError,
24
+ ValidationError,
25
+ )
26
+
27
+ PDFInput = Union[bytes, bytearray, str]
28
+
29
+
30
+ class Tailprint:
31
+ """Tailprint API Client."""
32
+
33
+ def __init__(
34
+ self,
35
+ api_key: Optional[str] = None,
36
+ base_url: Optional[str] = None,
37
+ timeout: float = 30.0,
38
+ ) -> None:
39
+ """Initialize Tailprint client.
40
+
41
+ Args:
42
+ api_key: Tailprint API Key (e.g. 'sec_live_...'). Defaults to TAILPRINT_API_KEY env var.
43
+ base_url: Base API URL. Defaults to 'https://tailprint.com'.
44
+ timeout: Request timeout in seconds. Defaults to 30.0.
45
+ """
46
+ self.api_key = api_key or os.environ.get("TAILPRINT_API_KEY", "")
47
+ self.base_url = (base_url or os.environ.get("TAILPRINT_BASE_URL", "https://tailprint.com")).rstrip("/")
48
+ self.timeout = timeout
49
+
50
+ def _to_base64_data_uri(self, input_val: PDFInput) -> str:
51
+ """Helper to convert bytes or string to data URI / URL string."""
52
+ if isinstance(input_val, (bytes, bytearray)):
53
+ b64 = base64.b64encode(input_val).decode("utf-8")
54
+ return f"data:application/pdf;base64,{b64}"
55
+ if isinstance(input_val, str):
56
+ if input_val.startswith("http://") or input_val.startswith("https://") or input_val.startswith("data:"):
57
+ return input_val
58
+ # Assume raw base64 string
59
+ return f"data:application/pdf;base64,{input_val}"
60
+ raise ValueError("Invalid PDF input: must be bytes or string URL/Base64")
61
+
62
+ def _request(
63
+ self,
64
+ endpoint: str,
65
+ method: str = "POST",
66
+ payload: Optional[Dict[str, Any]] = None,
67
+ expect_binary: bool = False,
68
+ ) -> Union[bytes, Dict[str, Any]]:
69
+ """Executes an HTTP request to the Tailprint API."""
70
+ url = f"{self.base_url}{endpoint}"
71
+ headers = {
72
+ "User-Agent": "Tailprint-Python-SDK/1.3.0",
73
+ }
74
+
75
+ if self.api_key:
76
+ headers["Authorization"] = f"Bearer {self.api_key}"
77
+
78
+ data: Optional[bytes] = None
79
+ if payload is not None:
80
+ headers["Content-Type"] = "application/json"
81
+ data = json.dumps(payload).encode("utf-8")
82
+
83
+ req = urllib.request.Request(url, data=data, headers=headers, method=method)
84
+
85
+ try:
86
+ with urllib.request.urlopen(req, timeout=self.timeout) as response:
87
+ content_type = response.headers.get("Content-Type", "")
88
+ body = response.read()
89
+
90
+ if expect_binary or "application/pdf" in content_type:
91
+ return body
92
+
93
+ if body:
94
+ try:
95
+ return json.loads(body.decode("utf-8"))
96
+ except Exception:
97
+ return body
98
+ return {}
99
+
100
+ except urllib.error.HTTPError as err:
101
+ err_body = err.read().decode("utf-8") if err.fp else ""
102
+ msg = err.reason
103
+ details = None
104
+
105
+ try:
106
+ parsed = json.loads(err_body)
107
+ msg = parsed.get("message", msg)
108
+ details = parsed.get("details")
109
+ except Exception:
110
+ if err_body:
111
+ msg = err_body
112
+
113
+ if err.code == 401:
114
+ raise AuthenticationError(msg, err.code, details) from err
115
+ elif err.code == 429:
116
+ raise RateLimitError(msg, err.code, details) from err
117
+ elif err.code == 400:
118
+ raise ValidationError(msg, err.code, details) from err
119
+ elif err.code in (408, 504):
120
+ raise TimeoutError(msg, err.code, details) from err
121
+ else:
122
+ raise TailprintError(msg, err.code, details) from err
123
+
124
+ except urllib.error.URLError as err:
125
+ raise TailprintError(f"Connection failed: {err.reason}", 503) from err
126
+
127
+ # =========================================================================
128
+ # CORE PDF RENDERING
129
+ # =========================================================================
130
+
131
+ def render_pdf(
132
+ self,
133
+ html: Optional[str] = None,
134
+ url: Optional[str] = None,
135
+ format: str = "A4",
136
+ landscape: bool = False,
137
+ print_background: bool = True,
138
+ margin: Optional[Dict[str, str]] = None,
139
+ display_header_footer: Optional[bool] = None,
140
+ header_html: Optional[str] = None,
141
+ footer_html: Optional[str] = None,
142
+ smart_pagination: bool = True,
143
+ password: Optional[str] = None,
144
+ owner_password: Optional[str] = None,
145
+ wait_for: Optional[str] = None,
146
+ delay_ms: Optional[int] = None,
147
+ block_popups: bool = False,
148
+ no_store: bool = False,
149
+ store: bool = False,
150
+ expires_in: str = "24h",
151
+ ) -> Union[bytes, Dict[str, Any]]:
152
+ """Renders HTML (with Tailwind CSS classes) or a public URL to vector PDF.
153
+
154
+ Args:
155
+ html: HTML string with Tailwind CSS utility classes.
156
+ url: Direct web URL to capture as vector PDF.
157
+ format: Paper size ('A4', 'Letter', 'Legal').
158
+ landscape: Landscape orientation if True.
159
+ margin: Dict with 'top', 'bottom', 'left', 'right' (e.g. {'top': '10mm'}).
160
+ store: If True, stores PDF on Tailprint CDN and returns a hosted link dict.
161
+
162
+ Returns:
163
+ bytes (PDF binary stream) or dict (if store=True).
164
+ """
165
+ payload: Dict[str, Any] = {
166
+ "format": format,
167
+ "landscape": landscape,
168
+ "print_background": print_background,
169
+ "smart_pagination": smart_pagination,
170
+ "no_store": no_store,
171
+ "store": store,
172
+ "expires_in": expires_in,
173
+ }
174
+ if html is not None:
175
+ payload["html"] = html
176
+ if url is not None:
177
+ payload["url"] = url
178
+ if margin is not None:
179
+ payload["margin"] = margin
180
+ if header_html is not None:
181
+ payload["header_html"] = header_html
182
+ if footer_html is not None:
183
+ payload["footer_html"] = footer_html
184
+ if display_header_footer is not None:
185
+ payload["display_header_footer"] = display_header_footer
186
+ if password is not None:
187
+ payload["password"] = password
188
+ if owner_password is not None:
189
+ payload["owner_password"] = owner_password
190
+ if wait_for is not None:
191
+ payload["wait_for"] = wait_for
192
+ if delay_ms is not None:
193
+ payload["delay_ms"] = delay_ms
194
+ if block_popups:
195
+ payload["block_popups"] = True
196
+
197
+ return self._request("/v1/render/pdf", payload=payload, expect_binary=not store)
198
+
199
+ # =========================================================================
200
+ # ASYNCHRONOUS & BATCH RENDERING
201
+ # =========================================================================
202
+
203
+ def render_pdf_async(
204
+ self,
205
+ html: Optional[str] = None,
206
+ url: Optional[str] = None,
207
+ webhook_url: Optional[str] = None,
208
+ webhook_headers: Optional[Dict[str, str]] = None,
209
+ format: str = "A4",
210
+ landscape: bool = False,
211
+ margin: Optional[Dict[str, str]] = None,
212
+ header_html: Optional[str] = None,
213
+ footer_html: Optional[str] = None,
214
+ smart_pagination: bool = True,
215
+ delay_ms: Optional[int] = None,
216
+ expires_in: str = "24h",
217
+ ) -> Dict[str, Any]:
218
+ """Submits a PDF rendering job asynchronously in background.
219
+
220
+ Returns immediately with a job_id and status_url.
221
+ """
222
+ payload: Dict[str, Any] = {
223
+ "format": format,
224
+ "landscape": landscape,
225
+ "smart_pagination": smart_pagination,
226
+ "async": True,
227
+ "expires_in": expires_in,
228
+ }
229
+ if html is not None:
230
+ payload["html"] = html
231
+ if url is not None:
232
+ payload["url"] = url
233
+ if webhook_url is not None:
234
+ payload["webhook_url"] = webhook_url
235
+ if webhook_headers is not None:
236
+ payload["webhook_headers"] = webhook_headers
237
+ if margin is not None:
238
+ payload["margin"] = margin
239
+ if header_html is not None:
240
+ payload["header_html"] = header_html
241
+ if footer_html is not None:
242
+ payload["footer_html"] = footer_html
243
+ if delay_ms is not None:
244
+ payload["delay_ms"] = delay_ms
245
+
246
+ res = self._request("/v1/render/pdf", payload=payload, expect_binary=False)
247
+ return res if isinstance(res, dict) else {}
248
+
249
+ def render_batch(
250
+ self,
251
+ items: List[Dict[str, Any]],
252
+ merge_output: bool = False,
253
+ merged_filename: str = "merged-batch.pdf",
254
+ webhook_url: Optional[str] = None,
255
+ webhook_headers: Optional[Dict[str, str]] = None,
256
+ expires_in: str = "24h",
257
+ ) -> Dict[str, Any]:
258
+ """Submits up to 100 documents for concurrent batch rendering with optional merging."""
259
+ payload: Dict[str, Any] = {
260
+ "items": items,
261
+ "merge_output": merge_output,
262
+ "merged_filename": merged_filename,
263
+ "expires_in": expires_in,
264
+ }
265
+ if webhook_url is not None:
266
+ payload["webhook_url"] = webhook_url
267
+ if webhook_headers is not None:
268
+ payload["webhook_headers"] = webhook_headers
269
+
270
+ res = self._request("/v1/render/batch", payload=payload, expect_binary=False)
271
+ return res if isinstance(res, dict) else {}
272
+
273
+ def get_job(self, job_id: str) -> Dict[str, Any]:
274
+ """Retrieves the live processing status, progress, and download links of a job."""
275
+ res = self._request(f"/v1/jobs/{job_id}", method="GET", expect_binary=False)
276
+ return res if isinstance(res, dict) else {}
277
+
278
+ def wait_for_job(
279
+ self,
280
+ job_id: str,
281
+ poll_interval: float = 1.0,
282
+ timeout: float = 60.0,
283
+ ) -> Dict[str, Any]:
284
+ """Polls a job until it reaches 'completed' or 'failed', or until timeout expires."""
285
+ start_time = time.time()
286
+ while time.time() - start_time < timeout:
287
+ job = self.get_job(job_id)
288
+ if job.get("status") in ("completed", "failed"):
289
+ return job
290
+ time.sleep(poll_interval)
291
+
292
+ raise TimeoutError(f"Job {job_id} did not finish within {timeout}s", 408)
293
+
294
+ # =========================================================================
295
+ # EUROPEAN E-INVOICING (Factur-X / ZUGFeRD / EN 16931)
296
+ # =========================================================================
297
+
298
+ def generate_einvoice(
299
+ self,
300
+ invoice_number: str,
301
+ seller: Dict[str, Any],
302
+ buyer: Dict[str, Any],
303
+ line_items: List[Dict[str, Any]],
304
+ currency: str = "EUR",
305
+ issue_date: Optional[str] = None,
306
+ due_date: Optional[str] = None,
307
+ payment_iban: Optional[str] = None,
308
+ payment_bic: Optional[str] = None,
309
+ notes: Optional[str] = None,
310
+ profile: str = "BASIC",
311
+ store: bool = False,
312
+ expires_in: str = "24h",
313
+ ) -> Union[bytes, Dict[str, Any]]:
314
+ """Generates a legally compliant European Factur-X / ZUGFeRD hybrid PDF/A-3 invoice with embedded XML."""
315
+ payload: Dict[str, Any] = {
316
+ "invoiceNumber": invoice_number,
317
+ "currency": currency,
318
+ "seller": seller,
319
+ "buyer": buyer,
320
+ "lineItems": line_items,
321
+ "profile": profile,
322
+ "store": store,
323
+ "expires_in": expires_in,
324
+ }
325
+ if issue_date:
326
+ payload["issueDate"] = issue_date
327
+ if due_date:
328
+ payload["dueDate"] = due_date
329
+ if payment_iban:
330
+ payload["paymentIban"] = payment_iban
331
+ if payment_bic:
332
+ payload["paymentBic"] = payment_bic
333
+ if notes:
334
+ payload["notes"] = notes
335
+
336
+ return self._request("/v1/render/einvoice", payload=payload, expect_binary=not store)
337
+
338
+ def validate_einvoice(
339
+ self,
340
+ pdf: Optional[PDFInput] = None,
341
+ xml: Optional[str] = None,
342
+ ) -> Dict[str, Any]:
343
+ """Inspects a PDF or XML invoice to verify compliance with European standard EN 16931."""
344
+ payload: Dict[str, Any] = {}
345
+ if pdf is not None:
346
+ payload["pdf"] = self._to_base64_data_uri(pdf)
347
+ if xml is not None:
348
+ payload["xml"] = xml
349
+
350
+ res = self._request("/v1/pdf/validate-einvoice", payload=payload, expect_binary=False)
351
+ return res if isinstance(res, dict) else {}
352
+
353
+ # =========================================================================
354
+ # PDF OPERATIONS (Merge, Split, Fill, Compress, Stamp)
355
+ # =========================================================================
356
+
357
+ def merge_pdfs(
358
+ self,
359
+ pdfs: List[PDFInput],
360
+ output_filename: str = "merged.pdf",
361
+ store: bool = False,
362
+ expires_in: str = "24h",
363
+ ) -> Union[bytes, Dict[str, Any]]:
364
+ """Merges two or more PDFs into a single unified vector PDF."""
365
+ payload = {
366
+ "pdfs": [self._to_base64_data_uri(p) for p in pdfs],
367
+ "output_filename": output_filename,
368
+ "store": store,
369
+ "expires_in": expires_in,
370
+ }
371
+ return self._request("/v1/pdf/merge", payload=payload, expect_binary=not store)
372
+
373
+ def split_pdf(
374
+ self,
375
+ pdf: PDFInput,
376
+ pages: str,
377
+ output_filename: str = "extracted.pdf",
378
+ store: bool = False,
379
+ expires_in: str = "24h",
380
+ ) -> Union[bytes, Dict[str, Any]]:
381
+ """Extracts specific pages or page ranges from a PDF (e.g. '1-3, 5, 8-10')."""
382
+ payload = {
383
+ "pdf": self._to_base64_data_uri(pdf),
384
+ "pages": pages,
385
+ "output_filename": output_filename,
386
+ "store": store,
387
+ "expires_in": expires_in,
388
+ }
389
+ return self._request("/v1/pdf/split", payload=payload, expect_binary=not store)
390
+
391
+ def fill_pdf_form(
392
+ self,
393
+ pdf: PDFInput,
394
+ fields: Dict[str, Any],
395
+ flatten: bool = True,
396
+ output_filename: str = "completed-form.pdf",
397
+ store: bool = False,
398
+ expires_in: str = "24h",
399
+ ) -> Union[bytes, Dict[str, Any]]:
400
+ """Fills interactive AcroForms with text, checkboxes, and radios."""
401
+ payload = {
402
+ "pdf": self._to_base64_data_uri(pdf),
403
+ "fields": fields,
404
+ "flatten": flatten,
405
+ "output_filename": output_filename,
406
+ "store": store,
407
+ "expires_in": expires_in,
408
+ }
409
+ return self._request("/v1/pdf/fill-form", payload=payload, expect_binary=not store)
410
+
411
+ def compress_pdf(
412
+ self,
413
+ pdf: PDFInput,
414
+ output_filename: str = "compressed.pdf",
415
+ store: bool = False,
416
+ expires_in: str = "24h",
417
+ ) -> Union[bytes, Dict[str, Any]]:
418
+ """Shrinks PDF file size via object stream compression."""
419
+ payload = {
420
+ "pdf": self._to_base64_data_uri(pdf),
421
+ "output_filename": output_filename,
422
+ "store": store,
423
+ "expires_in": expires_in,
424
+ }
425
+ return self._request("/v1/pdf/compress", payload=payload, expect_binary=not store)
426
+
427
+ def stamp_pdf(
428
+ self,
429
+ pdf: PDFInput,
430
+ watermark_text: str,
431
+ color: str = "red",
432
+ opacity: float = 0.15,
433
+ font_size: int = 54,
434
+ output_filename: Optional[str] = None,
435
+ store: bool = False,
436
+ expires_in: str = "24h",
437
+ ) -> Union[bytes, Dict[str, Any]]:
438
+ """Stamps a diagonal watermark or compliance stamp across all pages of a PDF."""
439
+ payload: Dict[str, Any] = {
440
+ "pdf": self._to_base64_data_uri(pdf),
441
+ "watermark_text": watermark_text,
442
+ "color": color,
443
+ "opacity": opacity,
444
+ "font_size": font_size,
445
+ "store": store,
446
+ "expires_in": expires_in,
447
+ }
448
+ if output_filename:
449
+ payload["output_filename"] = output_filename
450
+ return self._request("/v1/pdf/stamp", payload=payload, expect_binary=not store)
451
+
452
+ # =========================================================================
453
+ # UTILITIES & USAGE
454
+ # =========================================================================
455
+
456
+ def get_usage(self) -> Dict[str, Any]:
457
+ """Returns real-time monthly quota usage and limits."""
458
+ res = self._request("/v1/usage", method="GET", expect_binary=False)
459
+ return res if isinstance(res, dict) else {}
460
+
461
+ def verify_webhook_signature(
462
+ self,
463
+ payload_bytes: bytes,
464
+ signature_header: str,
465
+ secret: Optional[str] = None,
466
+ ) -> bool:
467
+ """Verifies the HMAC-SHA256 signature on an incoming webhook request."""
468
+ signing_key = (secret or self.api_key).encode("utf-8")
469
+ expected_sig = hmac.new(signing_key, payload_bytes, hashlib.sha256).hexdigest()
470
+
471
+ header_val = signature_header.strip()
472
+ if header_val.startswith("sha256="):
473
+ header_val = header_val[7:]
474
+
475
+ return hmac.compare_digest(expected_sig, header_val)
tailprint/errors.py ADDED
@@ -0,0 +1,36 @@
1
+ """Tailprint SDK exception classes."""
2
+
3
+ from typing import Any, Optional
4
+
5
+
6
+ class TailprintError(Exception):
7
+ """Base exception for all Tailprint SDK errors."""
8
+
9
+ def __init__(self, message: str, status_code: int = 500, details: Optional[Any] = None) -> None:
10
+ super().__init__(message)
11
+ self.message = message
12
+ self.status_code = status_code
13
+ self.details = details
14
+
15
+ def __repr__(self) -> str:
16
+ return f"{self.__class__.__name__}(message={self.message!r}, status_code={self.status_code})"
17
+
18
+
19
+ class AuthenticationError(TailprintError):
20
+ """Raised when an API key is missing, invalid, or inactive (HTTP 401)."""
21
+ pass
22
+
23
+
24
+ class RateLimitError(TailprintError):
25
+ """Raised when the monthly quota is exhausted (HTTP 429)."""
26
+ pass
27
+
28
+
29
+ class ValidationError(TailprintError):
30
+ """Raised when request parameters fail schema validation (HTTP 400)."""
31
+ pass
32
+
33
+
34
+ class TimeoutError(TailprintError):
35
+ """Raised when a job or HTTP request exceeds its timeout (HTTP 408 / 504)."""
36
+ pass
tailprint/py.typed ADDED
File without changes
@@ -0,0 +1,195 @@
1
+ Metadata-Version: 2.4
2
+ Name: tailprint
3
+ Version: 1.3.0
4
+ Summary: Official Python SDK for Tailprint — Developer-First PDF & Document Engine
5
+ Author-email: Tailprint <reach@clewaa.com>
6
+ License-Expression: MIT
7
+ Project-URL: Homepage, https://tailprint.com
8
+ Project-URL: Documentation, https://tailprint.com/docs
9
+ Project-URL: Repository, https://github.com/coheroseb/tailprint
10
+ Project-URL: Issues, https://github.com/coheroseb/tailprint/issues
11
+ Keywords: pdf,html-to-pdf,tailwind,factur-x,zugferd,einvoice,playwright,pdf-merge,pdf-split,pdf-stamp,document-generation
12
+ Classifier: Development Status :: 5 - Production/Stable
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: Operating System :: OS Independent
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.8
17
+ Classifier: Programming Language :: Python :: 3.9
18
+ Classifier: Programming Language :: Python :: 3.10
19
+ Classifier: Programming Language :: Python :: 3.11
20
+ Classifier: Programming Language :: Python :: 3.12
21
+ Classifier: Programming Language :: Python :: 3.13
22
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
23
+ Classifier: Topic :: Text Processing :: Markup :: HTML
24
+ Classifier: Typing :: Typed
25
+ Requires-Python: >=3.8
26
+ Description-Content-Type: text/markdown
27
+
28
+ # Tailprint Python SDK
29
+
30
+ [![PyPI version](https://img.shields.io/pypi/v/tailprint.svg)](https://pypi.org/project/tailprint/)
31
+ [![Python versions](https://img.shields.io/pypi/pyversions/tailprint.svg)](https://pypi.org/project/tailprint/)
32
+ [![License: MIT](https://img.shields.io/badge/License-MIT-blue.svg)](https://opensource.org/licenses/MIT)
33
+
34
+ Official Python client for **Tailprint** ([tailprint.com](https://tailprint.com)) — the developer-first programmable PDF and document engine.
35
+
36
+ Turns Tailwind CSS HTML, dynamic data, and AI prompts into pixel-perfect vector **PDFs**, European **Factur-X / ZUGFeRD e-invoices**, and provides full PDF manipulation operations (merge, split, fill forms, stamp, compress, validate).
37
+
38
+ - **Zero dependencies:** Built entirely with standard library modules (`urllib`, `json`, `base64`, `hmac`). Installs in < 1 second with 0 dependency conflicts.
39
+ - **Async & Batch:** Native support for asynchronous background rendering and concurrent multi-document batch runs.
40
+ - **Enterprise-Ready:** Cryptographic webhook signature verification and zero-retention GDPR mode.
41
+
42
+ ---
43
+
44
+ ## 📦 Installation
45
+
46
+ ```bash
47
+ pip install tailprint
48
+ ```
49
+
50
+ ---
51
+
52
+ ## 🚀 Quickstart
53
+
54
+ ```python
55
+ import os
56
+ from tailprint import Tailprint
57
+
58
+ # Initialize with your API key (or set TAILPRINT_API_KEY environment variable)
59
+ tp = Tailprint(api_key=os.environ.get("TAILPRINT_API_KEY", "sec_live_..."))
60
+
61
+ # 1. Render Tailwind HTML to Vector PDF
62
+ pdf_bytes = tp.render_pdf(
63
+ html="""
64
+ <div class="p-12 bg-slate-900 text-white font-sans">
65
+ <h1 class="text-3xl font-bold text-indigo-400">Quarterly Invoice</h1>
66
+ <p class="text-slate-400 mt-2">Styled dynamically with Tailwind CSS.</p>
67
+ </div>
68
+ """,
69
+ format="A4",
70
+ smart_pagination=True,
71
+ )
72
+
73
+ with open("invoice.pdf", "wb") as f:
74
+ f.write(pdf_bytes)
75
+ ```
76
+
77
+ ---
78
+
79
+ ## ⚡ Asynchronous Rendering & Webhooks
80
+
81
+ For heavy 100+ page reports or async pipelines:
82
+
83
+ ```python
84
+ # Submit rendering job in background
85
+ job = tp.render_pdf_async(
86
+ html="<h1>Heavy 200-page Annual Financial Report</h1>",
87
+ webhook_url="https://your-api.com/webhooks/pdf",
88
+ webhook_headers={"X-Secret": "your_webhook_secret"},
89
+ )
90
+
91
+ print("Job queued:", job["job_id"])
92
+
93
+ # Optionally poll until finished:
94
+ completed_job = tp.wait_for_job(job["job_id"], poll_interval=1.0, timeout=60.0)
95
+ print("Download URL:", completed_job["results"][0]["download_url"])
96
+ ```
97
+
98
+ ---
99
+
100
+ ## 📦 Concurrent Batch Rendering with Auto-Merge
101
+
102
+ Render up to 100 documents concurrently and optionally merge them into a single multi-page PDF:
103
+
104
+ ```python
105
+ batch = tp.render_batch(
106
+ items=[
107
+ {"html": "<h1>Invoice #101</h1>", "filename": "inv-101.pdf"},
108
+ {"html": "<h1>Invoice #102</h1>", "filename": "inv-102.pdf"},
109
+ {"html": "<h1>Invoice #103</h1>", "filename": "inv-103.pdf"},
110
+ ],
111
+ merge_output=True,
112
+ merged_filename="all-invoices.pdf",
113
+ webhook_url="https://your-api.com/webhooks/pdf",
114
+ )
115
+
116
+ completed = tp.wait_for_job(batch["job_id"])
117
+ print("Merged PDF link:", completed["merged_output"]["download_url"])
118
+ ```
119
+
120
+ ---
121
+
122
+ ## 🇪🇺 European Factur-X / ZUGFeRD E-Invoicing
123
+
124
+ ### Generate Compliant Hybrid PDF/A-3
125
+ ```python
126
+ invoice_pdf = tp.generate_einvoice(
127
+ invoice_number="INV-2026-0042",
128
+ currency="EUR",
129
+ seller={"name": "Acme Nordic ApS", "country": "DK", "vat_id": "DK12345678"},
130
+ buyer={"name": "Global Logistics GmbH", "country": "DE", "vat_id": "DE987654321"},
131
+ line_items=[
132
+ {"name": "Consulting Services", "quantity": 10, "unit_price": 150.0, "vat_rate": 25.0}
133
+ ],
134
+ payment_iban="DK001234567890",
135
+ )
136
+ ```
137
+
138
+ ### Validate E-Invoice Compliance (EN 16931)
139
+ ```python
140
+ report = tp.validate_einvoice(pdf=invoice_pdf)
141
+ print("Compliant:", report["is_valid"])
142
+ print("Profile:", report["profile"])
143
+ print("Totals:", report["invoice_details"]["totals"])
144
+ ```
145
+
146
+ ---
147
+
148
+ ## 🛠 Complete PDF Operations
149
+
150
+ ```python
151
+ # 1. Merge multiple PDFs
152
+ merged_pdf = tp.merge_pdfs([doc1_bytes, doc2_bytes])
153
+
154
+ # 2. Extract pages (e.g. pages 1 to 3, and page 5)
155
+ split_pdf = tp.split_pdf(doc_bytes, pages="1-3, 5")
156
+
157
+ # 3. Fill interactive AcroForms
158
+ filled_pdf = tp.fill_pdf_form(doc_bytes, fields={"customer_name": "Alice", "opt_in": True})
159
+
160
+ # 4. Stamp watermark or compliance mark
161
+ stamped_pdf = tp.stamp_pdf(doc_bytes, watermark_text="CONFIDENTIAL", opacity=0.2)
162
+
163
+ # 5. Compress PDF file size
164
+ compressed_pdf = tp.compress_pdf(doc_bytes)
165
+ ```
166
+
167
+ ---
168
+
169
+ ## 🔐 Webhook Signature Verification
170
+
171
+ Verify HMAC-SHA256 signatures in FastAPI, Flask, or Django:
172
+
173
+ ```python
174
+ from fastapi import FastAPI, Request, HTTPException
175
+
176
+ app = FastAPI()
177
+
178
+ @app.post("/webhooks/pdf")
179
+ async def handle_webhook(request: Request):
180
+ payload = await request.body()
181
+ sig_header = request.headers.get("x-tailprint-signature", "")
182
+
183
+ if not tp.verify_webhook_signature(payload, sig_header):
184
+ raise HTTPException(status_code=401, detail="Invalid signature")
185
+
186
+ data = await request.json()
187
+ print("PDF rendered:", data["data"]["download_url"])
188
+ return {"status": "ok"}
189
+ ```
190
+
191
+ ---
192
+
193
+ ## 📄 License
194
+
195
+ MIT © [Tailprint](https://tailprint.com)
@@ -0,0 +1,8 @@
1
+ tailprint/__init__.py,sha256=hA7ByzWVbiWIaODta1m2ZHyKptBK0lRHsV7CLWaRpW0,390
2
+ tailprint/client.py,sha256=xLkgsHqC_gFgINHZV_Ni7oRBZ9109odAgsd9htMFUeg,17648
3
+ tailprint/errors.py,sha256=SeWqbIQiWpIaocRtvAtRXeRxWK6zX-cBs_UFaw-fzvQ,1035
4
+ tailprint/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
5
+ tailprint-1.3.0.dist-info/METADATA,sha256=_tK99WjDxMlb4ASoVPjpjFgWkU4_EDSNMHb1_Gl5uRo,6338
6
+ tailprint-1.3.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
7
+ tailprint-1.3.0.dist-info/top_level.txt,sha256=9scmpv9ZYPn3tuzljvNF82itjYgKrB_htvow786Yg58,10
8
+ tailprint-1.3.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ tailprint