Достижение: Добавлены скрипты и документация для релиза PDF Compressor.

- Добавлен release-body.md для подробных заметок о релизе на русском языке.
- Реализован release-gitea.ps1 для автоматизированного релиза Gitea с помощью PowerShell.
- Создан release-gitea.sh для автоматизированного релиза Gitea с помощью Bash.
- Добавлен release.sh для сборки и маркировки релизов с поддержкой нескольких платформ.
- Улучшен пользовательский интерфейс благодаря информативному логированию и обработке ошибок.
- Добавлена ​​поддержка переменных окружения и управления конфигурацией.
- Добавлена ​​функция создания архивов и загрузки ресурсов в Gitea.
This commit is contained in:
Dmitriy Fofanov
2025-11-05 09:33:12 +03:00
parent f328d67080
commit ec65cfd05a
43 changed files with 5792 additions and 2 deletions

View File

@@ -0,0 +1,259 @@
package compressors
import (
"fmt"
"image"
"image/jpeg"
"image/png"
"io"
"os"
"path/filepath"
"strings"
"github.com/nfnt/resize"
)
// ImageCompressor интерфейс для сжатия изображений
type ImageCompressor interface {
CompressJPEG(inputPath, outputPath string, quality int) error
CompressPNG(inputPath, outputPath string, quality int) error
}
// DefaultImageCompressor реализация компрессора изображений
type DefaultImageCompressor struct{}
// NewImageCompressor создает новый компрессор изображений
func NewImageCompressor() ImageCompressor {
return &DefaultImageCompressor{}
}
// CompressJPEG сжимает JPEG файл с указанным качеством
func (c *DefaultImageCompressor) CompressJPEG(inputPath, outputPath string, quality int) error {
// Открываем исходный файл
inputFile, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("не удалось открыть файл %s: %w", inputPath, err)
}
defer inputFile.Close()
// Декодируем изображение
img, err := jpeg.Decode(inputFile)
if err != nil {
return fmt.Errorf("не удалось декодировать JPEG файл %s: %w", inputPath, err)
}
// Получаем размер исходного файла для сравнения
inputFileInfo, err := inputFile.Stat()
if err != nil {
return fmt.Errorf("не удалось получить информацию о файле %s: %w", inputPath, err)
}
originalSize := inputFileInfo.Size()
// Вычисляем новый размер на основе качества
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
// Более агрессивное уменьшение размера для достижения реального сжатия
// quality 10 -> 0.5 (50%), quality 50 -> 0.9 (90%)
scaleFactor := 0.5 + float64(quality-10)/40.0*0.4
if scaleFactor > 1.0 {
scaleFactor = 1.0
}
newWidth := uint(float64(width) * scaleFactor)
newHeight := uint(float64(height) * scaleFactor)
// Изменяем размер изображения только если есть реальная польза
var finalImg image.Image
if newWidth < uint(width) && newHeight < uint(height) {
finalImg = resize.Resize(newWidth, newHeight, img, resize.Lanczos3)
} else {
finalImg = img
}
// Маппинг качества: 10->30, 30->55, 50->75 (более консервативно)
jpegQuality := 20 + int(float64(quality-10)/40.0*55.0)
if jpegQuality < 20 {
jpegQuality = 20
}
if jpegQuality > 75 {
jpegQuality = 75
}
// Создаем временный файл для проверки результата
tmpPath := outputPath + ".tmp"
tmpFile, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("не удалось создать временный файл: %w", err)
}
// Кодируем во временный файл
options := &jpeg.Options{Quality: jpegQuality}
err = jpeg.Encode(tmpFile, finalImg, options)
tmpFile.Close()
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось закодировать JPEG: %w", err)
}
// Проверяем размер результата
tmpInfo, err := os.Stat(tmpPath)
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось получить информацию о временном файле: %w", err)
}
// Если сжатие неэффективно (файл больше или почти такой же), копируем оригинал
if tmpInfo.Size() >= originalSize*95/100 {
os.Remove(tmpPath)
// Копируем оригинал
inputFile.Seek(0, 0)
outputFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("не удалось создать выходной файл: %w", err)
}
defer outputFile.Close()
_, err = io.Copy(outputFile, inputFile)
if err != nil {
return fmt.Errorf("не удалось скопировать файл: %w", err)
}
return nil
}
// Переименовываем временный файл в выходной
err = os.Rename(tmpPath, outputPath)
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось переименовать временный файл: %w", err)
}
return nil
}
// CompressPNG сжимает PNG файл с указанным качеством
func (c *DefaultImageCompressor) CompressPNG(inputPath, outputPath string, quality int) error {
// Открываем исходный файл
inputFile, err := os.Open(inputPath)
if err != nil {
return fmt.Errorf("не удалось открыть файл %s: %w", inputPath, err)
}
defer inputFile.Close()
// Получаем размер исходного файла для сравнения
inputFileInfo, err := inputFile.Stat()
if err != nil {
return fmt.Errorf("не удалось получить информацию о файле %s: %w", inputPath, err)
}
originalSize := inputFileInfo.Size()
// Декодируем изображение
img, err := png.Decode(inputFile)
if err != nil {
return fmt.Errorf("не удалось декодировать PNG файл %s: %w", inputPath, err)
}
// Вычисляем новый размер на основе качества
bounds := img.Bounds()
width := bounds.Dx()
height := bounds.Dy()
// Более консервативное масштабирование для PNG
// quality 10 -> 0.6 (60%), quality 50 -> 0.9 (90%)
scaleFactor := 0.6 + float64(quality-10)/40.0*0.3
if scaleFactor > 1.0 {
scaleFactor = 1.0
}
newWidth := uint(float64(width) * scaleFactor)
newHeight := uint(float64(height) * scaleFactor)
// Не изменяем размер для маленьких изображений
if width < 400 && height < 400 {
newWidth = uint(width)
newHeight = uint(height)
}
// Изменяем размер изображения только если это даст выигрыш
var finalImg image.Image
if newWidth < uint(width) && newHeight < uint(height) {
finalImg = resize.Resize(newWidth, newHeight, img, resize.Lanczos3)
} else {
finalImg = img
}
// Создаем временный файл для проверки результата
tmpPath := outputPath + ".tmp"
tmpFile, err := os.Create(tmpPath)
if err != nil {
return fmt.Errorf("не удалось создать временный файл: %w", err)
}
// Для PNG используем максимальное сжатие
encoder := &png.Encoder{
CompressionLevel: png.BestCompression,
}
err = encoder.Encode(tmpFile, finalImg)
tmpFile.Close()
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось закодировать PNG: %w", err)
}
// Проверяем размер результата
tmpInfo, err := os.Stat(tmpPath)
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось получить информацию о временном файле: %w", err)
}
// Если сжатие неэффективно (файл больше или почти такой же), копируем оригинал
if tmpInfo.Size() >= originalSize*95/100 {
os.Remove(tmpPath)
// Копируем оригинал
inputFile.Seek(0, 0)
outputFile, err := os.Create(outputPath)
if err != nil {
return fmt.Errorf("не удалось создать выходной файл: %w", err)
}
defer outputFile.Close()
_, err = io.Copy(outputFile, inputFile)
if err != nil {
return fmt.Errorf("не удалось скопировать файл: %w", err)
}
return nil
}
// Переименовываем временный файл в выходной
err = os.Rename(tmpPath, outputPath)
if err != nil {
os.Remove(tmpPath)
return fmt.Errorf("не удалось переименовать временный файл: %w", err)
}
return nil
}
// IsImageFile проверяет, является ли файл изображением поддерживаемого формата
func IsImageFile(filename string) bool {
ext := strings.ToLower(filepath.Ext(filename))
return ext == ".jpg" || ext == ".jpeg" || ext == ".png"
}
// GetImageFormat возвращает формат изображения по расширению файла
func GetImageFormat(filename string) string {
ext := strings.ToLower(filepath.Ext(filename))
switch ext {
case ".jpg", ".jpeg":
return "jpeg"
case ".png":
return "png"
default:
return ""
}
}

