Spaces:
Sleeping
Add one-click fictitious sample documents, generated at app startup
Browse files- sample_docs/generate_samples.py builds three watermarked demo PDFs
(construction change order, accounting/tax invoice, bill of lading).
Every page carries a "FICTITIOUS DATA - NOT REAL DATA" banner,
watermark, and footer.
- The PDFs are generated on first launch via ensure_samples() rather
than committed - the Hugging Face Hub rejects binary files outside
LFS storage, and the documents are deterministic anyway. Falls back
to a temp directory if the app dir isn't writable.
- The bill of lading is saved as an image-only scanned page (JPEG
compressed) so it exercises the real Tesseract OCR path with
confidence scores; the other two keep native text layers.
- Step 2 gains sample buttons that load the file AND preset matching
categories/custom fields, so Run works immediately with no setup.
- Hardened extract_custom_fields against OCR'd multi-column forms:
captures that are themselves labels (trailing ':' or '#') are
skipped instead of recorded as values.
- allowed_paths added to launch() so Gradio can serve the generated PDFs.
- .gitignore +4 -1
- README.md +20 -0
- app.py +51 -2
- extractors.py +16 -3
- sample_docs/__init__.py +0 -0
- sample_docs/generate_samples.py +284 -0
|
@@ -2,5 +2,8 @@ __pycache__/
|
|
| 2 |
*.pyc
|
| 3 |
.venv/
|
| 4 |
*.csv
|
| 5 |
-
!sample_data/*.csv
|
| 6 |
.DS_Store
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
*.pyc
|
| 3 |
.venv/
|
| 4 |
*.csv
|
|
|
|
| 5 |
.DS_Store
|
| 6 |
+
|
| 7 |
+
# Generated at app startup - never committed (HF Spaces rejects
|
| 8 |
+
# binary files outside LFS storage)
|
| 9 |
+
sample_docs/*.pdf
|
|
@@ -100,6 +100,26 @@ If the chat call fails or no token is available, the rest of the app
|
|
| 100 |
still works fully via the "Advanced: edit settings manually" accordion
|
| 101 |
in Step 2 — the chat is a convenience layer on top, not a dependency.
|
| 102 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 103 |
## Local development
|
| 104 |
|
| 105 |
```bash
|
|
|
|
| 100 |
still works fully via the "Advanced: edit settings manually" accordion
|
| 101 |
in Step 2 — the chat is a convenience layer on top, not a dependency.
|
| 102 |
|
| 103 |
+
## Sample documents
|
| 104 |
+
|
| 105 |
+
Three fictitious, clearly watermarked demo PDFs are generated on first
|
| 106 |
+
launch (by `sample_docs/generate_samples.py` — they aren't committed,
|
| 107 |
+
since the Hub requires LFS for binaries) and are loadable with one click
|
| 108 |
+
from Step 2 (each button also presets the matching extraction settings,
|
| 109 |
+
so Run works immediately):
|
| 110 |
+
|
| 111 |
+
- **Construction change order** — PO/SAP numbers, cost overrun amounts,
|
| 112 |
+
a safety-violation field note, OSHA cert reference (native text layer).
|
| 113 |
+
- **Accounting / tax invoice** — invoice number, line items, tax rate,
|
| 114 |
+
past-due/credit-hold language (native text layer).
|
| 115 |
+
- **Bill of lading** — saved as an image-only "scanned" page, so it
|
| 116 |
+
exercises the full Tesseract OCR path with confidence scores; custom
|
| 117 |
+
fields pull BOL/reference numbers, weights, and delivery date.
|
| 118 |
+
|
| 119 |
+
Every sample carries a "FICTITIOUS DATA - NOT REAL DATA" banner,
|
| 120 |
+
watermark, and footer. Regenerate them manually with
|
| 121 |
+
`python sample_docs/generate_samples.py`.
|
| 122 |
+
|
| 123 |
## Local development
|
| 124 |
|
| 125 |
```bash
|
|
@@ -41,6 +41,36 @@ METADATA_COLUMNS = [
|
|
| 41 |
"custom_fields", "only_matches_filter", "chat_model", "chat_transcript",
|
| 42 |
]
|
| 43 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
QUICK_PROMPTS = {
|
| 45 |
"Construction & SAP data": (
|
| 46 |
"Flag change orders, delay notices, and safety violations. "
|
|
@@ -96,7 +126,12 @@ CUSTOM_CSS = """
|
|
| 96 |
.step-kicker { font-weight: 600; font-size: 1.15rem; margin-bottom: 2px; }
|
| 97 |
.step-subtitle { color: var(--body-text-color-subdued); margin-bottom: 14px; font-size: 0.92rem; }
|
| 98 |
#run-button button { font-size: 1.02rem !important; padding: 14px !important; font-weight: 600 !important; }
|
| 99 |
-
#quick-prompts button { font-size: 0.85rem !important; }
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 100 |
"""
|
| 101 |
|
| 102 |
PIPELINE_HTML = """
|
|
@@ -306,6 +341,14 @@ with gr.Blocks(title="Document Intelligence — OCR & Structured Data Extraction
|
|
| 306 |
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"],
|
| 307 |
file_count="multiple",
|
| 308 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 309 |
with gr.Accordion("Advanced: edit settings manually", open=False):
|
| 310 |
categories_input = gr.CheckboxGroup(
|
| 311 |
choices=CATEGORY_CHOICES,
|
|
@@ -355,6 +398,12 @@ with gr.Blocks(title="Document Intelligence — OCR & Structured Data Extraction
|
|
| 355 |
fn=handle_chat, inputs=[chatbot, chat_input, settings_state, hf_token_input], outputs=chat_outputs
|
| 356 |
)
|
| 357 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 358 |
run_button.click(
|
| 359 |
fn=process_files,
|
| 360 |
inputs=[files_input, categories_input, custom_keywords_input, custom_fields_input, only_matches_input, chatbot],
|
|
@@ -362,4 +411,4 @@ with gr.Blocks(title="Document Intelligence — OCR & Structured Data Extraction
|
|
| 362 |
)
|
| 363 |
|
| 364 |
if __name__ == "__main__":
|
| 365 |
-
demo.launch()
|
|
|
|
| 41 |
"custom_fields", "only_matches_filter", "chat_model", "chat_transcript",
|
| 42 |
]
|
| 43 |
|
| 44 |
+
from sample_docs.generate_samples import ensure_samples
|
| 45 |
+
|
| 46 |
+
# The demo PDFs are generated on first launch rather than shipped in git
|
| 47 |
+
# (Hugging Face rejects binary files outside LFS storage).
|
| 48 |
+
SAMPLE_DIR = ensure_samples()
|
| 49 |
+
|
| 50 |
+
# One-click demo documents (all fictitious, clearly watermarked). Each button
|
| 51 |
+
# loads the file AND presets the matching extraction settings so "Run" works
|
| 52 |
+
# immediately.
|
| 53 |
+
SAMPLE_DOCS = {
|
| 54 |
+
"Construction change order": {
|
| 55 |
+
"file": os.path.join(SAMPLE_DIR, "sample_construction_change_order.pdf"),
|
| 56 |
+
"categories": ["construction", "compliance", "general"],
|
| 57 |
+
"keywords": "",
|
| 58 |
+
"fields": "contract ref",
|
| 59 |
+
},
|
| 60 |
+
"Accounting / tax invoice": {
|
| 61 |
+
"file": os.path.join(SAMPLE_DIR, "sample_accounting_tax_invoice.pdf"),
|
| 62 |
+
"categories": ["finance"],
|
| 63 |
+
"keywords": "",
|
| 64 |
+
"fields": "tax year",
|
| 65 |
+
},
|
| 66 |
+
"Bill of lading (scanned)": {
|
| 67 |
+
"file": os.path.join(SAMPLE_DIR, "sample_bill_of_lading.pdf"),
|
| 68 |
+
"categories": ["construction", "general"],
|
| 69 |
+
"keywords": "",
|
| 70 |
+
"fields": "bol number, reference number, gross weight, net weight, delivery date, carrier",
|
| 71 |
+
},
|
| 72 |
+
}
|
| 73 |
+
|
| 74 |
QUICK_PROMPTS = {
|
| 75 |
"Construction & SAP data": (
|
| 76 |
"Flag change orders, delay notices, and safety violations. "
|
|
|
|
| 126 |
.step-kicker { font-weight: 600; font-size: 1.15rem; margin-bottom: 2px; }
|
| 127 |
.step-subtitle { color: var(--body-text-color-subdued); margin-bottom: 14px; font-size: 0.92rem; }
|
| 128 |
#run-button button { font-size: 1.02rem !important; padding: 14px !important; font-weight: 600 !important; }
|
| 129 |
+
#quick-prompts button, #sample-docs button { font-size: 0.85rem !important; }
|
| 130 |
+
.sample-note {
|
| 131 |
+
font-size: 0.8rem; color: var(--body-text-color-subdued);
|
| 132 |
+
margin: 10px 0 6px 0;
|
| 133 |
+
}
|
| 134 |
+
.sample-note strong { color: var(--body-text-color); }
|
| 135 |
"""
|
| 136 |
|
| 137 |
PIPELINE_HTML = """
|
|
|
|
| 341 |
file_types=[".pdf", ".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"],
|
| 342 |
file_count="multiple",
|
| 343 |
)
|
| 344 |
+
gr.Markdown(
|
| 345 |
+
'<div class="sample-note"><strong>No documents handy?</strong> '
|
| 346 |
+
"Load a sample below — it fills in matching extraction settings "
|
| 347 |
+
"too, so you can go straight to Run. All samples are fictitious "
|
| 348 |
+
"and watermarked as such.</div>"
|
| 349 |
+
)
|
| 350 |
+
with gr.Row(elem_id="sample-docs"):
|
| 351 |
+
sample_buttons = {label: gr.Button(label, size="sm") for label in SAMPLE_DOCS}
|
| 352 |
with gr.Accordion("Advanced: edit settings manually", open=False):
|
| 353 |
categories_input = gr.CheckboxGroup(
|
| 354 |
choices=CATEGORY_CHOICES,
|
|
|
|
| 398 |
fn=handle_chat, inputs=[chatbot, chat_input, settings_state, hf_token_input], outputs=chat_outputs
|
| 399 |
)
|
| 400 |
|
| 401 |
+
for label, cfg in SAMPLE_DOCS.items():
|
| 402 |
+
sample_buttons[label].click(
|
| 403 |
+
fn=lambda c=cfg: ([c["file"]], c["categories"], c["keywords"], c["fields"]),
|
| 404 |
+
outputs=[files_input, categories_input, custom_keywords_input, custom_fields_input],
|
| 405 |
+
)
|
| 406 |
+
|
| 407 |
run_button.click(
|
| 408 |
fn=process_files,
|
| 409 |
inputs=[files_input, categories_input, custom_keywords_input, custom_fields_input, only_matches_input, chatbot],
|
|
|
|
| 411 |
)
|
| 412 |
|
| 413 |
if __name__ == "__main__":
|
| 414 |
+
demo.launch(allowed_paths=[SAMPLE_DIR])
|
|
@@ -132,13 +132,25 @@ def _postprocess_custom_value(label, raw_value):
|
|
| 132 |
return m.group(0).strip() if m else trimmed.strip()
|
| 133 |
|
| 134 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 135 |
def extract_custom_fields(text, field_labels):
|
| 136 |
"""Generic label->value extraction for user-defined fields.
|
| 137 |
|
| 138 |
For labels like "reference number" or "gross weight", finds the label in
|
| 139 |
the text and captures whatever follows it on the same line (or the next
|
| 140 |
line, if the label sits alone on its own line - common in OCR'd forms).
|
| 141 |
-
|
|
|
|
| 142 |
"""
|
| 143 |
results = {label: [] for label in field_labels if label.strip()}
|
| 144 |
if not results:
|
|
@@ -155,8 +167,9 @@ def extract_custom_fields(text, field_labels):
|
|
| 155 |
after = line[idx + len(label_clean):]
|
| 156 |
after = re.sub(r"^[\s:.\-–—]+", "", after).strip()
|
| 157 |
if not after and i + 1 < len(lines):
|
| 158 |
-
|
| 159 |
-
|
|
|
|
| 160 |
value = _postprocess_custom_value(label_clean, after)
|
| 161 |
if value and value not in results[label]:
|
| 162 |
results[label].append(value)
|
|
|
|
| 132 |
return m.group(0).strip() if m else trimmed.strip()
|
| 133 |
|
| 134 |
|
| 135 |
+
def _looks_like_label(s):
|
| 136 |
+
"""True for strings that are another form label rather than a value.
|
| 137 |
+
|
| 138 |
+
OCR on multi-column forms often emits a run of labels ("BOL Number:",
|
| 139 |
+
"Reference Number:", "PO#") followed later by the run of values, so a
|
| 140 |
+
naive "take the next thing" capture would swallow a neighboring label.
|
| 141 |
+
"""
|
| 142 |
+
s = s.strip()
|
| 143 |
+
return not s or s.endswith(":") or s.endswith("#")
|
| 144 |
+
|
| 145 |
+
|
| 146 |
def extract_custom_fields(text, field_labels):
|
| 147 |
"""Generic label->value extraction for user-defined fields.
|
| 148 |
|
| 149 |
For labels like "reference number" or "gross weight", finds the label in
|
| 150 |
the text and captures whatever follows it on the same line (or the next
|
| 151 |
line, if the label sits alone on its own line - common in OCR'd forms).
|
| 152 |
+
Label-like captures (another "Foo:" heading) are skipped rather than
|
| 153 |
+
recorded as values. Returns {label: [values]}.
|
| 154 |
"""
|
| 155 |
results = {label: [] for label in field_labels if label.strip()}
|
| 156 |
if not results:
|
|
|
|
| 167 |
after = line[idx + len(label_clean):]
|
| 168 |
after = re.sub(r"^[\s:.\-–—]+", "", after).strip()
|
| 169 |
if not after and i + 1 < len(lines):
|
| 170 |
+
candidate = lines[i + 1].strip()
|
| 171 |
+
after = "" if _looks_like_label(candidate) else candidate
|
| 172 |
+
if after and not _looks_like_label(after):
|
| 173 |
value = _postprocess_custom_value(label_clean, after)
|
| 174 |
if value and value not in results[label]:
|
| 175 |
results[label].append(value)
|
|
File without changes
|
|
@@ -0,0 +1,284 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate the fictitious sample PDFs bundled with the app.
|
| 2 |
+
|
| 3 |
+
Run from the repo root (or this directory): python sample_docs/generate_samples.py
|
| 4 |
+
|
| 5 |
+
Every document carries a visible "SAMPLE - FICTITIOUS DATA" banner and
|
| 6 |
+
footer. All company names, numbers, and amounts are invented. The bill
|
| 7 |
+
of lading is re-rendered as an image-only page so it exercises the
|
| 8 |
+
Tesseract OCR path; the other two keep their native text layer.
|
| 9 |
+
"""
|
| 10 |
+
import os
|
| 11 |
+
|
| 12 |
+
import fitz
|
| 13 |
+
|
| 14 |
+
OUT_DIR = os.path.dirname(os.path.abspath(__file__))
|
| 15 |
+
|
| 16 |
+
PAGE_W, PAGE_H = 612, 792 # US Letter
|
| 17 |
+
MARGIN = 54
|
| 18 |
+
|
| 19 |
+
INK = (0.13, 0.16, 0.22) # charcoal
|
| 20 |
+
SUBTLE = (0.45, 0.50, 0.58) # slate
|
| 21 |
+
ACCENT = (0.16, 0.35, 0.68) # deep blue
|
| 22 |
+
BANNER_BG = (0.98, 0.93, 0.93)
|
| 23 |
+
BANNER_INK = (0.65, 0.15, 0.15)
|
| 24 |
+
RULE = (0.85, 0.87, 0.90)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
class Doc:
|
| 28 |
+
"""Tiny layout helper: a cursor-based text flow over one page."""
|
| 29 |
+
|
| 30 |
+
def __init__(self):
|
| 31 |
+
self.pdf = fitz.open()
|
| 32 |
+
self.page = self.pdf.new_page(width=PAGE_W, height=PAGE_H)
|
| 33 |
+
self.y = MARGIN
|
| 34 |
+
|
| 35 |
+
def banner(self):
|
| 36 |
+
rect = fitz.Rect(0, 0, PAGE_W, 26)
|
| 37 |
+
self.page.draw_rect(rect, color=None, fill=BANNER_BG)
|
| 38 |
+
self.page.insert_text(
|
| 39 |
+
(MARGIN, 17),
|
| 40 |
+
"SAMPLE DOCUMENT - FICTITIOUS DATA FOR DEMONSTRATION ONLY - NOT REAL DATA",
|
| 41 |
+
fontsize=8.5, fontname="hebo", color=BANNER_INK,
|
| 42 |
+
)
|
| 43 |
+
self.y = 26 + 28
|
| 44 |
+
|
| 45 |
+
def header(self, company, doc_title):
|
| 46 |
+
self.page.insert_text((MARGIN, self.y), company, fontsize=16, fontname="hebo", color=INK)
|
| 47 |
+
self.y += 20
|
| 48 |
+
self.page.insert_text((MARGIN, self.y), doc_title, fontsize=11, fontname="helv", color=ACCENT)
|
| 49 |
+
self.y += 12
|
| 50 |
+
self.page.draw_line((MARGIN, self.y), (PAGE_W - MARGIN, self.y), color=RULE, width=1)
|
| 51 |
+
self.y += 22
|
| 52 |
+
|
| 53 |
+
def kv(self, label, value, x=MARGIN, label_w=150):
|
| 54 |
+
self.page.insert_text((x, self.y), label, fontsize=9.5, fontname="hebo", color=SUBTLE)
|
| 55 |
+
self.page.insert_text((x + label_w, self.y), value, fontsize=9.5, fontname="helv", color=INK)
|
| 56 |
+
self.y += 16
|
| 57 |
+
|
| 58 |
+
def kv_pair_row(self, l1, v1, l2, v2):
|
| 59 |
+
mid = PAGE_W / 2 + 10
|
| 60 |
+
self.page.insert_text((MARGIN, self.y), l1, fontsize=9.5, fontname="hebo", color=SUBTLE)
|
| 61 |
+
self.page.insert_text((MARGIN + 120, self.y), v1, fontsize=9.5, fontname="helv", color=INK)
|
| 62 |
+
self.page.insert_text((mid, self.y), l2, fontsize=9.5, fontname="hebo", color=SUBTLE)
|
| 63 |
+
self.page.insert_text((mid + 110, self.y), v2, fontsize=9.5, fontname="helv", color=INK)
|
| 64 |
+
self.y += 16
|
| 65 |
+
|
| 66 |
+
def section(self, title):
|
| 67 |
+
self.y += 10
|
| 68 |
+
self.page.insert_text((MARGIN, self.y), title.upper(), fontsize=9, fontname="hebo", color=ACCENT)
|
| 69 |
+
self.y += 6
|
| 70 |
+
self.page.draw_line((MARGIN, self.y), (PAGE_W - MARGIN, self.y), color=RULE, width=0.8)
|
| 71 |
+
self.y += 16
|
| 72 |
+
|
| 73 |
+
def para(self, text, size=9.5):
|
| 74 |
+
rect = fitz.Rect(MARGIN, self.y - 10, PAGE_W - MARGIN, self.y + 90)
|
| 75 |
+
used = self.page.insert_textbox(rect, text, fontsize=size, fontname="helv", color=INK, lineheight=1.35)
|
| 76 |
+
self.y += (90 - used) + 6 if used >= 0 else 60
|
| 77 |
+
|
| 78 |
+
def table(self, headers, rows, col_xs):
|
| 79 |
+
for x, h in zip(col_xs, headers):
|
| 80 |
+
self.page.insert_text((x, self.y), h, fontsize=8.5, fontname="hebo", color=SUBTLE)
|
| 81 |
+
self.y += 5
|
| 82 |
+
self.page.draw_line((MARGIN, self.y), (PAGE_W - MARGIN, self.y), color=RULE, width=0.8)
|
| 83 |
+
self.y += 13
|
| 84 |
+
for row in rows:
|
| 85 |
+
for x, cell in zip(col_xs, row):
|
| 86 |
+
self.page.insert_text((x, self.y), cell, fontsize=9, fontname="helv", color=INK)
|
| 87 |
+
self.y += 15
|
| 88 |
+
self.y += 4
|
| 89 |
+
|
| 90 |
+
def watermark(self):
|
| 91 |
+
self.page.insert_text(
|
| 92 |
+
(PAGE_W / 2 - 150, PAGE_H / 2), "SAMPLE - NOT REAL DATA",
|
| 93 |
+
fontsize=24, fontname="hebo", color=(0.88, 0.88, 0.90), fill_opacity=0.8,
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
def footer(self):
|
| 97 |
+
self.page.draw_line((MARGIN, PAGE_H - 46), (PAGE_W - MARGIN, PAGE_H - 46), color=RULE, width=0.8)
|
| 98 |
+
self.page.insert_text(
|
| 99 |
+
(MARGIN, PAGE_H - 32),
|
| 100 |
+
"This sample document was generated for product demonstration. All names, numbers,",
|
| 101 |
+
fontsize=7.5, fontname="helv", color=SUBTLE,
|
| 102 |
+
)
|
| 103 |
+
self.page.insert_text(
|
| 104 |
+
(MARGIN, PAGE_H - 22),
|
| 105 |
+
"and amounts are fictitious. Any resemblance to real entities is coincidental.",
|
| 106 |
+
fontsize=7.5, fontname="helv", color=SUBTLE,
|
| 107 |
+
)
|
| 108 |
+
|
| 109 |
+
def save(self, name, as_scanned=False, dpi=170):
|
| 110 |
+
self.footer()
|
| 111 |
+
path = os.path.join(OUT_DIR, name)
|
| 112 |
+
if as_scanned:
|
| 113 |
+
# JPEG keeps the "scanned" page under ~300 KB; a raw PNG pixmap
|
| 114 |
+
# embed produces a multi-megabyte file.
|
| 115 |
+
pix = self.page.get_pixmap(dpi=dpi)
|
| 116 |
+
jpeg_bytes = pix.tobytes("jpeg", jpg_quality=80)
|
| 117 |
+
scanned = fitz.open()
|
| 118 |
+
page = scanned.new_page(width=PAGE_W, height=PAGE_H)
|
| 119 |
+
page.insert_image(fitz.Rect(0, 0, PAGE_W, PAGE_H), stream=jpeg_bytes)
|
| 120 |
+
scanned.save(path)
|
| 121 |
+
scanned.close()
|
| 122 |
+
else:
|
| 123 |
+
self.pdf.save(path)
|
| 124 |
+
self.pdf.close()
|
| 125 |
+
return path
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
def construction_change_order():
|
| 129 |
+
d = Doc()
|
| 130 |
+
d.banner()
|
| 131 |
+
d.header("MERIDIAN BUILD GROUP (FICTITIOUS)", "Change Order Notice - CO-2026-014")
|
| 132 |
+
d.kv_pair_row("Project:", "Falcon Ridge Distribution Ctr", "Date:", "06/12/2026")
|
| 133 |
+
d.kv_pair_row("PO#", "4500187266", "SAP Document:", "4500187266")
|
| 134 |
+
d.kv_pair_row("Contractor:", "Ironline Steel Erectors", "Contract Ref:", "C-2025-0092")
|
| 135 |
+
|
| 136 |
+
d.section("Description of Change")
|
| 137 |
+
d.para(
|
| 138 |
+
"This change order documents a schedule slip of 9 working days on the "
|
| 139 |
+
"Phase 2 structural package, driven by a material shortage of steel bar "
|
| 140 |
+
"joists at the fabricator. The revised sequence results in a cost overrun "
|
| 141 |
+
"of $48,750.00 against the structural steel budget line."
|
| 142 |
+
)
|
| 143 |
+
|
| 144 |
+
d.section("Field Notes")
|
| 145 |
+
d.para(
|
| 146 |
+
"A safety violation was recorded on 06/10/2026 during joist staging "
|
| 147 |
+
"(improper fall protection at the leading edge). A corrective action "
|
| 148 |
+
"plan is required from the subcontractor prior to remobilization. "
|
| 149 |
+
"Reference OSHA 1926.501 requirements."
|
| 150 |
+
)
|
| 151 |
+
|
| 152 |
+
d.section("Cost Summary")
|
| 153 |
+
d.table(
|
| 154 |
+
["Item", "Description", "Amount"],
|
| 155 |
+
[
|
| 156 |
+
["CO-014.1", "Extended general conditions (9 days)", "$21,600.00"],
|
| 157 |
+
["CO-014.2", "Joist re-sequencing and crane standby", "$18,900.00"],
|
| 158 |
+
["CO-014.3", "Expedited freight on replacement joists", "$8,250.00"],
|
| 159 |
+
["", "Total this change order", "$48,750.00"],
|
| 160 |
+
],
|
| 161 |
+
[MARGIN, MARGIN + 70, PAGE_W - MARGIN - 90],
|
| 162 |
+
)
|
| 163 |
+
|
| 164 |
+
d.kv("Status:", "Pending approval")
|
| 165 |
+
d.watermark()
|
| 166 |
+
return d.save("sample_construction_change_order.pdf")
|
| 167 |
+
|
| 168 |
+
|
| 169 |
+
def accounting_tax_invoice():
|
| 170 |
+
d = Doc()
|
| 171 |
+
d.banner()
|
| 172 |
+
d.header("NORTHPORT ACCOUNTING SERVICES (FICTITIOUS)", "Client Invoice & Tax Statement")
|
| 173 |
+
d.kv_pair_row("Invoice #", "INV-20461", "Invoice Date:", "04/02/2026")
|
| 174 |
+
d.kv_pair_row("Client:", "Harbor & Vine LLC", "Due Date:", "05/02/2026")
|
| 175 |
+
d.kv_pair_row("Tax Year:", "2025", "Terms:", "Net 30")
|
| 176 |
+
|
| 177 |
+
d.section("Services")
|
| 178 |
+
d.table(
|
| 179 |
+
["Code", "Description", "Amount"],
|
| 180 |
+
[
|
| 181 |
+
["TAX-1120S", "Federal S-corp return preparation (2025)", "$6,800.00"],
|
| 182 |
+
["TAX-ST", "State franchise & sales tax filings (4 qtrs)", "$3,200.00"],
|
| 183 |
+
["ADV-Q4", "Quarterly advisory - Q4 close support", "$2,400.00"],
|
| 184 |
+
],
|
| 185 |
+
[MARGIN, MARGIN + 70, PAGE_W - MARGIN - 90],
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
d.section("Totals")
|
| 189 |
+
d.kv("Subtotal:", "$12,400.00", label_w=120)
|
| 190 |
+
d.kv("Sales Tax (8.25%):", "$1,023.00", label_w=120)
|
| 191 |
+
d.kv("Total Due:", "$13,423.00", label_w=120)
|
| 192 |
+
|
| 193 |
+
d.section("Account Status")
|
| 194 |
+
d.para(
|
| 195 |
+
"This invoice is PAST DUE as of 06/02/2026. A late fee of 1.5% per "
|
| 196 |
+
"month applies to the outstanding balance. The account has been "
|
| 197 |
+
"placed on credit hold; new engagements are suspended until the "
|
| 198 |
+
"balance is settled or a payment plan is approved."
|
| 199 |
+
)
|
| 200 |
+
d.watermark()
|
| 201 |
+
return d.save("sample_accounting_tax_invoice.pdf")
|
| 202 |
+
|
| 203 |
+
|
| 204 |
+
def bill_of_lading():
|
| 205 |
+
d = Doc()
|
| 206 |
+
d.banner()
|
| 207 |
+
d.header("BLUEWATER FREIGHT LINES (FICTITIOUS)", "Straight Bill of Lading - Not Negotiable")
|
| 208 |
+
# Single-column rows: this page ships as a scanned image, and OCR reads
|
| 209 |
+
# wide two-column layouts as separate label/value blocks.
|
| 210 |
+
d.kv("BOL Number:", "BOL-88213", label_w=130)
|
| 211 |
+
d.kv("Reference Number:", "REF-102455", label_w=130)
|
| 212 |
+
d.kv("PO#", "4600221944", label_w=130)
|
| 213 |
+
d.kv("Ship Date:", "07/11/2026", label_w=130)
|
| 214 |
+
d.kv("Delivery Date:", "07/18/2026", label_w=130)
|
| 215 |
+
d.kv("Freight Terms:", "Prepaid", label_w=130)
|
| 216 |
+
|
| 217 |
+
d.section("Shipper / Consignee")
|
| 218 |
+
d.kv("Shipper:", "Cascade Fixture Works, 1200 Foundry Rd, Portsville")
|
| 219 |
+
d.kv("Consignee:", "Falcon Ridge Distribution Ctr, Dock 14, Meridian")
|
| 220 |
+
d.kv("Carrier:", "Bluewater Freight Lines, SCAC: BWFL")
|
| 221 |
+
|
| 222 |
+
d.section("Freight Description")
|
| 223 |
+
d.table(
|
| 224 |
+
["Units", "Package", "Description of Articles", "Class"],
|
| 225 |
+
[
|
| 226 |
+
["12", "Pallets", "Steel shelving components, banded", "70"],
|
| 227 |
+
["4", "Crates", "Conveyor drive assemblies", "85"],
|
| 228 |
+
],
|
| 229 |
+
[MARGIN, MARGIN + 55, MARGIN + 130, PAGE_W - MARGIN - 60],
|
| 230 |
+
)
|
| 231 |
+
|
| 232 |
+
d.section("Weights")
|
| 233 |
+
d.kv("Gross Weight:", "18,420 lbs", label_w=110)
|
| 234 |
+
d.kv("Net Weight:", "17,660 lbs", label_w=110)
|
| 235 |
+
d.kv("Declared Value:", "$92,500.00", label_w=110)
|
| 236 |
+
|
| 237 |
+
d.para(
|
| 238 |
+
"Received, subject to the classifications and tariffs in effect on the "
|
| 239 |
+
"date of issue. Shipper certifies the above materials are properly "
|
| 240 |
+
"classified, described, packaged, marked and labeled."
|
| 241 |
+
)
|
| 242 |
+
d.watermark()
|
| 243 |
+
# Saved as an image-only page so it exercises the OCR path.
|
| 244 |
+
return d.save("sample_bill_of_lading.pdf", as_scanned=True)
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
GENERATORS = {
|
| 248 |
+
"sample_construction_change_order.pdf": construction_change_order,
|
| 249 |
+
"sample_accounting_tax_invoice.pdf": accounting_tax_invoice,
|
| 250 |
+
"sample_bill_of_lading.pdf": bill_of_lading,
|
| 251 |
+
}
|
| 252 |
+
|
| 253 |
+
|
| 254 |
+
def ensure_samples():
|
| 255 |
+
"""Generate any missing sample PDFs; return the directory they live in.
|
| 256 |
+
|
| 257 |
+
The PDFs are not committed to git (Hugging Face rejects binary files
|
| 258 |
+
outside LFS), so the Space builds them on first launch. Falls back to a
|
| 259 |
+
temp directory if the app directory isn't writable.
|
| 260 |
+
"""
|
| 261 |
+
global OUT_DIR
|
| 262 |
+
import tempfile
|
| 263 |
+
|
| 264 |
+
missing = [n for n in GENERATORS if not os.path.exists(os.path.join(OUT_DIR, n))]
|
| 265 |
+
if not missing:
|
| 266 |
+
return OUT_DIR
|
| 267 |
+
try:
|
| 268 |
+
os.makedirs(OUT_DIR, exist_ok=True)
|
| 269 |
+
with open(os.path.join(OUT_DIR, ".write_test"), "w") as f:
|
| 270 |
+
f.write("ok")
|
| 271 |
+
os.remove(os.path.join(OUT_DIR, ".write_test"))
|
| 272 |
+
except OSError:
|
| 273 |
+
OUT_DIR = os.path.join(tempfile.gettempdir(), "sample_docs")
|
| 274 |
+
os.makedirs(OUT_DIR, exist_ok=True)
|
| 275 |
+
missing = [n for n in GENERATORS if not os.path.exists(os.path.join(OUT_DIR, n))]
|
| 276 |
+
|
| 277 |
+
for name in missing:
|
| 278 |
+
GENERATORS[name]()
|
| 279 |
+
return OUT_DIR
|
| 280 |
+
|
| 281 |
+
|
| 282 |
+
if __name__ == "__main__":
|
| 283 |
+
for fn in GENERATORS.values():
|
| 284 |
+
print("wrote", fn())
|