Каталогизатор документов: разбор папки, тезисы, поиск, GUI

Программа принимает папку с документами, извлекает из них текст и
метаданные и строит каталог: краткое описание, тезисы и ключевые слова
для каждого файла плюс поиск по всему собранному.

Ядро:
- extract: PDF, DJVU, DOC(X), RTF, ODT, CHM, EPUB, FB2, PPT(X), XLS(X), TXT;
  PDF передаётся MuPDF потоком в память (работают длинные пути) и ограничен
  по времени — повреждённый файл иначе чинится минутами;
- summarize: экстрактивное реферирование по TF-IDF, посчитанному на самой
  библиотеке, с лёгким стеммером для русского и английского;
- db: SQLite с полнотекстовым индексом FTS5, морфологический поиск с BM25;
- scanner: инкрементальный многопоточный обход, счётчик попыток защищает
  от файла, обрывающего разбор;
- report: автономный HTML с поиском, Markdown, JSON, CSV.

Интерфейс:
- окно PySide6 в тёмной теме: вкладки разбора и поиска, фоновый поток,
  карточка документа, правка своих ключевых слов;
- командная строка: build, scan, analyze, report, search, show, tag, stats;
- у каждой папки свой каталог, последняя обработанная запоминается.

Сборка: scripts/build_exe.py даёт Catalogizer.exe в Windows и
Catalogizer.app в macOS, иконки .ico и .icns рисуются кодом.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-30 19:20:29 +03:00
commit 49f91247c7
30 changed files with 3934 additions and 0 deletions

155
scripts/build_exe.py Normal file
View File

@@ -0,0 +1,155 @@
"""@file build_exe.py
@brief Сборка автономной программы: Python, Qt и PyMuPDF внутри.
Запуск (из корня проекта, интерпретатором с PySide6 и PyInstaller):
python scripts/build_exe.py
Что получается, зависит от системы, на которой идёт сборка:
* Windows — ``dist/Catalogizer.exe``;
* macOS — ``dist/Catalogizer.app``;
* Linux — ``dist/Catalogizer``.
PyInstaller не умеет собирать под чужую систему: программу для macOS
собирают на macOS, для Windows — на Windows. Исходники при этом одни.
Готовая программа не требует ни Python, ни PySide6, ни PyMuPDF.
Ключи разобраны в ``--help``:
* ``--onedir`` — папка вместо одного файла: запускается заметно быстрее
(в macOS так всегда: ``.app`` по устройству и есть папка);
* ``--console`` — оставить консоль, чтобы видеть traceback и сообщения Qt;
* ``--cli`` — дополнительно собрать консольную программу ``catalog``.
"""
from __future__ import annotations
import argparse
import shutil
import subprocess
import sys
from pathlib import Path
MACOS = sys.platform == "darwin"
WINDOWS = sys.platform == "win32"
#: Идентификатор пакета macOS: по нему система различает приложения.
BUNDLE_ID = "ru.set.catalogizer"
#: Корень проекта: скрипт лежит в scripts/.
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(Path(__file__).resolve().parent)) # для make_icon
DIST = ROOT / "dist"
BUILD = ROOT / "build"
ICON = ROOT / "ico" / ("catalog.icns" if MACOS else "catalog.ico")
#: Модули Qt, которые приложению не нужны. Без этого списка в сборку тянутся
#: QtWebEngine и 3D-стек — сотни мегабайт мимо цели.
UNUSED_QT_MODULES = (
"QtWebEngineCore", "QtWebEngineWidgets", "QtWebEngineQuick",
"QtQuick", "QtQuick3D", "QtQml", "Qt3DCore", "Qt3DRender",
"QtMultimedia", "QtBluetooth", "QtCharts", "QtDataVisualization",
"QtGraphs", "QtPdf", "QtSql", "QtTest", "QtNetwork", "QtSerialPort",
)
#: Что исключается всегда: tkinter в программе не используется, а PySide2
#: рядом с PySide6 ломает сборку.
COMMON_EXCLUDES = ("tkinter", "PySide2", "shiboken2", "matplotlib",
"numpy", "PIL", "pytest")
def check_environment() -> None:
"""@brief Проверяет, что в интерпретаторе есть всё нужное для сборки."""
missing = []
for module, package in (("PyInstaller", "pyinstaller"),
("PySide6", "PySide6"),
("pymupdf", "pymupdf")):
try:
__import__(module)
except ImportError:
missing.append(package)
if missing:
raise SystemExit(
"Не хватает пакетов: %s\nУстановите их:\n %s -m pip install %s"
% (", ".join(missing), sys.executable, " ".join(missing)))
def build_command(name: str, entry: Path, args: argparse.Namespace,
windowed: bool) -> list[str]:
"""@brief Собирает командную строку PyInstaller для одной цели."""
command = [
sys.executable, "-m", "PyInstaller",
"--noconfirm",
"--clean",
"--name", name,
"--distpath", str(DIST),
"--workpath", str(BUILD),
"--specpath", str(BUILD),
# Пакет catalogizer лежит в корне проекта и не обязан быть установлен.
"--paths", str(ROOT),
]
# На macOS .app — это всегда папка, поэтому onefile там смысла не имеет.
command.append("--onedir" if (args.onedir or (MACOS and windowed))
else "--onefile")
# Консоль скрыта: приложение оконное. С --console видны traceback и
# сообщения Qt — так ищут причину, если программа молча не стартует.
command.append("--console" if (args.console or not windowed) else "--windowed")
if ICON.exists():
command += ["--icon", str(ICON)]
if MACOS and windowed:
command += ["--osx-bundle-identifier", BUNDLE_ID]
for module in UNUSED_QT_MODULES:
command += ["--exclude-module", "PySide6.%s" % module]
for module in COMMON_EXCLUDES:
command += ["--exclude-module", module]
command.append(str(entry))
return command
def run(command: list[str]) -> None:
print(" ".join('"%s"' % c if " " in c else c for c in command), flush=True)
result = subprocess.run(command, cwd=str(ROOT))
if result.returncode:
raise SystemExit("PyInstaller завершился с кодом %d" % result.returncode)
def main() -> int:
parser = argparse.ArgumentParser(
description="Сборка автономного Каталогизатора "
"(exe в Windows, .app в macOS)")
parser.add_argument("--onedir", action="store_true",
help="папка вместо одного файла: запуск быстрее")
parser.add_argument("--console", action="store_true",
help="оставить консоль у оконной сборки (для отладки)")
parser.add_argument("--cli", action="store_true",
help="дополнительно собрать консольную программу catalog")
args = parser.parse_args()
check_environment()
if not ICON.exists():
# Иконки не хранятся в репозитории — они целиком рисуются кодом.
import make_icon
print("Рисую иконки…", flush=True)
make_icon.build_ico()
make_icon.build_icns()
if BUILD.exists():
shutil.rmtree(BUILD, ignore_errors=True)
run(build_command("Catalogizer", ROOT / "catalog_gui.py", args, windowed=True))
if args.cli:
run(build_command("catalog", ROOT / "catalog.py", args, windowed=False))
if MACOS:
# Загрузчик .app обязан быть исполняемым, даже если права потерялись.
binary = DIST / "Catalogizer.app" / "Contents" / "MacOS" / "Catalogizer"
if binary.exists():
binary.chmod(0o755)
print("\nГотово. Файлы в %s" % DIST)
return 0
if __name__ == "__main__":
raise SystemExit(main())