View File

@@ -0,0 +1,69 @@
package compressors
import (
"fmt"
"os"
"github.com/pdfcpu/pdfcpu/pkg/api"
"compressor/internal/domain/entities"
)
// PDFCPUCompressor реализация компрессора с использованием PDFCPU
type PDFCPUCompressor struct{}
// NewPDFCPUCompressor создает новый PDFCPU компрессор
func NewPDFCPUCompressor() *PDFCPUCompressor {
return &PDFCPUCompressor{}
}
// Compress сжимает PDF файл используя PDFCPU библиотеку
func (p *PDFCPUCompressor) Compress(inputPath, outputPath string, config *entities.CompressionConfig) (*entities.CompressionResult, error) {
fmt.Printf("🔄 Сжатие PDF с уровнем %d%% (PDFCPU)...\n", config.Level)
// Получаем исходный размер файла
originalInfo, err := os.Stat(inputPath)
if err != nil {
return nil, fmt.Errorf("ошибка получения информации об исходном файле: %w", err)
}
// Применяем настройки в зависимости от уровня сжатия
if config.ImageCompression {
fmt.Printf("📸 Включено сжатие изображений (качество: %d%%)\n", config.ImageQuality)
}
if config.RemoveDuplicates {
fmt.Println("🔄 Удаление дубликатов объектов")
}
// Выполняем оптимизацию с базовыми настройками
err = api.OptimizeFile(inputPath, outputPath, nil)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка оптимизации PDFCPU: %w", err)
}
// Получаем размер сжатого файла
compressedInfo, err := os.Stat(outputPath)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка получения информации о сжатом файле: %w", err)
}
result := &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
CompressedSize: compressedInfo.Size(),
Success: true,
}
result.CalculateCompressionRatio()
fmt.Printf("✅ Сжатие завершено: %s\n", outputPath)
return result, nil
}

