Программа принимает папку с документами, извлекает из них текст и метаданные и строит каталог: краткое описание, тезисы и ключевые слова для каждого файла плюс поиск по всему собранному. Ядро: - 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>
168 lines
6.6 KiB
Python
168 lines
6.6 KiB
Python
"""@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())
|