Каталогизатор документов: разбор папки, тезисы, поиск, 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:
155
scripts/build_exe.py
Normal file
155
scripts/build_exe.py
Normal 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
167
scripts/make_icon.py
Normal 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())
|
||||
Reference in New Issue
Block a user