File size: 10,677 Bytes
cfe45d5
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
"""
Document extraction service – extracts text (as markdown) and images from
uploaded documents (PDF, DOCX, PPTX).

- PDF:  PyMuPDF + PyMuPDF4LLM (markdown with structure preserved)
- DOCX: python-docx (paragraphs + embedded images)
- PPTX: python-pptx (slide text + embedded images)
"""

import os
import hashlib
import tempfile

import fitz  # PyMuPDF
import pymupdf4llm
from docx import Document as DocxDocument
from pptx import Presentation
from fastapi import UploadFile
from sqlalchemy.orm import Session

from app.config import settings
from app.models.project import Project, ProjectStatus
from app.models.asset import Asset, AssetType
from app.services import r2_storage

# Minimum image bytes to keep (skip tiny icons / decorations)
_MIN_IMAGE_BYTES = 5_000  # 5 KB

# Recognised file extensions -> handler key
_EXT_MAP = {
    ".pdf": "pdf",
    ".docx": "docx",
    ".pptx": "pptx",
}


def extract_from_documents(
    project: Project,
    files: list[UploadFile],
    db: Session,
) -> Project:
    """
    Extract text and images from uploaded documents.

    - Concatenates all extracted text into ``project.blog_content``
    - Saves extracted images locally and uploads to R2
    - Sets ``project.status = SCRAPED``
    """
    all_markdown: list[str] = []
    image_dir = os.path.join(settings.MEDIA_DIR, f"projects/{project.id}/images")
    os.makedirs(image_dir, exist_ok=True)

    image_count = 0

    for upload_file in files:
        filename = upload_file.filename or "document"
        ext = os.path.splitext(filename)[1].lower()
        handler = _EXT_MAP.get(ext, "pdf")  # default to PDF

        # Save upload to a temp file
        with tempfile.NamedTemporaryFile(delete=False, suffix=ext or ".pdf") as tmp:
            content = upload_file.file.read()
            tmp.write(content)
            tmp_path = tmp.name

        try:
            if handler == "pdf":
                md, imgs = _extract_pdf(tmp_path, image_dir)
            elif handler == "docx":
                md, imgs = _extract_docx(tmp_path, image_dir)
            elif handler == "pptx":
                md, imgs = _extract_pptx(tmp_path, image_dir)
            else:
                md, imgs = "", []

            if md and md.strip():
                all_markdown.append(md)

            # Create Asset records for extracted images
            for img_path, img_filename in imgs:
                r2_key = None
                r2_url = None
                if r2_storage.is_r2_configured():
                    try:
                        r2_url = r2_storage.upload_project_image(
                            project.user_id, project.id, img_path, img_filename
                        )
                        r2_key = r2_storage.image_key(
                            project.user_id, project.id, img_filename
                        )
                    except Exception as e:
                        print(f"[DOC_EXTRACTOR] R2 upload failed for {img_filename}: {e}")

                asset = Asset(
                    project_id=project.id,
                    asset_type=AssetType.IMAGE,
                    original_url=None,
                    local_path=img_path,
                    filename=img_filename,
                    r2_key=r2_key,
                    r2_url=r2_url,
                )
                db.add(asset)
                image_count += 1

        finally:
            try:
                os.unlink(tmp_path)
            except OSError:
                pass

    # ── Persist results ───────────────────────────────────────
    project.blog_content = "\n\n---\n\n".join(all_markdown) if all_markdown else ""
    project.status = ProjectStatus.SCRAPED
    db.commit()
    db.refresh(project)

    print(
        f"[DOC_EXTRACTOR] Project {project.id}: extracted "
        f"{len(all_markdown)} document(s), {image_count} images, "
        f"{len(project.blog_content)} chars of markdown"
    )

    return project


# ─── PDF extraction ──────────────────────────────────────────


def _extract_pdf(
    file_path: str, image_dir: str
) -> tuple[str, list[tuple[str, str]]]:
    """Return (markdown_text, [(local_path, filename), ...])."""
    images: list[tuple[str, str]] = []

    # Markdown text via pymupdf4llm
    md_text = pymupdf4llm.to_markdown(file_path)

    # Images via PyMuPDF
    doc = fitz.open(file_path)
    for page_num in range(len(doc)):
        page = doc[page_num]
        image_list = page.get_images(full=True)

        for img_info in image_list:
            xref = img_info[0]
            try:
                base_image = doc.extract_image(xref)
            except Exception:
                continue

            if not base_image or not base_image.get("image"):
                continue

            image_bytes = base_image["image"]
            if len(image_bytes) < _MIN_IMAGE_BYTES:
                continue

            ext = base_image.get("ext", "png")
            if ext not in ("png", "jpg", "jpeg", "webp"):
                ext = "png"

            img_hash = hashlib.md5(image_bytes).hexdigest()[:10]
            filename = f"pdf_p{page_num + 1}_{img_hash}.{ext}"
            local_path = os.path.join(image_dir, filename)

            if os.path.exists(local_path):
                continue

            with open(local_path, "wb") as f:
                f.write(image_bytes)

            images.append((local_path, filename))

    doc.close()
    return md_text or "", images


