Claude commited on
Commit
3a6d22d
·
unverified ·
1 Parent(s): bb76929

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.

Files changed (6) hide show
  1. .gitignore +4 -1
  2. README.md +20 -0
  3. app.py +51 -2
  4. extractors.py +16 -3
  5. sample_docs/__init__.py +0 -0
  6. sample_docs/generate_samples.py +284 -0
.gitignore CHANGED
@@ -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
README.md CHANGED
@@ -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
app.py CHANGED
@@ -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])
extractors.py CHANGED
@@ -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
- Returns {label: [values]}.
 
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
- after = lines[i + 1].strip()
159
- if after:
 
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)
sample_docs/__init__.py ADDED
File without changes
sample_docs/generate_samples.py ADDED
@@ -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())