File size: 3,943 Bytes
b9d34d8
 
 
 
 
 
 
 
 
 
 
 
74fe989
 
 
 
 
 
 
 
b9d34d8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
"""Build a Word (.docx) document from extracted results.

Uses python-docx (already a dependency). Pure-local, no network. Each uploaded
document becomes a section with a filename heading, and each page a "Page N"
heading (annotated with its source / OCR confidence) followed by its text.
"""

import io
import math
import re

# Characters that are illegal in XML 1.0 (and so rejected by python-docx/lxml
# with "All strings must be XML compatible", or by the final UTF-8 save with
# "surrogates not allowed"). We keep the only valid C0 controls (tab, newline,
# carriage return) and strip the rest. A form-feed (U+000C) is the common
# offender: PDF text layers emit it as a page/section separator. Lone surrogates
# (U+D800-U+DFFF) and the non-characters U+FFFE/U+FFFF are the others β€” PyMuPDF
# can emit U+FFFE from a broken/byte-swapped ToUnicode CMap, so it reaches Word
# export organically and would otherwise abort the ENTIRE multi-document export.
_XML_ILLEGAL = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f\ud800-\udfff\ufffe\uffff]")


def _xml_safe(value) -> str:
    """Coerce any value to an XML-safe string for python-docx."""
    if value is None:
        s = ""
    elif isinstance(value, str):
        s = value
    else:
        s = str(value)
    return _XML_ILLEGAL.sub("", s)


def build_docx(documents: list) -> bytes:
    """Render extracted documents to .docx bytes.

    ``documents`` is a list of ``{"filename": str, "pages": [
        {"page": int, "source": "text"|"ocr"|None, "confidence": float|None,
         "text": str}, ...]}``.

    The whole payload is untrusted (it comes from an HTTP body), so this never
    raises on a malformed shape, an XML-illegal control character in any string,
    or a non-finite (NaN/Infinity) confidence β€” it degrades gracefully instead.
    """
    from docx import Document  # imported lazily so import of this module is cheap

    doc = Document()
    # Be defensive about the posted payload: the body comes from an untrusted
    # HTTP request, so anything that isn't the expected shape (a list of dicts of
    # dicts) is coerced/skipped instead of raising a 500. The frontend always
    # sends well-formed data; a hand-crafted request must not crash the handler.
    if not isinstance(documents, list):
        documents = []
    first = True
    for d in documents:
        if not isinstance(d, dict):
            continue
        if not first:
            doc.add_page_break()
        first = False
        doc.add_heading(_xml_safe(d.get("filename") or "Document"), level=1)

        pages = d.get("pages")
        if not isinstance(pages, list):
            pages = []
        for p in pages:
            if not isinstance(p, dict):
                continue
            source = p.get("source")
            conf = p.get("confidence")
            # Guard finiteness: json.loads accepts bare NaN/Infinity, and
            # round(nan)/round(inf) raise β€” which would 500 the export.
            if source == "ocr":
                if (
                    isinstance(conf, (int, float))
                    and not isinstance(conf, bool)
                    and math.isfinite(conf)
                ):
                    tag = " β€” OCR ({}%)".format(round(conf * 100))
                else:
                    tag = " β€” OCR"
            elif source == "text":
                tag = " β€” Text layer"
            else:
                tag = ""
            doc.add_heading(_xml_safe("Page {}{}".format(p.get("page"), tag)), level=2)

            text = _xml_safe(p.get("text"))
            if text.strip():
                # Preserve line structure: one paragraph per line.
                for line in text.split("\n"):
                    doc.add_paragraph(line)
            else:
                empty = doc.add_paragraph()
                empty.add_run("(no text on this page)").italic = True

    buf = io.BytesIO()
    doc.save(buf)
    return buf.getvalue()