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 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)