Skip to content

Commit 01d97fd

Browse files
authored
Merge pull request #414 from Ontos-AI/feat/wuchengke/office-compat-normalize
feat: normalize non-standard DOCX/XLSX before parse
2 parents 3271682 + a57a438 commit 01d97fd

8 files changed

Lines changed: 614 additions & 16 deletions

File tree

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
from __future__ import annotations
2+
3+
import os
4+
from dataclasses import dataclass
5+
from typing import NoReturn
6+
7+
from app.services.document_parser.conversion.legacy_converter import (
8+
normalize_docx_variant,
9+
normalize_xlsx_variant,
10+
)
11+
from app.services.document_parser.orchestration.office_container_inspection import (
12+
inspect_office_container,
13+
read_override_content_type,
14+
)
15+
from loguru import logger
16+
17+
from shared.core.exceptions.domain_exceptions import ValidationException
18+
19+
OLE_CFBF_SIGNATURE = bytes.fromhex("D0CF11E0A1B11AE1")
20+
21+
_STANDARD_MAIN_CONTENT_TYPES: dict[str, str] = {
22+
".docx": (
23+
"application/vnd.openxmlformats-officedocument."
24+
"wordprocessingml.document.main+xml"
25+
),
26+
".xlsx": (
27+
"application/vnd.openxmlformats-officedocument."
28+
"spreadsheetml.sheet.main+xml"
29+
),
30+
}
31+
_MAIN_PART_BY_EXTENSION: dict[str, str] = {
32+
".docx": "/word/document.xml",
33+
".xlsx": "/xl/workbook.xml",
34+
}
35+
_VARIANT_CONTENT_TYPE_MARKERS: tuple[str, ...] = (
36+
"macroEnabled.main+xml",
37+
"template.main+xml",
38+
)
39+
_NORMALIZED_OUTPUT_DIRNAME = "office_compat"
40+
41+
42+
@dataclass(frozen=True)
43+
class OfficeCompatNormalization:
44+
file_path: str
45+
conversion: dict[str, object] | None = None
46+
47+
48+
def normalize_office_source(file_path: str) -> OfficeCompatNormalization:
49+
"""Convert known DOCX/XLSX OOXML variants; reject encrypted OLE containers."""
50+
extension = os.path.splitext(file_path)[1].lower()
51+
if extension not in _STANDARD_MAIN_CONTENT_TYPES:
52+
return OfficeCompatNormalization(file_path=file_path)
53+
54+
if _has_ole_cfbf_signature(file_path):
55+
_raise_encrypted_office_file(extension)
56+
57+
inspection = inspect_office_container(file_path)
58+
if inspection is None or inspection.content_types_xml is None:
59+
return OfficeCompatNormalization(file_path=file_path)
60+
61+
content_type = read_override_content_type(
62+
inspection.content_types_xml,
63+
_MAIN_PART_BY_EXTENSION[extension],
64+
)
65+
if content_type is None:
66+
return OfficeCompatNormalization(file_path=file_path)
67+
if content_type == _STANDARD_MAIN_CONTENT_TYPES[extension]:
68+
return OfficeCompatNormalization(file_path=file_path)
69+
if not _is_known_ooxml_variant(content_type):
70+
return OfficeCompatNormalization(file_path=file_path)
71+
72+
outdir = os.path.join(os.path.dirname(file_path), _NORMALIZED_OUTPUT_DIRNAME)
73+
if extension == ".docx":
74+
converted_path, _converted_name = normalize_docx_variant(file_path, outdir)
75+
else:
76+
converted_path, _converted_name = normalize_xlsx_variant(file_path, outdir)
77+
logger.info(
78+
"Office compatibility conversion applied: "
79+
f"source_path={file_path}, normalized_path={converted_path}, "
80+
f"content_type={content_type}"
81+
)
82+
return OfficeCompatNormalization(
83+
file_path=converted_path,
84+
conversion={"content_type": content_type},
85+
)
86+
87+
88+
def _has_ole_cfbf_signature(file_path: str) -> bool:
89+
with open(file_path, "rb") as handle:
90+
header = handle.read(len(OLE_CFBF_SIGNATURE))
91+
return header == OLE_CFBF_SIGNATURE
92+
93+
94+
def _is_known_ooxml_variant(content_type: str) -> bool:
95+
return any(marker in content_type for marker in _VARIANT_CONTENT_TYPE_MARKERS)
96+
97+
98+
def _raise_encrypted_office_file(extension: str) -> NoReturn:
99+
raise ValidationException(
100+
user_message=(
101+
f"Invalid file: the uploaded {extension} file is encrypted or "
102+
"password-protected. Please unlock the file and upload again."
103+
),
104+
violations=[
105+
{
106+
"field": "file",
107+
"description": "Encrypted or password-protected Office file",
108+
}
109+
],
110+
)

