terbium-parse 0.2.0__tar.gz → 0.3.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 (51) hide show
  1. {terbium_parse-0.2.0/src/terbium_parse.egg-info → terbium_parse-0.3.0}/PKG-INFO +11 -1
  2. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/README.md +10 -0
  3. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/pyproject.toml +1 -1
  4. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/__init__.py +5 -3
  5. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/cli.py +15 -0
  6. terbium_parse-0.3.0/src/terbium/extract.py +156 -0
  7. {terbium_parse-0.2.0 → terbium_parse-0.3.0/src/terbium_parse.egg-info}/PKG-INFO +11 -1
  8. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium_parse.egg-info/SOURCES.txt +1 -0
  9. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/tests/test_smoke.py +13 -0
  10. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/LICENSE +0 -0
  11. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/setup.cfg +0 -0
  12. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/api.py +0 -0
  13. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/documents/__init__.py +0 -0
  14. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/documents/base.py +0 -0
  15. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/documents/csv_adapter.py +0 -0
  16. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/documents/pdf.py +0 -0
  17. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/documents/pptx_adapter.py +0 -0
  18. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/documents/xlsx_adapter.py +0 -0
  19. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/harness/__init__.py +0 -0
  20. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/harness/ai.py +0 -0
  21. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/harness/arrange.py +0 -0
  22. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/harness/escalation.py +0 -0
  23. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/harness/providers/__init__.py +0 -0
  24. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/harness/providers/anthropic_provider.py +0 -0
  25. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/harness/providers/base.py +0 -0
  26. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/harness/providers/gemini_provider.py +0 -0
  27. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/harness/router.py +0 -0
  28. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/harness/vision.py +0 -0
  29. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/layout/__init__.py +0 -0
  30. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/layout/columns.py +0 -0
  31. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/layout/confidence.py +0 -0
  32. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/layout/dehead.py +0 -0
  33. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/layout/grid.py +0 -0
  34. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/layout/images.py +0 -0
  35. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/layout/labels.py +0 -0
  36. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/layout/lines.py +0 -0
  37. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/layout/signals.py +0 -0
  38. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/model/__init__.py +0 -0
  39. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/model/document.py +0 -0
  40. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/model/elements.py +0 -0
  41. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/model/record.py +0 -0
  42. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/model/table.py +0 -0
  43. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/py.typed +0 -0
  44. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/schema/__init__.py +0 -0
  45. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/schema/base.py +0 -0
  46. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/schema/furniture.py +0 -0
  47. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium/schema/generic.py +0 -0
  48. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium_parse.egg-info/dependency_links.txt +0 -0
  49. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium_parse.egg-info/entry_points.txt +0 -0
  50. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium_parse.egg-info/requires.txt +0 -0
  51. {terbium_parse-0.2.0 → terbium_parse-0.3.0}/src/terbium_parse.egg-info/top_level.txt +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: terbium-parse
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A god-level algorithmic multi-file parser (PDF/PPTX/XLSX/CSV) that scores its own confidence and only reaches for AI when it is genuinely stuck.
5
5
  Author: anishfyi
6
6
  License: MIT
@@ -101,11 +101,21 @@ doc = terbium.parse("Furniture Catalogue.pdf",
101
101
  ai=terbium.AI(anthropic_key="sk-..."))
102
102
  ```
103
103
 
104
+ Pull out the product images, each named after the product it sits under:
105
+
106
+ ```python
107
+ manifest = terbium.export_images("lookbook.pdf", "out/")
108
+ # out/Kyoto_Bedside_Table.jpeg, out/Meadow_Bedside_Table.jpeg, ...
109
+ # manifest rows carry: product, collection, page, pixel size, colorspace,
110
+ # format, dpi, dominant_color, bbox
111
+ ```
112
+
104
113
  Run it from the shell:
105
114
 
106
115
  ```bash
107
116
  terbium "Furniture Catalogue.pdf" --schema furniture
108
117
  terbium report.xlsx --json out.json
118
+ terbium lookbook.pdf --images out/ # extract product photos + manifest.csv
109
119
  ```
110
120
 
111
121
  ## What it parses
@@ -70,11 +70,21 @@ doc = terbium.parse("Furniture Catalogue.pdf",
70
70
  ai=terbium.AI(anthropic_key="sk-..."))
71
71
  ```
