Достижение: Добавлены скрипты и документация для релиза PDF Compressor.
- Добавлен release-body.md для подробных заметок о релизе на русском языке. - Реализован release-gitea.ps1 для автоматизированного релиза Gitea с помощью PowerShell. - Создан release-gitea.sh для автоматизированного релиза Gitea с помощью Bash. - Добавлен release.sh для сборки и маркировки релизов с поддержкой нескольких платформ. - Улучшен пользовательский интерфейс благодаря информативному логированию и обработке ошибок. - Добавлена поддержка переменных окружения и управления конфигурацией. - Добавлена функция создания архивов и загрузки ресурсов в Gitea.
This commit is contained in:
259
internal/infrastructure/compressors/image_compressor.go
Normal file
259
internal/infrastructure/compressors/image_compressor.go
Normal 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 ""
|
||||
}
|
||||
}
|
||||
69
internal/infrastructure/compressors/pdfcpu_compressor.go
Normal file
69
internal/infrastructure/compressors/pdfcpu_compressor.go
Normal 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
|
||||
}
|
||||
145
internal/infrastructure/compressors/unipdf_compressor.go
Normal file
145
internal/infrastructure/compressors/unipdf_compressor.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user