apps/worker/app/services/document_ingestion/source_preparation.py

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,13 @@
66
from app.services.document_ingestion.file_size_policy import (
77
build_file_size_limit_message,
88
)
9-
from app.services.document_ingestion.processing_context import ParseJobContext
9+
from app.services.document_ingestion.office_compat_normalizer import (
10+
normalize_office_source,
11+
)
12+
from app.services.document_ingestion.processing_context import (
13+
ParseJobContext,
14+
persist_job_metadata_updates,
15+
)
1016
from app.services.document_parser.support.internal_parse_name import (
1117
prepare_internal_parse_input,
1218
)
@@ -64,10 +70,18 @@ def prepare_source_file(
6470
f"local_path={prepared_parse_input.file_path}"
6571
)
6672

73+
normalized_source = normalize_office_source(prepared_parse_input.file_path)
74+
if normalized_source.conversion is not None:
75+
persist_job_metadata_updates(
76+
job_id=job_id,
77+
job_context=job_context,
78+
metadata_updates={"office_compat": normalized_source.conversion},
79+
)
80+
6781
return PreparedSourceFile(
6882
source_file_name=source_file_name,
6983
internal_parse_name=prepared_parse_input.internal_filename,
70-
local_file_path=prepared_parse_input.file_path,
84+
local_file_path=normalized_source.file_path,
7185
file_extension=file_extension,
7286
)
7387

apps/worker/app/services/document_parser/conversion/legacy_converter.py

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ def _convert_with_libreoffice(
5151
expected_output_ext: str,
5252
operation: str,
5353
) -> tuple[str, str]:
54-
"""Convert a legacy Office document to an OOXML format via LibreOffice."""
54+
"""Convert an Office document to an OOXML format via LibreOffice."""
5555
soffice_path = resolve_libreoffice_binary()
5656