167
scripts/make_icon.py Normal file
View File

@@ -0,0 +1,167 @@
"""@file make_icon.py
@brief Генерация иконок программы без внешних библиотек.
Иконка рисуется кодом (стопка страниц с корешком и лупой), чтобы в
репозитории не заводить бинарный файл неизвестного происхождения.
Делаются оба формата: ``ico/catalog.ico`` для Windows и
``ico/catalog.icns`` для macOS.
Запуск: ``python scripts/make_icon.py``.
"""
from __future__ import annotations
import struct
import zlib
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
TARGET_ICO = ROOT / "ico" / "catalog.ico"
TARGET_ICNS = ROOT / "ico" / "catalog.icns"
#: Размеры изображений внутри .ico.
SIZES = (16, 24, 32, 48, 64, 128, 256)
#: Типы изображений ICNS и их размеры: macOS берёт подходящий сам.
ICNS_TYPES = ((b"ic11", 32), (b"ic12", 64), (b"ic07", 128),
(b"ic08", 256), (b"ic09", 512))
#: Служебные байты растровых форматов.
TRANSPARENT = bytes(4) # прозрачный пиксель
FILTER_NONE = bytes(1) # PNG: строка без фильтра
PNG_MAGIC = bytes((137, 80, 78, 71, 13, 10, 26, 10))
BG = (0x1d, 0x28, 0x38) # тёмная плашка в цветах темы
PAGE = (0xdc, 0xe6, 0xf2) # страница
PAGE_DIM = (0x91, 0xa0, 0xb4) # страницы за первой
SPINE = (0x15, 0x70, 0xd8) # синий корешок
GLASS = (0x2f, 0x91, 0xff) # лупа
def _round_corner(x: float, y: float, size: float, radius: float) -> bool:
"""@brief Точка внутри скруглённого квадрата 0..size?"""
cx = min(max(x, radius), size - radius)
cy = min(max(y, radius), size - radius)
return (x - cx) ** 2 + (y - cy) ** 2 <= radius ** 2
def _pixel(u: float, v: float) -> tuple[int, int, int] | None:
"""@brief Цвет точки в относительных координатах 0..1 (None — прозрачно)."""
if not _round_corner(u, v, 1.0, 0.22):
return None
colour = BG
# Три страницы уступом: задние приглушены.
for shift, page in ((0.10, PAGE_DIM), (0.05, PAGE_DIM), (0.0, PAGE)):
left, right = 0.22 + shift, 0.72 + shift
top, bottom = 0.16 + shift, 0.80 + shift
if left <= u <= right and top <= v <= bottom:
colour = page
if u <= left + 0.06 and page is PAGE:
colour = SPINE # корешок у передней страницы
# Лупа: кольцо и ручка.
cx, cy, r = 0.66, 0.64, 0.19
d = ((u - cx) ** 2 + (v - cy) ** 2) ** 0.5
if r - 0.055 <= d <= r:
colour = GLASS
if 0.0 <= u - cx - 0.10 <= 0.055 and 0.0 <= v - cy - 0.10 <= 0.20:
if abs((u - cx - 0.135) - (v - cy - 0.135)) < 0.035:
colour = GLASS
return colour
# --------------------------------------------------------------------------
# Windows: .ico
# --------------------------------------------------------------------------
def _bgra_bottom_up(size: int) -> bytes:
"""@brief Растр BGRA снизу вверх — так его хранит формат ICO."""
rows = []
for y in range(size - 1, -1, -1):
row = bytearray()
for x in range(size):
px = _pixel((x + 0.5) / size, (y + 0.5) / size)
if px:
r, g, b = px
row += bytes((b, g, r, 255))
else:
row += TRANSPARENT
rows.append(bytes(row))
return b"".join(rows)
def _ico_entry(size: int) -> bytes:
"""@brief Одно изображение в формате BMP-в-ICO вместе с пустой маской."""
header = struct.pack("<IiiHHIIiiII", 40, size, size * 2, 1, 32, 0,
size * size * 4, 0, 0, 0, 0)
mask_row = ((size + 31) // 32) * 4
return header + _bgra_bottom_up(size) + bytes(mask_row * size)
def build_ico(target: Path = TARGET_ICO) -> Path:
"""@brief Пишет многоразмерный .ico и возвращает путь к нему."""
images = [_ico_entry(size) for size in SIZES]
offset = 6 + 16 * len(images)
out = bytearray(struct.pack("<HHH", 0, 1, len(images)))
for size, data in zip(SIZES, images):
out += struct.pack("<BBBBHHII", size % 256, size % 256, 0, 0, 1, 32,
len(data), offset)
offset += len(data)
for data in images:
out += data
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(bytes(out))
return target
# --------------------------------------------------------------------------
# macOS: .icns
# --------------------------------------------------------------------------
def _rgba_top_down(size: int) -> bytes:
"""@brief Растр RGBA сверху вниз, каждая строка с байтом фильтра PNG."""
rows = []
for y in range(size):
row = bytearray(FILTER_NONE)
for x in range(size):
px = _pixel((x + 0.5) / size, (y + 0.5) / size)
if px:
r, g, b = px
row += bytes((r, g, b, 255))
else:
row += TRANSPARENT
rows.append(bytes(row))
return b"".join(rows)
def _png(size: int) -> bytes:
"""@brief PNG без внешних библиотек: zlib и CRC из стандартной поставки."""
def chunk(tag: bytes, data: bytes) -> bytes:
body = tag + data
return (struct.pack(">I", len(data)) + body
+ struct.pack(">I", zlib.crc32(body) & 0xFFFFFFFF))
# Ширина, высота, 8 бит на канал, тип 6 (RGBA), без чересстрочности.
header = struct.pack(">IIBBBBB", size, size, 8, 6, 0, 0, 0)
return (PNG_MAGIC
+ chunk(b"IHDR", header)
+ chunk(b"IDAT", zlib.compress(_rgba_top_down(size), 9))
+ chunk(b"IEND", b""))
def build_icns(target: Path = TARGET_ICNS) -> Path:
"""@brief Пишет .icns из PNG-изображений нескольких размеров."""
parts = []
for tag, size in ICNS_TYPES:
data = _png(size)
parts.append(tag + struct.pack(">I", len(data) + 8) + data)
body = b"".join(parts)
target.parent.mkdir(parents=True, exist_ok=True)
target.write_bytes(b"icns" + struct.pack(">I", len(body) + 8) + body)
return target
if __name__ == "__main__":
print(build_ico())
print(build_icns())