View File

@@ -0,0 +1,145 @@
package compressors
import (
"fmt"
"os"
"github.com/unidoc/unipdf/v3/common"
"github.com/unidoc/unipdf/v3/model"
"github.com/unidoc/unipdf/v3/model/optimize"
"compressor/internal/domain/entities"
)
// UniPDFCompressor реализация компрессора с использованием UniPDF
type UniPDFCompressor struct{}
// NewUniPDFCompressor создает новый UniPDF компрессор
func NewUniPDFCompressor() *UniPDFCompressor {
return &UniPDFCompressor{}
}
// Compress сжимает PDF файл используя UniPDF библиотеку
func (u *UniPDFCompressor) Compress(inputPath, outputPath string, config *entities.CompressionConfig) (*entities.CompressionResult, error) {
fmt.Printf("🔄 Сжатие PDF с уровнем %d%% (UniPDF)...\n", config.Level)
// Инициализируем логгер
common.SetLogger(common.NewConsoleLogger(common.LogLevelInfo))
// Проверяем лицензионный ключ из конфигурации или переменной окружения
licenseKey := config.UniPDFLicenseKey
if licenseKey == "" {
licenseKey = os.Getenv("UNIDOC_LICENSE_API_KEY")
}
if licenseKey == "" {
return &entities.CompressionResult{
OriginalSize: 0,
Success: false,
Error: fmt.Errorf("UniPDF требует лицензионный ключ. Установите его в конфигурации или в переменной UNIDOC_LICENSE_API_KEY. Используйте алгоритм 'pdfcpu' для бесплатной обработки или получите ключ на https://cloud.unidoc.io"),
}, fmt.Errorf("UniPDF лицензия не настроена. Установите лицензионный ключ в конфигурации или используйте алгоритм 'pdfcpu'")
}
// Устанавливаем лицензионный ключ
fmt.Printf("🔑 Устанавливаем лицензионный ключ UniPDF...\n")
os.Setenv("UNIDOC_LICENSE_API_KEY", licenseKey) // Получаем исходный размер файла
originalInfo, err := os.Stat(inputPath)
if err != nil {
return nil, fmt.Errorf("ошибка получения информации об исходном файле: %w", err)
}
// Открываем исходный PDF файл
pdfReader, file, err := model.NewPdfReaderFromFile(inputPath, nil)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка открытия файла: %w", err)
}
defer file.Close()
// Создаем writer с оптимизацией
pdfWriter := model.NewPdfWriter()
// Настраиваем оптимизацию в зависимости от уровня сжатия
optimizer := optimize.New(optimize.Options{
CombineDuplicateDirectObjects: true,
CombineIdenticalIndirectObjects: true,
ImageUpperPPI: float64(150 - config.Level), // чем выше уровень, тем ниже PPI
ImageQuality: 100 - config.Level, // чем выше уровень, тем ниже качество
})
pdfWriter.SetOptimizer(optimizer)
// Копируем страницы
numPages, err := pdfReader.GetNumPages()
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка получения количества страниц: %w", err)
}
for i := 1; i <= numPages; i++ {
page, err := pdfReader.GetPage(i)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка получения страницы %d: %w", i, err)
}
err = pdfWriter.AddPage(page)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка добавления страницы %d: %w", i, err)
}
}
// Сохраняем оптимизированный файл
outputFile, err := os.Create(outputPath)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка создания выходного файла: %w", err)
}
defer outputFile.Close()
err = pdfWriter.Write(outputFile)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка записи файла: %w", err)
}
// Получаем размер сжатого файла
compressedInfo, err := os.Stat(outputPath)
if err != nil {
return &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
Success: false,
Error: err,
}, fmt.Errorf("ошибка получения информации о сжатом файле: %w", err)
}
result := &entities.CompressionResult{
OriginalSize: originalInfo.Size(),
CompressedSize: compressedInfo.Size(),
Success: true,
}
result.CalculateCompressionRatio()
fmt.Printf("✅ Сжатие завершено: %s\n", outputPath)
return result, nil
}

