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.
- smart_slice/__init__.py +440 -0
- smart_slice/__main__.py +188 -0
- smart_slice/_accel.py +65 -0
- smart_slice/_config.py +60 -0
- smart_slice/_i18n.py +37 -0
- smart_slice/_logging.py +22 -0
- smart_slice/_markdown.py +33 -0
- smart_slice/_optional.py +114 -0
- smart_slice/_speedup_py.py +64 -0
- smart_slice/_uuid.py +40 -0
- smart_slice/_validation.py +174 -0
- smart_slice/chunker.py +884 -0
- smart_slice/chunking/__init__.py +29 -0
- smart_slice/chunking/_base.py +14 -0
- smart_slice/chunking/mark.py +39 -0
- smart_slice/chunking/overlap.py +64 -0
- smart_slice/exceptions.py +60 -0
- smart_slice/files.py +130 -0
- smart_slice/handlers/__init__.py +205 -0
- smart_slice/handlers/_utils.py +55 -0
- smart_slice/handlers/_xlsx_images.py +163 -0
- smart_slice/handlers/archive.py +152 -0
- smart_slice/handlers/base.py +22 -0
- smart_slice/handlers/csv_handler.py +136 -0
- smart_slice/handlers/doc.py +289 -0
- smart_slice/handlers/eml.py +100 -0
- smart_slice/handlers/epub.py +138 -0
- smart_slice/handlers/fb2.py +108 -0
- smart_slice/handlers/html.py +113 -0
- smart_slice/handlers/image.py +90 -0
- smart_slice/handlers/image_text_extract.py +125 -0
- smart_slice/handlers/ipynb.py +126 -0
- smart_slice/handlers/mbox.py +137 -0
- smart_slice/handlers/mhtml.py +96 -0
- smart_slice/handlers/mobi.py +93 -0
- smart_slice/handlers/msg.py +88 -0
- smart_slice/handlers/odf.py +239 -0
- smart_slice/handlers/pdf.py +634 -0
- smart_slice/handlers/ppt.py +134 -0
- smart_slice/handlers/pptx.py +160 -0
- smart_slice/handlers/raster_image.py +52 -0
- smart_slice/handlers/rtf.py +71 -0
- smart_slice/handlers/subtitle.py +103 -0
- smart_slice/handlers/svg.py +100 -0
- smart_slice/handlers/text.py +119 -0
- smart_slice/handlers/vcalendar.py +221 -0
- smart_slice/handlers/wps.py +54 -0
- smart_slice/handlers/xls.py +159 -0
- smart_slice/handlers/xlsx.py +262 -0
- smart_slice/handlers/xmind.py +170 -0
- smart_slice/handlers/zip_handler.py +306 -0
- smart_slice/options.py +225 -0
- smart_slice/patterns.py +69 -0
- smart_slice/qa/__init__.py +56 -0
- smart_slice/qa/_base.py +50 -0
- smart_slice/qa/_table_base.py +21 -0
- smart_slice/qa/csv_qa.py +64 -0
- smart_slice/qa/csv_table.py +78 -0
- smart_slice/qa/md_qa.py +146 -0
- smart_slice/qa/xls_qa.py +67 -0
- smart_slice/qa/xls_table.py +102 -0
- smart_slice/qa/xlsx_qa.py +78 -0
- smart_slice/qa/xlsx_table.py +129 -0
- smart_slice/qa/zip_qa.py +157 -0
- smart_slice/service.py +486 -0
- smart_slice/types.py +55 -0
- smart_slice-0.3.0.dist-info/METADATA +1193 -0
- smart_slice-0.3.0.dist-info/RECORD +71 -0
- smart_slice-0.3.0.dist-info/WHEEL +4 -0
- smart_slice-0.3.0.dist-info/entry_points.txt +2 -0
- 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)
|
smart_slice/_logging.py
ADDED
|
@@ -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}")
|
smart_slice/_markdown.py
ADDED
|
@@ -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 (````) 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
|
smart_slice/_optional.py
ADDED
|
@@ -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)
|