72
72
 
73
+ Pull out the product images, each named after the product it sits under:
74
+
75
+ ```python
76
+ manifest = terbium.export_images("lookbook.pdf", "out/")
77
+ # out/Kyoto_Bedside_Table.jpeg, out/Meadow_Bedside_Table.jpeg, ...
78
+ # manifest rows carry: product, collection, page, pixel size, colorspace,
79
+ # format, dpi, dominant_color, bbox
80
+ ```
81
+
73
82
  Run it from the shell:
74
83
 
75
84
  ```bash
76
85
  terbium "Furniture Catalogue.pdf" --schema furniture
77
86
  terbium report.xlsx --json out.json
87
+ terbium lookbook.pdf --images out/ # extract product photos + manifest.csv
78
88
  ```
79
89
 
80
90
  ## What it parses
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "terbium-parse"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "A god-level algorithmic multi-file parser (PDF/PPTX/XLSX/CSV) that scores its own confidence and only reaches for AI when it is genuinely stuck."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.9"
@@ -10,18 +10,20 @@ confidence and only reaches for AI when it is genuinely stuck.
10
10
  from __future__ import annotations
11
11
 
12
12
  from .api import parse, supported_extensions, DEFAULT_THRESHOLD
13
+ from .extract import export_images
13
14
  from .harness import AI
14
- from .harness.vision import read_page as read_images
15
+ from .harness.vision import read_page as read_vision
15
16
  from .model.document import ParsedDocument, Stats
16
17
  from .model.record import Record
17
18
  from .model.table import ExtractedTable
18
19
 
19
- __version__ = "0.2.0"
20
+ __version__ = "0.3.0"
20
21
 
