File size: 7,115 Bytes
aad7814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Document extraction for DOCX / PDF / legacy DOC.

Produces an ordered list of :class:`Block` objects (text + heading flag + level)
so the template discoverer can infer section structure, and a plain-text helper
for reference ingest. Legacy ``.doc`` files are converted to ``.docx`` first using
the first available tool: pandoc, LibreOffice, or Microsoft Word via pywin32.
"""

from __future__ import annotations

import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
from dataclasses import dataclass
from pathlib import Path

logger = logging.getLogger(__name__)

# Word VBA: wdFormatXMLDocument — Office Open XML (.docx) without macros.
_WD_FORMAT_DOCX = 12


@dataclass
class Block:
    """One extracted paragraph/line."""

    text: str
    is_heading: bool = False
    level: int = 0


def _soffice_executable() -> str | None:
    for name in ("soffice", "libreoffice"):
        p = shutil.which(name)
        if p:
            return p
    if sys.platform == "win32":
        for pf in (
            os.environ.get("ProgramFiles", r"C:\Program Files"),
            os.environ.get("ProgramFiles(x86)", r"C:\Program Files (x86)"),
        ):
            cand = Path(pf) / "LibreOffice" / "program" / "soffice.exe"
            if cand.is_file():
                return str(cand)
    return None


def convert_doc_to_docx(source: Path) -> tuple[Path | None, Path | None]:
    """Convert a legacy ``.doc`` to ``.docx`` in a temp dir.

    Returns ``(docx_path, temp_dir)``; the caller must ``shutil.rmtree(temp_dir)``.
    Returns ``(None, None)`` on failure.
    """
    out_dir = Path(tempfile.mkdtemp(prefix="rics_v2_doc_"))
    out = out_dir / f"{source.stem}.docx"

    pandoc = shutil.which("pandoc")
    if pandoc:
        try:
            subprocess.run(
                [pandoc, str(source.resolve()), "-o", str(out.resolve())],
                check=True, capture_output=True, text=True, timeout=180,
            )
            if out.is_file() and out.stat().st_size > 0:
                logger.info("Converted %s via pandoc", source.name)
                return out, out_dir
        except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as exc:
            logger.debug("pandoc .doc->.docx failed for %s: %s", source, exc)

    soffice = _soffice_executable()
    if soffice:
        try:
            subprocess.run(
                [soffice, "--headless", "--convert-to", "docx", "--outdir",
                 str(out_dir), str(source.resolve())],
                check=True, capture_output=True, text=True, timeout=240,
            )
            if out.is_file() and out.stat().st_size > 0:
                logger.info("Converted %s via LibreOffice", source.name)
                return out, out_dir
        except (subprocess.CalledProcessError, OSError, subprocess.TimeoutExpired) as exc:
            logger.debug("LibreOffice .doc->.docx failed for %s: %s", source, exc)

    try:
        import win32com.client  # type: ignore[import-untyped]
    except ImportError:
        pass
    else:
        word = doc = None
        try:
            word = win32com.client.Dispatch("Word.Application")
            word.Visible = False
            word.DisplayAlerts = 0
            doc = word.Documents.Open(str(source.resolve()), ReadOnly=True)
            doc.SaveAs2(str(out.resolve()), FileFormat=_WD_FORMAT_DOCX)
            doc.Close(SaveChanges=False)
            doc = None
            word.Quit()
            word = None
            if out.is_file() and out.stat().st_size > 0:
                logger.info("Converted %s via Microsoft Word (COM)", source.name)
                return out, out_dir
        except Exception as exc:  # noqa: BLE001
            logger.debug("Word COM .doc->.docx failed for %s: %s", source, exc)
        finally:
            if doc is not None:
                try:
                    doc.Close(SaveChanges=False)
                except Exception:  # noqa: BLE001
                    pass
            if word is not None:
                try:
                    word.Quit()
                except Exception:  # noqa: BLE001
                    pass

    shutil.rmtree(out_dir, ignore_errors=True)
    logger.warning(
        "Could not convert legacy .doc %s. Install pandoc, LibreOffice, or "
        "Microsoft Word + pywin32, or provide a .docx instead.",
        source,
    )
    return None, None


def _docx_blocks(path: Path) -> list[Block]:
    from docx import Document  # type: ignore[import-untyped]

    doc = Document(str(path))
    blocks: list[Block] = []
    for para in doc.paragraphs:
        text = (para.text or "").strip()
        if not text:
            continue
        style = (para.style.name or "") if para.style else ""
        is_heading = style.lower().startswith("heading") or style.lower() == "title"
        level = 0
        if is_heading:
            m = re.search(r"(\d+)", style)
            level = int(m.group(1)) if m else 1
        blocks.append(Block(text=text, is_heading=is_heading, level=level))
    return blocks


def _pdf_blocks(path: Path) -> list[Block]:
    """Heuristic PDF extraction: short, title-cased lines are treated as headings."""
    text = _pdf_text(path)
    blocks: list[Block] = []
    for raw in text.splitlines():
        line = raw.strip()
        if not line:
            continue
        words = line.split()
        looks_heading = (
            len(words) <= 9
            and not line.endswith((".", ",", ";"))
            and (line.isupper() or line[:1].isupper())
            and len(line) <= 80
        )
        blocks.append(Block(text=line, is_heading=looks_heading, level=1 if looks_heading else 0))
    return blocks


def _pdf_text(path: Path) -> str:
    try:
        from pypdf import PdfReader

        reader = PdfReader(str(path))
        return "\n".join((page.extract_text() or "") for page in reader.pages)
    except Exception as exc:  # noqa: BLE001
        logger.warning("pypdf extraction failed for %s (%s); trying PyMuPDF.", path, exc)
    try:
        import fitz  # type: ignore[import-untyped]

        with fitz.open(str(path)) as d:
            return "\n".join(page.get_text() for page in d)
    except Exception as exc:  # noqa: BLE001
        logger.warning("PyMuPDF extraction failed for %s (%s).", path, exc)
        return ""


def extract_blocks(path: Path) -> list[Block]:
    """Return ordered blocks for a DOCX / PDF / legacy DOC file."""
    suffix = path.suffix.lower()
    if suffix == ".doc":
        converted, tmp = convert_doc_to_docx(path)
        if converted is None:
            return []
        try:
            return _docx_blocks(converted)
        finally:
            shutil.rmtree(tmp, ignore_errors=True)
    if suffix in (".docx", ".docm"):
        return _docx_blocks(path)
    if suffix == ".pdf":
        return _pdf_blocks(path)
    raise ValueError(f"Unsupported document type: {path.suffix}")


def extract_text(path: Path) -> str:
    """Return plain text for a document (used for reference ingest)."""
    return "\n".join(b.text for b in extract_blocks(path))