smart-slice 0.3.0__py3-none-any.whl

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 (71) hide show
  1. smart_slice/__init__.py +440 -0
  2. smart_slice/__main__.py +188 -0
  3. smart_slice/_accel.py +65 -0
  4. smart_slice/_config.py +60 -0
  5. smart_slice/_i18n.py +37 -0
  6. smart_slice/_logging.py +22 -0
  7. smart_slice/_markdown.py +33 -0
  8. smart_slice/_optional.py +114 -0
  9. smart_slice/_speedup_py.py +64 -0
  10. smart_slice/_uuid.py +40 -0
  11. smart_slice/_validation.py +174 -0
  12. smart_slice/chunker.py +884 -0
  13. smart_slice/chunking/__init__.py +29 -0
  14. smart_slice/chunking/_base.py +14 -0
  15. smart_slice/chunking/mark.py +39 -0
  16. smart_slice/chunking/overlap.py +64 -0
  17. smart_slice/exceptions.py +60 -0
  18. smart_slice/files.py +130 -0
  19. smart_slice/handlers/__init__.py +205 -0
  20. smart_slice/handlers/_utils.py +55 -0
  21. smart_slice/handlers/_xlsx_images.py +163 -0
  22. smart_slice/handlers/archive.py +152 -0
  23. smart_slice/handlers/base.py +22 -0
  24. smart_slice/handlers/csv_handler.py +136 -0
  25. smart_slice/handlers/doc.py +289 -0
  26. smart_slice/handlers/eml.py +100 -0
  27. smart_slice/handlers/epub.py +138 -0
  28. smart_slice/handlers/fb2.py +108 -0
  29. smart_slice/handlers/html.py +113 -0
  30. smart_slice/handlers/image.py +90 -0
  31. smart_slice/handlers/image_text_extract.py +125 -0
  32. smart_slice/handlers/ipynb.py +126 -0
  33. smart_slice/handlers/mbox.py +137 -0
  34. smart_slice/handlers/mhtml.py +96 -0
  35. smart_slice/handlers/mobi.py +93 -0
  36. smart_slice/handlers/msg.py +88 -0
  37. smart_slice/handlers/odf.py +239 -0
  38. smart_slice/handlers/pdf.py +634 -0
  39. smart_slice/handlers/ppt.py +134 -0
  40. smart_slice/handlers/pptx.py +160 -0
  41. smart_slice/handlers/raster_image.py +52 -0
  42. smart_slice/handlers/rtf.py +71 -0
  43. smart_slice/handlers/subtitle.py +103 -0
  44. smart_slice/handlers/svg.py +100 -0
  45. smart_slice/handlers/text.py +119 -0
  46. smart_slice/handlers/vcalendar.py +221 -0
  47. smart_slice/handlers/wps.py +54 -0
  48. smart_slice/handlers/xls.py +159 -0
  49. smart_slice/handlers/xlsx.py +262 -0
  50. smart_slice/handlers/xmind.py +170 -0
  51. smart_slice/handlers/zip_handler.py +306 -0
  52. smart_slice/options.py +225 -0
  53. smart_slice/patterns.py +69 -0
  54. smart_slice/qa/__init__.py +56 -0
  55. smart_slice/qa/_base.py +50 -0
  56. smart_slice/qa/_table_base.py +21 -0
  57. smart_slice/qa/csv_qa.py +64 -0
  58. smart_slice/qa/csv_table.py +78 -0
  59. smart_slice/qa/md_qa.py +146 -0
  60. smart_slice/qa/xls_qa.py +67 -0
  61. smart_slice/qa/xls_table.py +102 -0
  62. smart_slice/qa/xlsx_qa.py +78 -0
  63. smart_slice/qa/xlsx_table.py +129 -0
  64. smart_slice/qa/zip_qa.py +157 -0
  65. smart_slice/service.py +486 -0
  66. smart_slice/types.py +55 -0
  67. smart_slice-0.3.0.dist-info/METADATA +1193 -0
  68. smart_slice-0.3.0.dist-info/RECORD +71 -0
  69. smart_slice-0.3.0.dist-info/WHEEL +4 -0
  70. smart_slice-0.3.0.dist-info/entry_points.txt +2 -0
  71. smart_slice-0.3.0.dist-info/licenses/LICENSE +674 -0