View File

@@ -0,0 +1,74 @@
package config
import (
"compressor/internal/domain/entities"
"os"
"gopkg.in/yaml.v3"
)
// Repository реализация репозитория конфигурации
type Repository struct{}
// NewRepository создает новый репозиторий конфигурации
func NewRepository() *Repository {
return &Repository{}
}
// Load загружает конфигурацию из файла
func (r *Repository) Load(configPath string) (*entities.Config, error) {
// Если файл не существует, создаем конфигурацию по умолчанию
if _, err := os.Stat(configPath); os.IsNotExist(err) {
return r.createDefaultConfig(), nil
}
data, err := os.ReadFile(configPath)
if err != nil {
return nil, err
}
var config entities.Config
if err := yaml.Unmarshal(data, &config); err != nil {
return nil, err
}
return &config, nil
}
// Save сохраняет конфигурацию в файл
func (r *Repository) Save(configPath string, config *entities.Config) error {
data, err := yaml.Marshal(config)
if err != nil {
return err
}
return os.WriteFile(configPath, data, 0644)
}
// createDefaultConfig создает конфигурацию по умолчанию
func (r *Repository) createDefaultConfig() *entities.Config {
return &entities.Config{
Scanner: entities.ScannerConfig{
SourceDirectory: "./pdfs",
TargetDirectory: "./compressed",
ReplaceOriginal: false,
},
Compression: entities.AppCompressionConfig{
Level: 50,
Algorithm: "pdfcpu",
AutoStart: false,
},
Processing: entities.ProcessingConfig{
ParallelWorkers: 2,
TimeoutSeconds: 30,
RetryAttempts: 3,
},
Output: entities.OutputConfig{
LogLevel: "info",
ProgressBar: true,
LogToFile: true,
LogFileName: "compressor.log",
LogMaxSizeMB: 10,
},
}
}

View File