21
22
  __all__ = [
22
23
  "parse",
24
+ "export_images",
23
25
  "AI",
24
- "read_images",
26
+ "read_vision",
25
27
  "supported_extensions",
26
28
  "ParsedDocument",
27
29
  "Record",
@@ -17,12 +17,27 @@ def main(argv=None) -> int:
17
17
  ap.add_argument("file", help="path to a " + "/".join(supported_extensions()) + " file")
18
18
  ap.add_argument("--schema", default="generic", help="generic (default) or furniture")
19
19
  ap.add_argument("--json", metavar="OUT", help="write records as JSON to this path (or - for stdout)")
20
+ ap.add_argument("--images", metavar="DIR", help="extract product images into DIR (+ a manifest.csv)")
20
21
  ap.add_argument("--ai", action="store_true", help="enable the AI lane using env keys")
21
22
  ap.add_argument("--tier", choices=["haiku", "sonnet", "opus"], help="pin the AI model tier")
22
23
  ap.add_argument("--limit", type=int, default=12, help="how many records to preview")
23
24
  ap.add_argument("--version", action="version", version=f"terbium {__version__}")
24
25
  args = ap.parse_args(argv)
25
26
 
27
+ if args.images:
28
+ from .extract import export_images
29
+ import csv as _csv
30
+
31
+ manifest = export_images(args.file, args.images)
32
+ cols = ["page", "product", "collection", "file", "format", "width_px",
33
+ "height_px", "colorspace", "bytes", "dpi", "dominant_color", "bbox"]
34
+ with open(f"{args.images}/manifest.csv", "w", newline="") as f:
35
+ w = _csv.DictWriter(f, fieldnames=cols)
36
+ w.writeheader()
37
+ w.writerows(manifest)
38
+ print(f"extracted {len(manifest)} images -> {args.images}/ (+ manifest.csv)")
39
+ return 0
40
+
26
41
  ai = AI(force_tier=args.tier) if args.ai else None
27
42
  doc = parse(args.file, schema=args.schema, ai=ai)
28
43
 
@@ -0,0 +1,156 @@
1
+ """Export the actual product images, not just the text.
2
+
3
+ This pulls every embedded photo out of a PDF, and - for lookbooks - names each
4
+ file after the product it sits under. It also reports the qualities that matter
5
+ downstream: pixel size, colorspace, format, position, effective DPI, dominant
6
+ colour, and the associated product name and collection.
7
+
8
+ Bytes come from PyMuPDF (already a terbium dependency), which hands back a
9
+ ready-to-save JPEG/PNG. Repeated images (a logo stamped on every page) are
10
+ detected and skipped so you do not export the same watermark 80 times.
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import io
15
+ import os
16
+ import re
17
+ from collections import defaultdict
18
+ from typing import List, Optional
19
+
20
+ import fitz
21
+
22
+ from .documents.pdf import PdfAdapter
23
+ from .layout import labels as _labels
24
+ from .layout.images import classify
25
+ from .layout.lines import cluster_lines
26
+ from .model.elements import ImageRef, Page
27
+
28
+ _CS = {1: "Gray", 3: "RGB", 4: "CMYK"}
29
+
30
+
31
+ def _safe(name: str) -> str:
32
+ return re.sub(r"[^A-Za-z0-9._-]+", "_", name).strip("_")[:80] or "image"
33
+
34
+
35
+ def _label_positions(page: Page) -> List[dict]:
36
+ """Merged product labels on a page, each with a centre and baseline."""
37
+ cands = []
38
+ for ln in cluster_lines(page.words):
39
+ for seg in _labels._segment_row(ln):
40
+ if _labels._is_label(seg):
41
+ cands.append(seg)
42
+ return _labels._merge_wrapped(cands) if cands else []
43
+
44
+
45
+ def _associate(bbox, items: List[dict]) -> Optional[str]:
46
+ """The label sitting just below an image, horizontally aligned to it."""
47
+ if not bbox or not items:
48
+ return None
49
+ ix0, iy0, ix1, iy1 = bbox
50
+ best, best_dy = None, 1e9
51
+ for it in items:
52
+ if ix0 - 25 <= it["cx"] <= ix1 + 25 and it["y"] > iy1 - 8:
53
+ dy = it["y"] - iy1
54
+ if dy < best_dy and dy < 140:
55
+ best, best_dy = it, dy
56
+ return best["name"] if best else None
57
+
58
+
59
+ def _dominant_color(data: bytes) -> Optional[str]:
60
+ try:
61
+ from PIL import Image
62
+
63
+ with Image.open(io.BytesIO(data)) as im:
64
+ im = im.convert("RGB").resize((16, 16))
65
+ px = list(im.getdata())
66
+ r = sum(p[0] for p in px) // len(px)
67
+ g = sum(p[1] for p in px) // len(px)
68
+ b = sum(p[2] for p in px) // len(px)
69
+ return f"#{r:02x}{g:02x}{b:02x}"
70
+ except Exception:
71
+ return None
72
+
73
+
74
+ def _dpi(width_px: int, bbox) -> Optional[int]:
75
+ if not bbox:
76
+ return None
77
+ width_pt = bbox[2] - bbox[0]
78
+ if width_pt <= 0:
79
+ return None
80
+ return round(width_px / (width_pt / 72.0))
81
+
82
+
83
+ def export_images(
84
+ path: str,
85
+ out_dir: str,
86
+ only_photos: bool = True,
87
+ associate: bool = True,
88
+ dedupe_repeats: bool = True,
89
+ min_side: int = 180,
90
+ max_aspect: float = 4.0,
91
+ ) -> List[dict]:
92
+ """Extract images from ``path`` into ``out_dir``; return a manifest.
93
+
94
+ ``only_photos`` skips icons, swatches, thin banners, and slivers, keeping real
95
+ product photos (shorter side >= ``min_side`` px, aspect ratio <= ``max_aspect``).
96
+ ``associate`` names lookbook photos after the product beneath them.
97
+ ``dedupe_repeats`` drops logos/watermarks that recur across many pages.
98
+ """
99
+ os.makedirs(out_dir, exist_ok=True)
100
+ pages = PdfAdapter().parse(path)
101
+ n = max(1, len(pages))
102
+
103
+ xref_pages = defaultdict(set)
104
+ for p in pages:
105
+ for im in p.images:
106
+ if im.xref:
107
+ xref_pages[im.xref].add(p.index)
108
+ repeated = {x for x, pgs in xref_pages.items() if len(pgs) >= max(3, 0.2 * n)}
109
+
110
+ manifest: List[dict] = []
111
+ used_names: dict = {}
112
+ doc = fitz.open(path)
113
+ try:
114
+ for p in pages:
115
+ items = _label_positions(p) if associate else []
116
+ collection = _labels._pick_collection(items, p) if items else None
117
+ for im in p.images:
118
+ if only_photos:
119
+ if im.kind != "photo":
120
+ continue
121
+ lo, hi = min(im.width, im.height), max(im.width, im.height)
122
+ if lo < min_side or (lo and hi / lo > max_aspect):
123
+ continue # a banner strip or sliver, not a product
124
+ if dedupe_repeats and im.xref in repeated:
125
+ continue
126
+ try:
127
+ d = doc.extract_image(im.xref)
128
+ except Exception:
129
+ continue
130
+ data = d["image"]
131
+ product = _associate(im.bbox, items) if associate else None
132
+
133
+ stem = _safe(product) if product else f"page{p.index + 1:03d}"
134
+ count = used_names.get(stem, 0) + 1
135
+ used_names[stem] = count
136
+ fname = f"{stem}.{d['ext']}" if count == 1 else f"{stem}_{count}.{d['ext']}"
137
+ with open(os.path.join(out_dir, fname), "wb") as f:
138
+ f.write(data)
139
+
140
+ manifest.append({
141
+ "page": p.index + 1,
142
+ "product": product,
143
+ "collection": collection if product else None,
144
+ "file": fname,
145
+ "format": d["ext"],
146
+ "width_px": d["width"],
147
+ "height_px": d["height"],
148
+ "colorspace": _CS.get(d.get("colorspace"), d.get("colorspace")),
149
+ "bytes": len(data),
150
+ "dpi": _dpi(d["width"], im.bbox),
151
+ "dominant_color": _dominant_color(data),
152
+ "bbox": tuple(round(v, 1) for v in im.bbox) if im.bbox else None,
153
+ })
154
+ finally:
155
+ doc.close()
156
+ return manifest
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: terbium-parse
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A god-level algorithmic multi-file parser (PDF/PPTX/XLSX/CSV) that scores its own confidence and only reaches for AI when it is genuinely stuck.
5
5
  Author: anishfyi
6
6
  License: MIT
@@ -101,11 +101,21 @@ doc = terbium.parse("Furniture Catalogue.pdf",
101
101
  ai=terbium.AI(anthropic_key="sk-..."))
102
102
  ```
103
103
 
104
+ Pull out the product images, each named after the product it sits under:
105
+
106
+ ```python
107
+ manifest = terbium.export_images("lookbook.pdf", "out/")
108
+ # out/Kyoto_Bedside_Table.jpeg, out/Meadow_Bedside_Table.jpeg, ...
109
+ # manifest rows carry: product, collection, page, pixel size, colorspace,
110
+ # format, dpi, dominant_color, bbox
111
+ ```
112
+
104
113
  Run it from the shell:
105
114
 
106
115
  ```bash
107
116
  terbium "Furniture Catalogue.pdf" --schema furniture
108
117
  terbium report.xlsx --json out.json
118
+ terbium lookbook.pdf --images out/ # extract product photos + manifest.csv
109
119
  ```
110
120
 
111
121
  ## What it parses
@@ -4,6 +4,7 @@ pyproject.toml
4
4
  src/terbium/__init__.py
5
5
  src/terbium/api.py
6
6
  src/terbium/cli.py
7
+ src/terbium/extract.py
7
8
  src/terbium/py.typed
8
9
  src/terbium/documents/__init__.py
9
10
  src/terbium/documents/base.py
@@ -112,6 +112,19 @@ def test_labels_need_a_grid_not_prose():
112
112
  assert extract_labels(cluster_lines(words), page) is None
113
113
 
114
114
 
115
+ def test_image_label_association():
116
+ # a photo should bind to the label sitting just below it, not a distant one
117
+ from terbium.extract import _associate
118
+
119
+ items = [
120
+ {"name": "Kyoto Bedside Table", "cx": 200, "y": 280, "size": 12},
121
+ {"name": "Far Away", "cx": 900, "y": 280, "size": 12},
122
+ {"name": "Above It", "cx": 200, "y": 20, "size": 12},
123
+ ]
124
+ assert _associate((100, 50, 300, 250), items) == "Kyoto Bedside Table"
125
+ assert _associate((100, 50, 300, 250), []) is None
126
+
127
+
115
128
  def test_csv_roundtrip(tmp_path):
116
129
  import terbium
117
130
 
File without changes
File without changes