5757
os.makedirs(outdir, exist_ok=True)
@@ -124,3 +124,25 @@ def xls_to_xlsx(xls_path: str, outdir: str = ".") -> tuple[str, str]:
124124
expected_output_ext="xlsx",
125125
operation="convert_xls_to_xlsx",
126126
)
127+
128+
129+
def normalize_docx_variant(source_path: str, outdir: str = ".") -> tuple[str, str]:
130+
"""Repair a non-standard DOCX container (macro/template content type) via LibreOffice."""
131+
return _convert_with_libreoffice(
132+
source_path=source_path,
133+
outdir=outdir,
134+
convert_to_arg="docx",
135+
expected_output_ext="docx",
136+
operation="normalize_docx_variant",
137+
)
138+
139+
140+
def normalize_xlsx_variant(source_path: str, outdir: str = ".") -> tuple[str, str]:
141+
"""Repair a non-standard XLSX container (macro/template content type) via LibreOffice."""
142+
return _convert_with_libreoffice(
143+
source_path=source_path,
144+
outdir=outdir,
145+
convert_to_arg="xlsx",
146+
expected_output_ext="xlsx",
147+
operation="normalize_xlsx_variant",
148+
)
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
from __future__ import annotations
2+
3+
import zipfile
4+
from dataclasses import dataclass
5+
from xml.etree import ElementTree
6+
7+
CONTENT_TYPES_MEMBER: str = "[Content_Types].xml"
8+
CONTENT_TYPES_NAMESPACE: str = (
9+
"http://schemas.openxmlformats.org/package/2006/content-types"
10+
)
11+
12+
13+
@dataclass(frozen=True)
14+
class OfficeContainerInspection:
15+
member_names: frozenset[str]
16+
content_types_xml: bytes | None
17+
18+
19+
def inspect_office_container(file_path: str) -> OfficeContainerInspection | None:
20+
"""Return ZIP members and Content_Types.xml when the path is a readable ZIP."""
21+
if not zipfile.is_zipfile(file_path):
22+
return None
23+
try:
24+
with zipfile.ZipFile(file_path, "r") as archive:
25+
member_names = frozenset(archive.namelist())
26+
content_types_xml = (
27+
archive.read(CONTENT_TYPES_MEMBER)
28+
if CONTENT_TYPES_MEMBER in member_names
29+
else None
30+
)
31+
except zipfile.BadZipFile:
32+
return None
33+
return OfficeContainerInspection(
34+
member_names=member_names,
35+
content_types_xml=content_types_xml,
36+
)
37+
38+
39+
def read_override_content_type(
40+
content_types_xml: bytes,
41+
part_name: str,
42+
) -> str | None:
43+
"""Return the Override ContentType for ``part_name``, or None if absent."""
44+
try:
45+
root = ElementTree.fromstring(content_types_xml)
46+
except ElementTree.ParseError:
47+
return None
48+
49+
override_tag = f"{{{CONTENT_TYPES_NAMESPACE}}}Override"
50+
for override in root.findall(override_tag):
51+
if override.get("PartName") == part_name:
52+
return override.get("ContentType")
53+
return None
54+
55+
56+
__all__ = [
57+
"CONTENT_TYPES_MEMBER",
58+
"CONTENT_TYPES_NAMESPACE",
59+
"OfficeContainerInspection",
60+
"inspect_office_container",
61+
"read_override_content_type",
62+
]

apps/worker/app/services/document_parser/orchestration/office_container_validator.py

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,13 @@
11
from __future__ import annotations
22

3-
import zipfile
43
from dataclasses import dataclass
4+
from typing import NoReturn
55

66
from app.services.document_parser.orchestration.format_router import DocumentFormat
7+
from app.services.document_parser.orchestration.office_container_inspection import (
8+
CONTENT_TYPES_MEMBER,
9+
inspect_office_container,
10+
)
711

812
from shared.core.exceptions.domain_exceptions import ValidationException
913

@@ -29,7 +33,6 @@ def violation_description(self) -> str:
2933
)
3034

3135

32-
_CONTENT_TYPES_MEMBER: str = "[Content_Types].xml"
3336
_OFFICE_CONTAINER_REQUIREMENTS: dict[
3437
DocumentFormat,
3538
_OfficeContainerRequirement,
@@ -61,23 +64,18 @@ def validate_office_container(
6164
if requirement is None:
6265
return
6366

64-
if not zipfile.is_zipfile(file_path):
67+
inspection = inspect_office_container(file_path)
68+
if inspection is None:
6569
_raise_invalid_office_file(requirement)
6670

67-
try:
68-
with zipfile.ZipFile(file_path, "r") as archive:
69-
member_names = set(archive.namelist())
70-
except zipfile.BadZipFile as exc:
71-
raise _build_invalid_office_file_exception(requirement) from exc
72-
7371
if (
74-
_CONTENT_TYPES_MEMBER not in member_names
75-
or requirement.required_member not in member_names
72+
CONTENT_TYPES_MEMBER not in inspection.member_names
73+
or requirement.required_member not in inspection.member_names
7674
):
7775
_raise_invalid_office_file(requirement)
7876

7977

80-
def _raise_invalid_office_file(requirement: _OfficeContainerRequirement) -> None:
78+
def _raise_invalid_office_file(requirement: _OfficeContainerRequirement) -> NoReturn:
8179
raise _build_invalid_office_file_exception(requirement)
8280

8381

0 commit comments

Comments
 (0)