smart_slice/_config.py ADDED
@@ -0,0 +1,60 @@
1
+ # coding=utf-8
2
+ """Environment-backed settings helpers.
3
+
4
+ There is no framework settings object: every tunable is read from the process
5
+ environment. The defensive parser caps live on
6
+ :class:`smart_slice._validation.ParserLimits`, which resolves each field through
7
+ :func:`_load_limits_from_env` below - the canonical variable name is
8
+ ``SMART_SLICE_PARSER_<FIELD>`` (e.g. ``SMART_SLICE_PARSER_MAX_XML_BYTES``).
9
+ """
10
+ import os
11
+ from dataclasses import fields
12
+ from typing import Optional
13
+
14
+ __all__ = ["integer_setting", "setting", "_env_flag", "_load_limits_from_env"]
15
+
16
+
17
+ def _env_flag(name: str, default: bool = False) -> bool:
18
+ """Boolean environment switch; anything but a truthy literal means ``default``."""
19
+ raw = os.environ.get(name)
20
+ if raw is None:
21
+ return default
22
+ return raw.strip().lower() in ("1", "true", "yes", "on")
23
+
24
+
25
+ def setting(name: str, default=None, config: Optional[dict] = None):
26
+ """Read a setting: environment first, then the optional ``config`` mapping."""
27
+ value = os.environ.get(name)
28
+ if value is not None:
29
+ return value
30
+ if config:
31
+ value = config.get(name)
32
+ if value is not None:
33
+ return value
34
+ return default
35
+
36
+
37
+ def integer_setting(name: str, default: int, minimum: int = 1, config: Optional[dict] = None) -> int:
38
+ """``setting`` coerced to a positive int, falling back to ``default`` on any problem."""
39
+ try:
40
+ raw = setting(name, default, config)
41
+ if isinstance(raw, bool):
42
+ return default
43
+ value = int(raw)
44
+ return value if value >= minimum else default
45
+ except (TypeError, ValueError, OverflowError):
46
+ return default
47
+
48
+
49
+ def _load_limits_from_env(klass, config: Optional[dict] = None):
50
+ """Build a frozen dataclass instance of ``klass`` from ``SMART_SLICE_PARSER_<FIELD>``.
51
+
52
+ Shared by ``ParserLimits.load`` so the environment-variable naming convention
53
+ is defined in exactly one place.
54
+ """
55
+ defaults = {field.name: field.default for field in fields(klass)}
56
+ resolved = {
57
+ name: integer_setting("SMART_SLICE_PARSER_" + name.upper(), default, config=config)
58
+ for name, default in defaults.items()
59
+ }
60
+ return klass(**resolved)
smart_slice/_i18n.py ADDED
@@ -0,0 +1,37 @@
1
+ # coding=utf-8
2
+ """Minimal gettext shim.
3
+
4
+ The slicing core ships user facing error strings. Wrapping them in ``gettext``
5
+ keeps them translatable without depending on a web framework: applications that
6
+ want translations can install their own catalogues for the ``smart_slice``
7
+ domain, everyone else gets the source strings unchanged.
8
+ """
9
+ import gettext as _gettext
10
+ import os
11
+ from typing import Any
12
+
13
+ __all__ = ["gettext", "install_translations"]
14
+
15
+ _DOMAIN = "smart_slice"
16
+ _translation = _gettext.NullTranslations()
17
+
18
+
19
+ def install_translations(localedir: str = None, languages=None) -> None:
20
+ """Load a catalogue for the ``smart_slice`` domain.
21
+
22
+ Missing catalogues are not an error: the null translation (identity) stays
23
+ installed so callers never have to guard.
24
+ """
25
+ global _translation
26
+ localedir = localedir or os.path.join(os.path.dirname(os.path.dirname(__file__)), "locale")
27
+ try:
28
+ _translation = _gettext.translation(_DOMAIN, localedir=localedir, languages=languages, fallback=True)
29
+ except Exception: # noqa: BLE001 - translation is an enhancement, never fatal
30
+ _translation = _gettext.NullTranslations()
31
+
32
+
33
+ def gettext(message: Any) -> str:
34
+ """Translate ``message``; returns it unchanged when no catalogue is installed."""
35
+ if not isinstance(message, str):
36
+ return str(message)
37
+ return _translation.gettext(message)
@@ -0,0 +1,22 @@
1
+ # coding=utf-8
2
+ """Logging setup.
3
+
4
+ The library never configures handlers or levels on the root logger; it only emits
5
+ records through ``logging.getLogger("smart_slice")`` so that the embedding
6
+ application stays in charge of log routing. Per-module child loggers are used
7
+ (``smart_slice.handlers.pdf`` ...) which makes selective silencing easy.
8
+ """
9
+ import logging
10
+
11
+ __all__ = ["logger", "get_logger"]
12
+
13
+ ROOT_LOGGER_NAME = "smart_slice"
14
+
15
+ logger = logging.getLogger(ROOT_LOGGER_NAME)
16
+
17
+
18
+ def get_logger(name: str = "") -> logging.Logger:
19
+ """Return a child logger under the ``smart_slice`` namespace."""
20
+ if not name:
21
+ return logger
22
+ return logging.getLogger(f"{ROOT_LOGGER_NAME}.{name}")
@@ -0,0 +1,33 @@
1
+ # coding=utf-8
2
+ """Markdown reference scanners.
3
+
4
+ Derived from the source platform text helpers (only these two pure-text scanners were
5
+ carried over; the rest of that module was framework-specific).
6
+
7
+ Both are used when a document arrives as a bundle (a zip of markdown pages plus
8
+ their images): the inner text references sibling files by relative path, and those
9
+ references have to be rewritten to the ``./oss/file/{id}`` form once the assets are
10
+ collected.
11
+ """
12
+ import re
13
+ from typing import List
14
+
15
+ __all__ = ["parse_md_image", "parse_md_file_link"]
16
+
17
+
18
+ def parse_md_image(content: str) -> List[str]:
19
+ """Return every markdown image reference (``![alt](target)``) found in ``content``."""
20
+ matches = re.finditer(r"!\[.*?\]\(.*?\)", content)
21
+ return [match.group() for match in matches]
22
+
23
+
24
+ def parse_md_file_link(content: str) -> List[str]:
25
+ """Return non-image markdown links like [text](file.mp4) and HTML src/href referencing files."""
26
+ results = []
27
+ # Regular markdown links (not images): [text](path)
28
+ for m in re.finditer(r"(?<!!)\[.*?\]\((.*?)\)", content):
29
+ results.append(m.group())
30
+ # HTML tags with src attribute, e.g. <video src="...">
31
+ for m in re.finditer(r'<\w+[^>]+\bsrc=["\']([^"\']+)["\']', content):
32
+ results.append(m.group())
33
+ return results
@@ -0,0 +1,114 @@
1
+ # coding=utf-8
2
+ """Optional-dependency bookkeeping.
3
+
4
+ Every format parser is an optional extra (see ``pyproject.toml``). A handler
5
+ whose parser is not installed must degrade to "this format is not supported"
6
+ instead of breaking ``import smart_slice`` for everybody.
7
+
8
+ This module is the single place that knows which top-level import belongs to
9
+ which pip extra, so the error text a user sees always names the exact command
10
+ that fixes it::
11
+
12
+ Unsupported file format: .pptx (install smart-slice[office] to enable it)
13
+
14
+ Handlers use it in two places:
15
+
16
+ ``support()``
17
+ returns False when the parser is missing, so dispatch skips the handler and
18
+ a later one (or the 400 from the service layer) answers instead;
19
+ ``handle()`` / ``get_content()``
20
+ calls :func:`require` first, so a direct call - or a delegation from another
21
+ handler such as ``WpsSplitHandle`` - raises a clear 400 rather than a
22
+ ``NameError``.
23
+ """
24
+ from typing import Dict, List, Optional
25
+
26
+ from .exceptions import UnsupportedFormatError
27
+
28
+ __all__ = ["EXTRA_FOR", "REQUIREMENT_FOR", "extra_for", "require", "missing_hint", "installed"]
29
+
30
+ #: top-level import name -> pip extra that provides it
31
+ EXTRA_FOR: Dict[str, str] = {
32
+ "markdownify": "markup",
33
+ "bs4": "markup",
34
+ "docx": "office",
35
+ "pptx": "office",
36
+ "openpyxl": "office",
37
+ "xlrd": "office",
38
+ "olefile": "office",
39
+ "striprtf": "office",
40
+ "pypdf": "pdf",
41
+ "PIL": "pdf",
42
+ "extract_msg": "mail",
43
+ "py7zr": "archive",
44
+ "mobi": "ebook",
45
+ "pillow_heif": "image",
46
+ "jieba": "keywords",
47
+ "rapidocr_onnxruntime": "ocr",
48
+ "uuid_utils": "fastuuid",
49
+ }
50
+
51
+ #: top-level import name -> the requirement string quoted in the error message
52
+ REQUIREMENT_FOR: Dict[str, str] = {
53
+ "markdownify": "markdownify>=0.13",
54
+ "bs4": "beautifulsoup4>=4.12",
55
+ "docx": "python-docx>=1.1",
56
+ "pptx": "python-pptx>=1.0",
57
+ "openpyxl": "openpyxl>=3.1",
58
+ "xlrd": "xlrd>=2.0",
59
+ "olefile": "olefile>=0.46",
60
+ "striprtf": "striprtf>=0.0.26",
61
+ "pypdf": "pypdf>=4.0",
62
+ "PIL": "pillow>=10.0",
63
+ "extract_msg": "extract-msg>=0.48",
64
+ "py7zr": "py7zr>=0.20",
65
+ "mobi": "mobi>=0.3.3",
66
+ "pillow_heif": "pillow-heif>=0.13",
67
+ "jieba": "jieba>=0.42",
68
+ "rapidocr_onnxruntime": "rapidocr-onnxruntime>=1.3",
69
+ "uuid_utils": "uuid-utils>=0.6",
70
+ }
71
+
72
+
73
+ def extra_for(module: str) -> str:
74
+ """Return the pip extra that provides ``module`` (``all`` when unknown)."""
75
+ return EXTRA_FOR.get(module.split(".")[0], "all")
76
+
77
+
78
+ def missing_hint(module: str) -> str:
79
+ """Install instruction for a missing parser, e.g. ``pip install smart-slice[office]``."""
80
+ return f"pip install smart-slice[{extra_for(module)}]"
81
+
82
+
83
+ def installed(module: str) -> bool:
84
+ """Is the optional parser importable right now?"""
85
+ import importlib.util
86
+
87
+ try:
88
+ return importlib.util.find_spec(module.split(".")[0]) is not None
89
+ except (ImportError, ValueError): # pragma: no cover - broken install
90
+ return False
91
+
92
+
93
+ def require(module: str, extension: Optional[str] = None) -> None:
94
+ """Raise :class:`UnsupportedFormatError` unless ``module`` is importable.
95
+
96
+ :param module: top-level import name, e.g. ``"openpyxl"``
97
+ :param extension: the file extension being parsed, used in the message
98
+ """
99
+ if installed(module):
100
+ return
101
+ subject = extension or module
102
+ raise UnsupportedFormatError(
103
+ f"Unsupported file format: {subject} "
104
+ f"(missing optional parser '{module}'; run {missing_hint(module)})"
105
+ )
106
+
107
+
108
+ def missing_extras() -> List[Dict[str, str]]:
109
+ """Every optional parser that is not importable, as dicts for introspection."""
110
+ out = []
111
+ for module, requirement in REQUIREMENT_FOR.items():
112
+ if not installed(module):
113
+ out.append({"module": module, "extra": EXTRA_FOR[module], "requirement": requirement})
114
+ return out
@@ -0,0 +1,64 @@
1
+ # coding=utf-8
2
+ r"""Pure-Python implementation of the optional C accelerator.
3
+
4
+ Mirrors :mod:`smart_slice._speedup` (C) exactly, function for function, so the
5
+ chunker can fall back to it on platforms without a compiler and tests can assert
6
+ the two agree. This module is the *reference* implementation: the C version must
7
+ match it, not the other way around.
8
+
9
+ Both exist only to remove repeated whole-document regex scans; neither changes
10
+ what a heading is. See :func:`smart_slice.chunker._candidate_titles` for how the
11
+ result is consumed and for the guard that keeps custom pattern lists on the regex
12
+ path.
13
+ """
14
+ from typing import List, Tuple
15
+
16
+ __all__ = ["scan_heading_candidates", "IMPLEMENTATION"]
17
+
18
+ IMPLEMENTATION = "python"
19
+
20
+
21
+ def scan_heading_candidates(text: str) -> List[Tuple[int, int, int]]:
22
+ r"""Report each line that could be an ATX heading, in document order.
23
+
24
+ Acceptance rule (identical to the canonical patterns in
25
+ :mod:`smart_slice.patterns`):
26
+
27
+ - the line's first character is ``#`` with **no** leading whitespace, since the
28
+ patterns anchor with ``(?<=^)`` / ``(?<=\\n)`` immediately before the hashes;
29
+ - followed by a run of ``#`` and then a single ASCII space (U+0020). A tab does
30
+ not qualify - every pattern requires a literal space, which is also why
31
+ ``## `` never matches a ``###`` line;
32
+ The scan is deliberately a **superset** of the regular expressions: it does not
33
+ reproduce their extra guards (the level-1 ``(?!-\*- coding:)`` exclusion, the
34
+ ``(?!#)`` level guards). A superset is the only safe direction for a prefilter -
35
+ reporting an extra candidate merely costs one regex scan that returns empty,
36
+ whereas missing one would silently drop headings. Keeping the scan free of
37
+ per-level special cases is also what lets the C and Python versions stay
38
+ trivially identical.
39
+
40
+ :param text: the (code-fence-masked) document text
41
+ :return: ``[(line_start, line_end, hashes), ...]`` where ``line_start`` is the
42
+ index of the first ``#`` and ``line_end`` is one past the last
43
+ character of the line (newline excluded)
44
+ """
45
+ candidates: List[Tuple[int, int, int]] = []
46
+ length = len(text)
47
+ line_start = 0
48
+
49
+ while line_start < length:
50
+ line_end = text.find("\n", line_start)
51
+ if line_end == -1:
52
+ line_end = length
53
+
54
+ cursor = line_start
55
+ while cursor < line_end and text[cursor] == "#":
56
+ cursor += 1
57
+ hashes = cursor - line_start
58
+
59
+ if hashes and cursor < line_end and text[cursor] == " ":
60
+ candidates.append((line_start, line_end, hashes))
61
+
62
+ line_start = line_end + 1
63
+
64
+ return candidates
smart_slice/_uuid.py ADDED
@@ -0,0 +1,40 @@
1
+ # coding=utf-8
2
+ """UUID helpers - drop-in replacement for ``uuid_utils.compat``.
3
+
4
+ Image assets extracted from documents get a fresh id which is then embedded in
5
+ the markdown reference (``./oss/file/{id}``). UUIDv7 is preferred because ids
6
+ stay time-sortable; ``uuid_utils`` provides it as a compiled extension. When
7
+ that optional dependency is absent, a small pure-python v7 generator is used so
8
+ the package keeps working with a stdlib-only install.
9
+ """
10
+ import os
11
+ import time
12
+ import uuid as _stdlib_uuid
13
+
14
+ __all__ = ["UUID", "uuid7", "uuid4"]
15
+
16
+ UUID = _stdlib_uuid.UUID
17
+
18
+
19
+ def uuid4() -> UUID:
20
+ return _stdlib_uuid.uuid4()
21
+
22
+
23
+ def _uuid7_pure_python() -> UUID:
24
+ """RFC 9562 style v7 UUID: 48-bit unix-ms timestamp + random tail."""
25
+ timestamp_ms = int(time.time() * 1000)
26
+ rand = bytearray(os.urandom(10))
27
+ rand[0] = (rand[0] & 0x0F) | 0x70 # version 7
28
+ rand[2] = (rand[2] & 0x3F) | 0x80 # RFC 4122 variant
29
+ body = timestamp_ms.to_bytes(6, "big") + bytes(rand)
30
+ return UUID(bytes=body)
31
+
32
+
33
+ try: # prefer the compiled implementation when installed
34
+ from uuid_utils import uuid7 as _uuid7_impl
35
+ except Exception: # noqa: BLE001 - optional dependency
36
+ _uuid7_impl = _uuid7_pure_python
37
+
38
+
39
+ def uuid7() -> UUID:
40
+ return _uuid7_impl()
@@ -0,0 +1,174 @@
1
+ # coding=utf-8
2
+ # Derived from the source platform document-slicing layer (see docs/PORTING.md).
3
+ # Imports and framework-facing symbols were re-routed; the parsing and
4
+ # splitting logic is unchanged.
5
+
6
+
7
+ from smart_slice._i18n import gettext as _
8
+ from smart_slice.exceptions import SliceError, ResourceLimitError
9
+ from smart_slice._config import _load_limits_from_env, integer_setting
10
+
11
+ import codecs
12
+ import io
13
+ import re
14
+ import xml.etree.ElementTree as ET
15
+ import zipfile
16
+ from contextlib import contextmanager
17
+ from dataclasses import dataclass, fields
18
+
19
+ from charset_normalizer import detect
20
+
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class ParserLimits:
25
+ max_input_bytes: int = 128 * 1024 * 1024
26
+ max_xml_bytes: int = 32 * 1024 * 1024
27
+ max_package_members: int = 10000
28
+ max_package_bytes: int = 128 * 1024 * 1024
29
+ max_xml_elements: int = 100000
30
+ max_xml_depth: int = 128
31
+ max_table_cells: int = 1000000
32
+ max_mime_parts: int = 1000
33
+ max_mime_depth: int = 32
34
+ max_odf_text_bytes: int = 32 * 1024 * 1024
35
+
36
+ @classmethod
37
+ def load(cls, config=None):
38
+ return _load_limits_from_env(cls, config=config)
39
+
40
+
41
+ BINARY_SIGNATURES = (
42
+ b'PK\x03\x04', b'PK\x05\x06', b'PK\x07\x08', b'\xd0\xcf\x11\xe0',
43
+ b'%PDF-', b'\x89PNG', b'\xff\xd8\xff', b'GIF87a', b'GIF89a', b'RIFF',
44
+ b'MZ', b'\x7fELF', b'\x1f\x8b', b'BZh', b'\xfd7zXZ', b'7z\xbc\xaf', b'Rar!',
45
+ )
46
+ CONTROL_CHARACTERS = re.compile(r'[\x00-\x08\x0b\x0e-\x1f\x7f-\x9f]')
47
+
48
+
49
+ def validate_input(buffer, limits=None):
50
+ limits = limits or ParserLimits.load()
51
+ if len(buffer) > limits.max_input_bytes:
52
+ raise ResourceLimitError('File exceeds the parser input byte limit')
53
+
54
+
55
+ def decode_text(buffer, encoding=None, detector=detect):
56
+ validate_input(buffer)
57
+ if not buffer:
58
+ return ''
59
+ if buffer.startswith(BINARY_SIGNATURES):
60
+ raise SliceError(400, 'Binary content cannot be parsed as text')
61
+ bom_encoding = None
62
+ for marker, candidate in (
63
+ (codecs.BOM_UTF32_LE, 'utf-32'), (codecs.BOM_UTF32_BE, 'utf-32'),
64
+ (codecs.BOM_UTF8, 'utf-8-sig'),
65
+ (codecs.BOM_UTF16_LE, 'utf-16'), (codecs.BOM_UTF16_BE, 'utf-16'),
66
+ ):
67
+ if buffer.startswith(marker):
68
+ bom_encoding = candidate
69
+ break
70
+ if not bom_encoding and b'\x00' in buffer:
71
+ raise SliceError(400, 'Binary content or Unicode text without a byte order mark')
72
+ if bom_encoding or encoding:
73
+ encodings = [bom_encoding or encoding]
74
+ else:
75
+ detected = detector(buffer[:65536]) or {}
76
+ encodings = ['utf-8']
77
+ candidate = detected.get('encoding')
78
+ confidence = detected.get('confidence')
79
+ try:
80
+ confidence = 1.0 if confidence is None else float(confidence)
81
+ except (TypeError, ValueError, OverflowError):
82
+ confidence = 0.0
83
+ if isinstance(candidate, str) and 0.5 < confidence <= 1.0:
84
+ if not candidate.lower().replace('_', '-').startswith(('utf-16', 'utf-32')):
85
+ encodings.append(candidate)
86
+ for candidate in encodings:
87
+ try:
88
+ content = buffer.decode(candidate)
89
+ except (UnicodeError, LookupError):
90
+ continue
91
+ if CONTROL_CHARACTERS.search(content):
92
+ raise SliceError(400, 'Binary control characters are not valid text')
93
+ return content
94
+ raise SliceError(400, 'Text encoding is invalid or unsupported')
95
+
96
+
97
+ class _BoundedTreeBuilder(ET.TreeBuilder):
98
+ def __init__(self, limits):
99
+ super().__init__()
100
+ self.limits = limits
101
+ self.depth = 0
102
+ self.elements = 0
103
+
104
+ def doctype(self, name, pubid, system):
105
+ raise SliceError(400, 'DTD and entity declarations are not supported')
106
+
107
+ def start(self, tag, attrs):
108
+ self.depth += 1
109
+ self.elements += 1
110
+ if self.depth > self.limits.max_xml_depth or self.elements > self.limits.max_xml_elements:
111
+ raise ResourceLimitError('XML exceeds the depth or element limit')
112
+ return super().start(tag, attrs)
113
+
114
+ def end(self, tag):
115
+ self.depth -= 1
116
+ return super().end(tag)
117
+
118
+
119
+ def parse_xml(buffer, limits=None):
120
+ limits = limits or ParserLimits.load()
121
+ if len(buffer) > limits.max_xml_bytes:
122
+ raise ResourceLimitError('XML exceeds the parser byte limit')
123
+ try:
124
+ return ET.fromstring(buffer, parser=ET.XMLParser(target=_BoundedTreeBuilder(limits)))
125
+ except ET.ParseError as error:
126
+ raise SliceError(400, 'Invalid or damaged XML document') from error
127
+
128
+
129
+ @contextmanager
130
+ def document_package(buffer, limits=None):
131
+ limits = limits or ParserLimits.load()
132
+ validate_input(buffer, limits)
133
+ if buffer.startswith(b'\xd0\xcf\x11\xe0'):
134
+ raise SliceError(400, 'Encrypted or legacy binary Office files are not supported by this parser')
135
+ try:
136
+ with zipfile.ZipFile(io.BytesIO(buffer)) as archive:
137
+ members = archive.infolist()
138
+ if len(members) > limits.max_package_members:
139
+ raise ResourceLimitError('Document package exceeds the member count limit')
140
+ if any(member.flag_bits & 1 for member in members):
141
+ raise SliceError(400, 'Encrypted document packages are not supported')
142
+ if sum(member.file_size for member in members) > limits.max_package_bytes:
143
+ raise ResourceLimitError('Document package exceeds the expanded byte limit')
144
+ names = [member.filename for member in members]
145
+ if len(set(names)) != len(names):
146
+ raise SliceError(400, 'Document package contains duplicate member names')
147
+ yield archive
148
+ except (zipfile.BadZipFile, KeyError, NotImplementedError, RuntimeError) as error:
149
+ if isinstance(error, ResourceLimitError):
150
+ raise
151
+ raise SliceError(400, 'Invalid, encrypted or damaged document package') from error
152
+
153
+
154
+ def read_package_xml(archive, name, limits=None):
155
+ limits = limits or ParserLimits.load()
156
+ if archive.getinfo(name).file_size > limits.max_xml_bytes:
157
+ raise ResourceLimitError('XML member exceeds the parser byte limit')
158
+ with archive.open(name) as reader:
159
+ content = reader.read(limits.max_xml_bytes + 1)
160
+ return parse_xml(content, limits)
161
+
162
+
163
+ def validate_ooxml(buffer, main_part, content_types):
164
+ limits = ParserLimits.load()
165
+ with document_package(buffer, limits) as archive:
166
+ root = read_package_xml(archive, '[Content_Types].xml', limits)
167
+ namespace = '{http://schemas.openxmlformats.org/package/2006/content-types}'
168
+ if root.tag != namespace + 'Types':
169
+ raise SliceError(400, 'Invalid Office content types manifest')
170
+ matches = [element.get('ContentType') for element in root.findall(namespace + 'Override')
171
+ if element.get('PartName') == '/' + main_part]
172
+ if len(matches) != 1 or matches[0] not in content_types:
173
+ raise SliceError(400, 'Office document content type does not match the supported format')
174
+ read_package_xml(archive, main_part, limits)