trimitdown 0.1.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.
- trimitdown/__init__.py +12 -0
- trimitdown/cli.py +84 -0
- trimitdown/convert.py +170 -0
- trimitdown/tiktoken_cache/9b5ad71b2ce5302211f9c61530b329a4922fc6a4 +100256 -0
- trimitdown/tiktoken_cache/README.md +14 -0
- trimitdown-0.1.0.dist-info/METADATA +243 -0
- trimitdown-0.1.0.dist-info/RECORD +11 -0
- trimitdown-0.1.0.dist-info/WHEEL +5 -0
- trimitdown-0.1.0.dist-info/entry_points.txt +2 -0
- trimitdown-0.1.0.dist-info/licenses/LICENSE +21 -0
- trimitdown-0.1.0.dist-info/top_level.txt +1 -0
trimitdown/__init__.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""TrimItDown: конвертация документов в markdown, пригодный для вставки в LLM.
|
|
2
|
+
|
|
3
|
+
ЕДИНСТВЕННЫЙ источник версии для всего проекта: пакета на PyPI, десктопного
|
|
4
|
+
приложения, ответа /api/mode и того, что видно в UI. Раньше версия приложения
|
|
5
|
+
жила отдельно в core/version.py -- два числа неизбежно разъезжаются, и разъезд
|
|
6
|
+
замечает пользователь, а не мы.
|
|
7
|
+
|
|
8
|
+
Обязан остаться строковым литералом: setuptools читает его статически через AST,
|
|
9
|
+
не импортируя модуль, -- иначе в build-окружение потянется markitdown.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
__version__ = "0.1.0"
|
trimitdown/cli.py
ADDED
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
"""Командная строка: единственный интерфейс pip/uvx-пакета.
|
|
2
|
+
|
|
3
|
+
Только локальная конверсия. Работа с внешним сервером -- это канал Docker
|
|
4
|
+
(docker exec внутрь контейнера) и настройка в десктопном приложении; сетевого
|
|
5
|
+
режима здесь нет сознательно, чтобы у инструмента, который ценен локальностью,
|
|
6
|
+
не появилось сетевых отказов.
|
|
7
|
+
"""
|
|
8
|
+
import argparse
|
|
9
|
+
import sys
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from trimitdown import __version__
|
|
13
|
+
from trimitdown.convert import ConversionError, convert_bytes, convert_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="trimitdown",
|
|
19
|
+
description="Convert documents to LLM-ready markdown.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--version", action="version", version=f"trimitdown {__version__}")
|
|
22
|
+
|
|
23
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
24
|
+
convert = sub.add_parser("convert", help="convert one document to markdown")
|
|
25
|
+
convert.add_argument("path", help='file to convert, or "-" to read stdin')
|
|
26
|
+
convert.add_argument("-o", "--output", help="write markdown here instead of stdout")
|
|
27
|
+
convert.add_argument(
|
|
28
|
+
"--type",
|
|
29
|
+
dest="suffix",
|
|
30
|
+
help='file extension for stdin input, e.g. ".pdf" -- required with "-"',
|
|
31
|
+
)
|
|
32
|
+
return parser
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _run_convert(args) -> int:
|
|
36
|
+
if args.path == "-":
|
|
37
|
+
if not args.suffix:
|
|
38
|
+
print(
|
|
39
|
+
'reading from stdin needs --type, e.g. --type .pdf: the extension '
|
|
40
|
+
"cannot be recovered from a stream",
|
|
41
|
+
file=sys.stderr,
|
|
42
|
+
)
|
|
43
|
+
return 2
|
|
44
|
+
try:
|
|
45
|
+
result = convert_bytes(sys.stdin.buffer.read(), args.suffix)
|
|
46
|
+
except ConversionError as e:
|
|
47
|
+
print(f"could not convert stdin: {e}", file=sys.stderr)
|
|
48
|
+
return 1
|
|
49
|
+
else:
|
|
50
|
+
path = Path(args.path)
|
|
51
|
+
if not path.is_file():
|
|
52
|
+
print(f"no such file: {path}", file=sys.stderr)
|
|
53
|
+
return 1
|
|
54
|
+
try:
|
|
55
|
+
result = convert_path(path)
|
|
56
|
+
except ConversionError as e:
|
|
57
|
+
print(f"could not convert {path}: {e}", file=sys.stderr)
|
|
58
|
+
return 1
|
|
59
|
+
|
|
60
|
+
if args.output:
|
|
61
|
+
Path(args.output).write_text(result.text, encoding="utf-8")
|
|
62
|
+
else:
|
|
63
|
+
# Явная запись в буфер: на Windows консольная кодировка по умолчанию не
|
|
64
|
+
# переваривает вывод конвертации (m³/h, ≥, кириллица) и печать падает.
|
|
65
|
+
sys.stdout.buffer.write(result.text.encode("utf-8"))
|
|
66
|
+
return 0
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def main(argv: list[str] | None = None) -> int:
|
|
70
|
+
# Converted documents and the paths they came from carry characters the console
|
|
71
|
+
# codepage cannot encode (m³/h, ≥, Cyrillic); on a Russian Windows the first one
|
|
72
|
+
# would abort the process. stdout is written as bytes below for the document
|
|
73
|
+
# body, but error messages go through the text layer and need this.
|
|
74
|
+
for stream in (sys.stdout, sys.stderr):
|
|
75
|
+
stream.reconfigure(encoding="utf-8", errors="replace")
|
|
76
|
+
|
|
77
|
+
args = _build_parser().parse_args(argv)
|
|
78
|
+
if args.command == "convert":
|
|
79
|
+
return _run_convert(args)
|
|
80
|
+
return 2
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
if __name__ == "__main__":
|
|
84
|
+
raise SystemExit(main())
|
trimitdown/convert.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Чистая конверсия документа в markdown.
|
|
2
|
+
|
|
3
|
+
Ни FastAPI, ни HTTP, ни архива: на вход путь или байты, на выходе текст и
|
|
4
|
+
счётчики. Веб-обёртки живут в core/converter.py и зовут этот модуль. Граница
|
|
5
|
+
закрыта тестом (tests/test_convert_pure.py::test_pure_module_does_not_import_fastapi):
|
|
6
|
+
импорт fastapi отсюда утяжелил бы CLI-пакет на весь веб-стек, ничего при этом не
|
|
7
|
+
сломав внешне -- то есть незаметно.
|
|
8
|
+
"""
|
|
9
|
+
import os
|
|
10
|
+
import re
|
|
11
|
+
import tempfile
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from importlib import resources
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
|
|
16
|
+
import tiktoken
|
|
17
|
+
|
|
18
|
+
from markitdown import MarkItDown
|
|
19
|
+
|
|
20
|
+
from pdfminer.pdfpage import PDFPage
|
|
21
|
+
from pptx import Presentation
|
|
22
|
+
|
|
23
|
+
from trimitdown_pdf import pdf_to_markdown
|
|
24
|
+
|
|
25
|
+
md = MarkItDown()
|
|
26
|
+
|
|
27
|
+
# Кэш едет внутри пакета как package data. Path(__file__) сработал бы для обычной
|
|
28
|
+
# установки, но не переживает упаковку; resources.files находит его и там, и в
|
|
29
|
+
# рабочем дереве. Без этого tiktoken при первом счёте полезет в сеть -- в офлайне
|
|
30
|
+
# это отказ, в оплаченном туннеле сюрприз.
|
|
31
|
+
os.environ.setdefault(
|
|
32
|
+
"TIKTOKEN_CACHE_DIR", str(resources.files("trimitdown") / "tiktoken_cache")
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
TOKENS_PER_UNIT_ESTIMATE = 2250 # midpoint of the documented 1500-3000 tokens/page vision-estimate
|
|
36
|
+
# range (Anthropic docs) — used for the PDF/PPTX before/after
|
|
37
|
+
# comparison only. The frontend never renders a negative saving:
|
|
38
|
+
# if a dense page's extracted text still exceeds this estimate,
|
|
39
|
+
# it shows the raw result-token count with no percentage.
|
|
40
|
+
|
|
41
|
+
_encoding = None
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class ConversionError(Exception):
|
|
45
|
+
"""Документ не удалось сконвертировать.
|
|
46
|
+
|
|
47
|
+
Своё исключение, а не HTTPException: ядро не знает про HTTP. Веб-слой ловит
|
|
48
|
+
его и переводит в 422, CLI -- в ненулевой код возврата.
|
|
49
|
+
"""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass(frozen=True)
|
|
53
|
+
class ConversionResult:
|
|
54
|
+
text: str
|
|
55
|
+
tokens_after: int | None
|
|
56
|
+
tokens_before: int | None
|
|
57
|
+
unit: str | None
|
|
58
|
+
units: int | None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _get_encoding():
|
|
62
|
+
global _encoding
|
|
63
|
+
if _encoding is None:
|
|
64
|
+
_encoding = tiktoken.get_encoding("cl100k_base")
|
|
65
|
+
return _encoding
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def count_tokens(text: str) -> int:
|
|
69
|
+
return len(_get_encoding().encode(text))
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def safe_stem(name: str) -> str:
|
|
73
|
+
# Untrusted filenames may carry either separator regardless of host OS --
|
|
74
|
+
# this same function sanitizes uploads on the Linux-hosted Docker server
|
|
75
|
+
# and on the Windows/macOS desktop app. Path().stem's separator and root
|
|
76
|
+
# parsing differ by platform (Windows treats "\" as a separator and
|
|
77
|
+
# collapses a leading "///" into a UNC-style prefix; POSIX does neither),
|
|
78
|
+
# so a name built from Path() here would sanitize differently depending
|
|
79
|
+
# on which OS the server happens to run on. Reduce explicitly instead.
|
|
80
|
+
last = re.split(r"[/\\]", name)[-1]
|
|
81
|
+
stem = last.rsplit(".", 1)[0]
|
|
82
|
+
stem = re.sub(r"[^\w\-. ]", "_", stem, flags=re.UNICODE).strip()
|
|
83
|
+
return stem or "file"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _count_pdf_pages(path: Path) -> int:
|
|
87
|
+
with open(path, "rb") as f:
|
|
88
|
+
return len(list(PDFPage.get_pages(f)))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _count_pptx_slides(path: Path) -> int:
|
|
92
|
+
return len(Presentation(path).slides)
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def _estimate_before_tokens(suffix: str, path: str) -> tuple[int | None, str | None, int | None]:
|
|
96
|
+
try:
|
|
97
|
+
if suffix == ".pdf":
|
|
98
|
+
units = _count_pdf_pages(Path(path))
|
|
99
|
+
return units * TOKENS_PER_UNIT_ESTIMATE, "page", units
|
|
100
|
+
if suffix == ".pptx":
|
|
101
|
+
units = _count_pptx_slides(Path(path))
|
|
102
|
+
return units * TOKENS_PER_UNIT_ESTIMATE, "slide", units
|
|
103
|
+
except Exception:
|
|
104
|
+
pass
|
|
105
|
+
return None, None, None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def convert_path(path: Path | str) -> ConversionResult:
|
|
109
|
+
"""Сконвертировать файл на диске. Синхронно: асинхронность -- забота веб-слоя."""
|
|
110
|
+
path = Path(path)
|
|
111
|
+
suffix = path.suffix.lower()
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
with open(path, "rb") as f:
|
|
115
|
+
head = f.read(1024)
|
|
116
|
+
|
|
117
|
+
# PDFs take our own extractor: markitdown's PDF converter glues words
|
|
118
|
+
# together, invents tables out of prose, and drops real ones. No fallback
|
|
119
|
+
# to markitdown here — both sit on pdfminer, so a file that breaks one
|
|
120
|
+
# breaks the other. This is a route, not a fallback: markitdown itself
|
|
121
|
+
# dispatches by sniffing content, so an HTML/TXT file misnamed ".pdf"
|
|
122
|
+
# used to convert fine through markitdown. Checking the %PDF magic bytes
|
|
123
|
+
# keeps real PDFs on our path and hands everything else back to
|
|
124
|
+
# markitdown's own sniffing, exactly as before.
|
|
125
|
+
#
|
|
126
|
+
# This check has now been written wrong twice. `data[:5] == b"%PDF"` was
|
|
127
|
+
# always False (5-byte slice, 4-byte literal) and made the branch a
|
|
128
|
+
# silent no-op. `data[:4] == b"%PDF"` looks right but anchors the marker
|
|
129
|
+
# at byte 0 — real PDFs can have a leading \r\n or junk before the header
|
|
130
|
+
# (measured: 2 of 719 real files, one offset by 2 bytes, one by 135) and
|
|
131
|
+
# pdfplumber parses both fine. Anchoring routed them to markitdown, which
|
|
132
|
+
# is exactly the fallback this comment forbids. pdfminer itself scans for
|
|
133
|
+
# the marker rather than anchoring, which is why those files open at all
|
|
134
|
+
# — so scan a window instead of anchoring at byte 0.
|
|
135
|
+
if suffix == ".pdf" and head.find(b"%PDF") != -1:
|
|
136
|
+
text = pdf_to_markdown(str(path))
|
|
137
|
+
else:
|
|
138
|
+
text = md.convert(str(path)).text_content
|
|
139
|
+
except Exception as e:
|
|
140
|
+
raise ConversionError(str(e)) from e
|
|
141
|
+
|
|
142
|
+
before, unit, units = _estimate_before_tokens(suffix, str(path))
|
|
143
|
+
|
|
144
|
+
# Token count is a non-essential stat — the converted document must always be
|
|
145
|
+
# returned. tiktoken can fail in a packaged build (e.g. the tiktoken_ext
|
|
146
|
+
# encoding-constructor plugin not bundled), and an unguarded call there turned
|
|
147
|
+
# every local-mode conversion into a 500. Degrade to a null count instead.
|
|
148
|
+
try:
|
|
149
|
+
after = count_tokens(text)
|
|
150
|
+
except Exception:
|
|
151
|
+
after = None
|
|
152
|
+
|
|
153
|
+
return ConversionResult(
|
|
154
|
+
text=text, tokens_after=after, tokens_before=before, unit=unit, units=units
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def convert_bytes(data: bytes, suffix: str) -> ConversionResult:
|
|
159
|
+
"""Сконвертировать содержимое в памяти.
|
|
160
|
+
|
|
161
|
+
Существует ради веб-слоя, где вход -- UploadFile, а не путь. Временный файл
|
|
162
|
+
нужен потому, что и markitdown, и наш движок работают с путями.
|
|
163
|
+
"""
|
|
164
|
+
with tempfile.NamedTemporaryFile(delete=False, suffix=suffix) as tmp:
|
|
165
|
+
tmp.write(data)
|
|
166
|
+
tmp_path = tmp.name
|
|
167
|
+
try:
|
|
168
|
+
return convert_path(tmp_path)
|
|
169
|
+
finally:
|
|
170
|
+
Path(tmp_path).unlink(missing_ok=True)
|