Каталогизатор документов: разбор папки, тезисы, поиск, 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

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