# ─── DOCX extraction ─────────────────────────────────────────


def _extract_docx(
    file_path: str, image_dir: str
) -> tuple[str, list[tuple[str, str]]]:
    """Return (markdown_text, [(local_path, filename), ...])."""
    images: list[tuple[str, str]] = []
    lines: list[str] = []

    doc = DocxDocument(file_path)

    # Extract text β€” convert paragraphs to simple markdown
    for para in doc.paragraphs:
        text = para.text.strip()
        if not text:
            lines.append("")
            continue

        style_name = (para.style.name or "").lower()
        if "heading 1" in style_name:
            lines.append(f"# {text}")
        elif "heading 2" in style_name:
            lines.append(f"## {text}")
        elif "heading 3" in style_name:
            lines.append(f"### {text}")
        elif "heading" in style_name:
            lines.append(f"#### {text}")
        elif "list" in style_name:
            lines.append(f"- {text}")
        else:
            lines.append(text)

    md_text = "\n\n".join(lines)

    # Extract embedded images from the docx relationships
    for rel in doc.part.rels.values():
        if "image" in rel.reltype:
            try:
                image_bytes = rel.target_part.blob
                if len(image_bytes) < _MIN_IMAGE_BYTES:
                    continue

                content_type = rel.target_part.content_type or ""
                ext = _mime_to_ext(content_type)

                img_hash = hashlib.md5(image_bytes).hexdigest()[:10]
                filename = f"docx_{img_hash}.{ext}"
                local_path = os.path.join(image_dir, filename)

                if os.path.exists(local_path):
                    continue

                with open(local_path, "wb") as f:
                    f.write(image_bytes)

                images.append((local_path, filename))
            except Exception as e:
                print(f"[DOC_EXTRACTOR] DOCX image extraction error: {e}")
                continue

    return md_text, images


# ─── PPTX extraction ─────────────────────────────────────────


def _extract_pptx(
    file_path: str, image_dir: str
) -> tuple[str, list[tuple[str, str]]]:
    """Return (markdown_text, [(local_path, filename), ...])."""
    images: list[tuple[str, str]] = []
    slides_text: list[str] = []

    prs = Presentation(file_path)

    for slide_num, slide in enumerate(prs.slides, 1):
        slide_lines: list[str] = []

        for shape in slide.shapes:
            # Extract text from text frames
            if shape.has_text_frame:
                for para in shape.text_frame.paragraphs:
                    text = para.text.strip()
                    if text:
                        slide_lines.append(text)

            # Extract text from tables
            if shape.has_table:
                for row in shape.table.rows:
                    row_text = " | ".join(
                        cell.text.strip() for cell in row.cells
                    )
                    if row_text.strip(" |"):
                        slide_lines.append(row_text)

            # Extract images
            if shape.shape_type == 13:  # MSO_SHAPE_TYPE.PICTURE
                try:
                    image = shape.image
                    image_bytes = image.blob
                    if len(image_bytes) < _MIN_IMAGE_BYTES:
                        continue

                    ext = _mime_to_ext(image.content_type or "")

                    img_hash = hashlib.md5(image_bytes).hexdigest()[:10]
                    filename = f"pptx_s{slide_num}_{img_hash}.{ext}"
                    local_path = os.path.join(image_dir, filename)

                    if os.path.exists(local_path):
                        continue

                    with open(local_path, "wb") as f:
                        f.write(image_bytes)

                    images.append((local_path, filename))
                except Exception as e:
                    print(f"[DOC_EXTRACTOR] PPTX image extraction error: {e}")
                    continue

        if slide_lines:
            header = f"## Slide {slide_num}"
            slides_text.append(header + "\n\n" + "\n\n".join(slide_lines))

    md_text = "\n\n---\n\n".join(slides_text)
    return md_text, images


# ─── Helpers ──────────────────────────────────────────────────


def _mime_to_ext(content_type: str) -> str:
    """Map MIME type to file extension."""
    ct = content_type.lower()
    mapping = {
        "image/png": "png",
        "image/jpeg": "jpg",
        "image/jpg": "jpg",
        "image/gif": "gif",
        "image/webp": "webp",
        "image/tiff": "tiff",
        "image/bmp": "bmp",
        "image/x-emf": "emf",
        "image/x-wmf": "wmf",
    }
    return mapping.get(ct, "png")