@@ -0,0 +1,110 @@
package logging
import (
"fmt"
"log"
"os"
"strings"
)
// FileLogger реализация логгера в файл
type FileLogger struct {
file *os.File
logger *log.Logger
logLevel string
}
// NewFileLogger создает новый файловый логгер
func NewFileLogger(filename, logLevel string, maxSizeMB int, logToFile bool) (*FileLogger, error) {
if !logToFile {
return nil, nil
}
file, err := os.OpenFile(filename, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0666)
if err != nil {
return nil, err
}
logger := log.New(file, "", log.LstdFlags)
return &FileLogger{
file: file,
logger: logger,
logLevel: strings.ToLower(logLevel),
}, nil
}
// Debug логирует отладочное сообщение
func (l *FileLogger) Debug(format string, args ...interface{}) {
if l.shouldLog("debug") {
l.writeLog("DEBUG", format, args...)
}
}
// Info логирует информационное сообщение
func (l *FileLogger) Info(format string, args ...interface{}) {
if l.shouldLog("info") {
l.writeLog("INFO", format, args...)
}
}
// Warning логирует предупреждение
func (l *FileLogger) Warning(format string, args ...interface{}) {
if l.shouldLog("warning") {
l.writeLog("WARNING", format, args...)
}
}
// Error логирует ошибку
func (l *FileLogger) Error(format string, args ...interface{}) {
if l.shouldLog("error") {
l.writeLog("ERROR", format, args...)
}
}
// Success логирует успешное выполнение
func (l *FileLogger) Success(format string, args ...interface{}) {
if l.shouldLog("info") {
l.writeLog("SUCCESS", format, args...)
}
}
// Close закрывает логгер
func (l *FileLogger) Close() error {
if l.file != nil {
return l.file.Close()
}
return nil
}
// writeLog записывает лог
func (l *FileLogger) writeLog(level, format string, args ...interface{}) {
if l.logger == nil {
return
}
message := fmt.Sprintf(format, args...)
l.logger.Printf("[%s] %s", level, message)
}
// shouldLog проверяет, нужно ли логировать на данном уровне
func (l *FileLogger) shouldLog(level string) bool {
levels := map[string]int{
"debug": 0,
"info": 1,
"warning": 2,
"error": 3,
}
currentLevel, ok := levels[l.logLevel]
if !ok {
currentLevel = 1 // default to info
}
messageLevel, ok := levels[level]
if !ok {
return false
}
return messageLevel >= currentLevel
}

View File

@@ -0,0 +1,24 @@
package repositories
import (
"compressor/internal/domain/entities"
)
// ConfigRepository реализация репозитория конфигурации
type ConfigRepository struct{}
// NewConfigRepository создает новый репозиторий конфигурации
func NewConfigRepository() *ConfigRepository {
return &ConfigRepository{}
}
// GetCompressionConfig получает конфигурацию сжатия по уровню
func (r *ConfigRepository) GetCompressionConfig(level int) (*entities.CompressionConfig, error) {
config := entities.NewCompressionConfig(level)
return config, nil
}
// ValidateConfig валидирует конфигурацию
func (r *ConfigRepository) ValidateConfig(config *entities.CompressionConfig) error {
return config.Validate()
}

View File

@@ -0,0 +1,69 @@
package repositories
import (
"io/fs"
"os"
"path/filepath"
"sort"
"strings"
"compressor/internal/domain/entities"
)
// FileSystemRepository реализация репозитория для работы с файловой системой
type FileSystemRepository struct{}
// NewFileSystemRepository создает новый репозиторий файловой системы
func NewFileSystemRepository() *FileSystemRepository {
return &FileSystemRepository{}
}
// GetFileInfo получает информацию о PDF файле
func (r *FileSystemRepository) GetFileInfo(path string) (*entities.PDFDocument, error) {
info, err := os.Stat(path)
if err != nil {
return nil, err
}
return &entities.PDFDocument{
Path: path,
Size: info.Size(),
ModifiedTime: info.ModTime(),
Pages: 0, // TODO: Можно добавить определение количества страниц
}, nil
}
// FileExists проверяет существование файла
func (r *FileSystemRepository) FileExists(path string) bool {
_, err := os.Stat(path)
return !os.IsNotExist(err)
}
// CreateDirectory создает директорию
func (r *FileSystemRepository) CreateDirectory(path string) error {
return os.MkdirAll(path, 0755)
}
// ListPDFFiles возвращает список PDF файлов в директории и всех подпапках
func (r *FileSystemRepository) ListPDFFiles(directory string) ([]string, error) {
var pdfFiles []string
err := filepath.WalkDir(directory, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil
}
if d.IsDir() {
return nil
}
if strings.EqualFold(filepath.Ext(d.Name()), ".pdf") {
pdfFiles = append(pdfFiles, path)
}
return nil
})
if err != nil {
return nil, err
}
sort.Strings(pdfFiles)
return pdfFiles, nil
}