feat(frontend): Phase 2 & 3 - Client-side document processing utilities

Created comprehensive document processing library:

� src/lib/document-utils/
├── processor.ts - Главный процессор (интеллектуальная обработка)
├── ocr.ts - OCR через Tesseract.js (rus+eng)
├── pdf.ts - PDF операции (extract text, thumbnail, split, merge, rotate)
├── docx.ts - DOCX извлечение текста (mammoth.js)
├── image.ts - Image processing (compress, crop, rotate, thumbnail)
└── index.ts - Экспорты

Возможности:
✅ Автоматическое определение типа файла
✅ OCR для изображений (client-side, не нагружает сервер)
✅ Извлечение текста из PDF и DOCX
✅ Сжатие изображений перед загрузкой
✅ Генерация thumbnails
✅ Progress callbacks для UI
✅ Оценка времени обработки
✅ TypeScript типы для всех функций

Преимущества перед server-side:
- Моментальная обратная связь
- Не нагружает backend
- Приватность (файл не уходит на сервер до загрузки)
- Экономия трафика (сжатие до отправки)
- Можно показать preview extracted text ДО upload

Phase 2 & 3 Complete!
This commit is contained in:
igor_nadein 2026-02-28 09:29:47 +03:00
parent 82762d3ce4
commit 047e1c1763
6 changed files with 850 additions and 0 deletions

View File

@ -0,0 +1,55 @@
/**
* DOCX text extraction using mammoth.js
*/
import mammoth from 'mammoth';
/**
* Извлекает текст из DOCX файла
*
* @param file - DOCX файл
* @returns Извлеченный текст
*/
export async function extractDOCXText(file: File): Promise<string> {
try {
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.extractRawText({ arrayBuffer });
if (result.messages.length > 0) {
console.warn('[DOCX] Warnings:', result.messages);
}
return result.value;
} catch (error) {
console.error('[DOCX] Extraction error:', error);
throw new Error('Ошибка извлечения текста из DOCX');
}
}
/**
* Извлекает HTML из DOCX файла (для rich preview)
*
* @param file - DOCX файл
* @returns HTML строка
*/
export async function extractDOCXHTML(file: File): Promise<string> {
try {
const arrayBuffer = await file.arrayBuffer();
const result = await mammoth.convertToHtml({ arrayBuffer });
return result.value;
} catch (error) {
console.error('[DOCX] HTML extraction error:', error);
throw new Error('Ошибка конвертации DOCX в HTML');
}
}
/**
* Проверяет, является ли файл DOCX
*/
export function isDOCXFile(file: File): boolean {
return (
file.type === 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' ||
file.name.toLowerCase().endsWith('.docx')
);
}

View File

@ -0,0 +1,242 @@
/**
* Client-side image processing utilities
* Compression, resize, crop, rotate
*/
export interface ImageCompressOptions {
maxSizeMB?: number;
maxWidthOrHeight?: number;
quality?: number;
}
export interface CropArea {
x: number;
y: number;
width: number;
height: number;
}
/**
* Сжимает изображение перед загрузкой
*
* @param file - Файл изображения
* @param options - Опции сжатия
* @returns Сжатое изображение как Blob
*/
export async function compressImage(
file: File,
options: ImageCompressOptions = {}
): Promise<Blob> {
const {
maxSizeMB = 10,
maxWidthOrHeight = 2048,
quality = 0.85
} = options;
try {
const img = await createImageBitmap(file);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// Вычисляем новые размеры с сохранением пропорций
let width = img.width;
let height = img.height;
if (width > maxWidthOrHeight || height > maxWidthOrHeight) {
const ratio = Math.min(
maxWidthOrHeight / width,
maxWidthOrHeight / height
);
width = Math.floor(width * ratio);
height = Math.floor(height * ratio);
}
canvas.width = width;
canvas.height = height;
// Рисуем изображение
ctx.drawImage(img, 0, 0, width, height);
// Конвертируем в Blob
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
// Проверяем размер
const sizeMB = blob.size / (1024 * 1024);
if (sizeMB > maxSizeMB) {
// Если еще слишком большой, рекурсивно сжимаем с меньшим quality
const newQuality = quality * 0.8;
if (newQuality < 0.1) {
reject(new Error('Не удалось сжать изображение до требуемого размера'));
} else {
compressImage(file, { ...options, quality: newQuality })
.then(resolve)
.catch(reject);
}
} else {
resolve(blob);
}
} else {
reject(new Error('Ошибка создания Blob'));
}
},
'image/jpeg',
quality
);
});
} catch (error) {
console.error('[Image] Compression error:', error);
throw new Error('Ошибка сжатия изображения');
}
}
/**
* Обрезает изображение
*
* @param file - Файл изображения
* @param area - Область обрезки
* @returns Обрезанное изображение как Blob
*/
export async function cropImage(
file: File,
area: CropArea
): Promise<Blob> {
try {
const img = await createImageBitmap(file);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
canvas.width = area.width;
canvas.height = area.height;
ctx.drawImage(
img,
area.x,
area.y,
area.width,
area.height,
0,
0,
area.width,
area.height
);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Ошибка создания Blob'));
}
},
file.type,
0.95
);
});
} catch (error) {
console.error('[Image] Crop error:', error);
throw new Error('Ошибка обрезки изображения');
}
}
/**
* Поворачивает изображение
*
* @param file - Файл изображения
* @param degrees - Угол поворота (90, 180, 270, -90)
* @returns Повернутое изображение как Blob
*/
export async function rotateImage(
file: File,
degrees: number
): Promise<Blob> {
try {
const img = await createImageBitmap(file);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
// Для 90 и 270 градусов меняем размеры canvas
if (degrees === 90 || degrees === -90 || degrees === 270) {
canvas.width = img.height;
canvas.height = img.width;
} else {
canvas.width = img.width;
canvas.height = img.height;
}
// Поворачиваем
ctx.translate(canvas.width / 2, canvas.height / 2);
ctx.rotate((degrees * Math.PI) / 180);
ctx.drawImage(img, -img.width / 2, -img.height / 2);
return new Promise((resolve, reject) => {
canvas.toBlob(
(blob) => {
if (blob) {
resolve(blob);
} else {
reject(new Error('Ошибка создания Blob'));
}
},
file.type,
0.95
);
});
} catch (error) {
console.error('[Image] Rotate error:', error);
throw new Error('Ошибка поворота изображения');
}
}
/**
* Генерирует thumbnail изображения
*
* @param file - Файл изображения
* @param maxSize - Максимальный размер (ширина или высота)
* @returns Data URL thumbnail
*/
export async function generateImageThumbnail(
file: File,
maxSize: number = 200
): Promise<string> {
try {
const img = await createImageBitmap(file);
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d')!;
const ratio = Math.min(maxSize / img.width, maxSize / img.height);
const width = Math.floor(img.width * ratio);
const height = Math.floor(img.height * ratio);
canvas.width = width;
canvas.height = height;
ctx.drawImage(img, 0, 0, width, height);
return canvas.toDataURL('image/jpeg', 0.7);
} catch (error) {
console.error('[Image] Thumbnail error:', error);
throw new Error('Ошибка создания thumbnail');
}
}
/**
* Проверяет, является ли файл изображением
*/
export function isImageFile(file: File): boolean {
return file.type.startsWith('image/');
}
/**
* Получает размеры изображения
*/
export async function getImageDimensions(
file: File
): Promise<{ width: number; height: number }> {
const img = await createImageBitmap(file);
return {
width: img.width,
height: img.height
};
}

View File

@ -0,0 +1,60 @@
/**
* Document Utils
* Утилиты для client-side обработки документов
*
* Возможности:
* - OCR (Tesseract.js) - rus + eng
* - PDF text extraction & operations (pdf-lib + pdf.js)
* - DOCX text extraction (mammoth.js)
* - Image compression & processing (Canvas API)
* - Intelligent document processor
*/
// Main processor
export {
processDocument,
needsProcessing,
estimateProcessingTime,
type ProcessingResult,
type ProcessingProgress,
type ProcessDocumentOptions
} from './processor';
// OCR
export {
performOCR,
isOCRSupported,
estimateOCRTime,
type OCRProgress,
type OCROptions
} from './ocr';
// PDF
export {
extractPDFText,
generatePDFThumbnail,
splitPDF,
mergePDFs,
rotatePDF,
getPDFInfo,
type PDFTextExtractionProgress
} from './pdf';
// DOCX
export {
extractDOCXText,
extractDOCXHTML,
isDOCXFile
} from './docx';
// Image
export {
compressImage,
cropImage,
rotateImage,
generateImageThumbnail,
isImageFile,
getImageDimensions,
type ImageCompressOptions,
type CropArea
} from './image';

View File

@ -0,0 +1,84 @@
/**
* Client-side OCR using Tesseract.js
* Извлекает текст из изображений прямо в браузере
*/
import Tesseract from 'tesseract.js';
export interface OCRProgress {
status: string;
progress: number;
}
export interface OCROptions {
languages?: string[];
onProgress?: (progress: OCRProgress) => void;
}
/**
* Выполняет OCR для изображения
*
* @param file - Файл изображения
* @param options - Опции OCR
* @returns Извлеченный текст
*/
export async function performOCR(
file: File,
options: OCROptions = {}
): Promise<string> {
const {
languages = ['rus', 'eng'], // Русский + английский по умолчанию
onProgress
} = options;
try {
const worker = await Tesseract.createWorker({
logger: (m) => {
if (onProgress) {
onProgress({
status: m.status,
progress: m.progress * 100
});
}
}
});
// Загружаем языки
await worker.loadLanguage(languages.join('+'));
await worker.initialize(languages.join('+'));
// Распознаем текст
const { data } = await worker.recognize(file);
await worker.terminate();
return data.text;
} catch (error) {
console.error('[OCR] Error:', error);
throw new Error('Ошибка распознавания текста');
}
}
/**
* Проверяет, поддерживается ли OCR для данного файла
*/
export function isOCRSupported(file: File): boolean {
const supportedTypes = [
'image/jpeg',
'image/jpg',
'image/png',
'image/bmp',
'image/tiff'
];
return supportedTypes.includes(file.type);
}
/**
* Оценивает время OCR в секундах (приблизительно)
*/
export function estimateOCRTime(file: File): number {
// Приблизительно 2-5 секунд на MB
const sizeMB = file.size / (1024 * 1024);
return Math.ceil(sizeMB * 3);
}

View File

@ -0,0 +1,201 @@
/**
* Client-side PDF utilities
* Извлечение текста, операции с PDF (split, merge, rotate)
*/
import * as pdfjsLib from 'pdfjs-dist';
import { PDFDocument, degrees } from 'pdf-lib';
// Настройка worker для PDF.js
pdfjsLib.GlobalWorkerOptions.workerSrc = `//unpkg.com/pdfjs-dist@${pdfjsLib.version}/build/pdf.worker.min.mjs`;
export interface PDFTextExtractionProgress {
currentPage: number;
totalPages: number;
progress: number;
}
/**
* Извлекает текст из PDF файла
*
* @param file - PDF файл
* @param onProgress - Callback прогресса
* @returns Извлеченный текст
*/
export async function extractPDFText(
file: File,
onProgress?: (progress: PDFTextExtractionProgress) => void
): Promise<string> {
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const totalPages = pdf.numPages;
let fullText = '';
for (let i = 1; i <= totalPages; i++) {
const page = await pdf.getPage(i);
const textContent = await page.getTextContent();
const pageText = textContent.items
.map((item: any) => item.str)
.join(' ');
fullText += pageText + '\n\n';
if (onProgress) {
onProgress({
currentPage: i,
totalPages,
progress: (i / totalPages) * 100
});
}
}
return fullText.trim();
} catch (error) {
console.error('[PDF] Text extraction error:', error);
throw new Error('Ошибка извлечения текста из PDF');
}
}
/**
* Генерирует thumbnail первой страницы PDF
*
* @param file - PDF файл
* @param scale - Масштаб (по умолчанию 0.5)
* @returns Data URL изображения
*/
export async function generatePDFThumbnail(
file: File,
scale: number = 0.5
): Promise<string> {
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const page = await pdf.getPage(1);
const viewport = page.getViewport({ scale });
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d')!;
canvas.width = viewport.width;
canvas.height = viewport.height;
await page.render({
canvasContext: context,
viewport
}).promise;
return canvas.toDataURL('image/jpeg', 0.7);
} catch (error) {
console.error('[PDF] Thumbnail generation error:', error);
throw new Error('Ошибка создания превью PDF');
}
}
/**
* Разделяет PDF файл на отдельные страницы
*
* @param file - PDF файл
* @param pageNumbers - Номера страниц для извлечения (1-based)
* @returns Новый PDF как Uint8Array
*/
export async function splitPDF(
file: File,
pageNumbers: number[]
): Promise<Uint8Array> {
try {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const newPdf = await PDFDocument.create();
for (const pageNum of pageNumbers) {
const [page] = await newPdf.copyPages(pdfDoc, [pageNum - 1]);
newPdf.addPage(page);
}
return await newPdf.save();
} catch (error) {
console.error('[PDF] Split error:', error);
throw new Error('Ошибка разделения PDF');
}
}
/**
* Объединяет несколько PDF файлов
*
* @param files - Массив PDF файлов
* @returns Объединенный PDF как Uint8Array
*/
export async function mergePDFs(files: File[]): Promise<Uint8Array> {
try {
const mergedPdf = await PDFDocument.create();
for (const file of files) {
const arrayBuffer = await file.arrayBuffer();
const pdf = await PDFDocument.load(arrayBuffer);
const pages = await mergedPdf.copyPages(pdf, pdf.getPageIndices());
pages.forEach(page => mergedPdf.addPage(page));
}
return await mergedPdf.save();
} catch (error) {
console.error('[PDF] Merge error:', error);
throw new Error('Ошибка объединения PDF');
}
}
/**
* Поворачивает все страницы PDF
*
* @param file - PDF файл
* @param rotation - Угол поворота (90, 180, 270)
* @returns Повернутый PDF как Uint8Array
*/
export async function rotatePDF(
file: File,
rotation: 90 | 180 | 270
): Promise<Uint8Array> {
try {
const arrayBuffer = await file.arrayBuffer();
const pdfDoc = await PDFDocument.load(arrayBuffer);
const pages = pdfDoc.getPages();
pages.forEach(page => {
page.setRotation(degrees(rotation));
});
return await pdfDoc.save();
} catch (error) {
console.error('[PDF] Rotate error:', error);
throw new Error('Ошибка поворота PDF');
}
}
/**
* Получает информацию о PDF файле
*/
export async function getPDFInfo(file: File): Promise<{
numPages: number;
title?: string;
author?: string;
creationDate?: Date;
}> {
try {
const arrayBuffer = await file.arrayBuffer();
const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
const metadata = await pdf.getMetadata();
return {
numPages: pdf.numPages,
title: metadata.info?.Title,
author: metadata.info?.Author,
creationDate: metadata.info?.CreationDate
? new Date(metadata.info.CreationDate)
: undefined
};
} catch (error) {
console.error('[PDF] Info error:', error);
throw new Error('Ошибка получения информации о PDF');
}
}

View File

@ -0,0 +1,208 @@
/**
* Main document processor
* Интеллектуальная обработка документов перед загрузкой на сервер
*/
import { performOCR, isOCRSupported } from './ocr';
import { extractPDFText, generatePDFThumbnail, getPDFInfo } from './pdf';
import { extractDOCXText, isDOCXFile } from './docx';
import { compressImage, generateImageThumbnail, isImageFile } from './image';
export interface ProcessingResult {
extractedText: string;
thumbnail?: string;
processedFile?: File | Blob;
metadata: {
originalSize: number;
processedSize?: number;
mimeType: string;
hasText: boolean;
processingTime: number;
ocrApplied?: boolean;
compressed?: boolean;
};
}
export interface ProcessingProgress {
stage: 'compressing' | 'ocr' | 'extracting_text' | 'generating_thumbnail' | 'complete';
progress: number;
message: string;
}
export interface ProcessDocumentOptions {
enableOCR?: boolean;
enableCompression?: boolean;
enableTextExtraction?: boolean;
enableThumbnail?: boolean;
maxImageSizeMB?: number;
onProgress?: (progress: ProcessingProgress) => void;
}
/**
* Интеллектуально обрабатывает документ перед загрузкой
*
* Автоматически определяет тип файла и выполняет:
* - Сжатие изображений
* - OCR для изображений
* - Извлечение текста из PDF/DOCX
* - Генерацию thumbnails
*
* @param file - Файл для обработки
* @param options - Опции обработки
* @returns Результат обработки
*/
export async function processDocument(
file: File,
options: ProcessDocumentOptions = {}
): Promise<ProcessingResult> {
const {
enableOCR = true,
enableCompression = true,
enableTextExtraction = true,
enableThumbnail = true,
maxImageSizeMB = 10,
onProgress
} = options;
const startTime = Date.now();
let extractedText = '';
let thumbnail: string | undefined;
let processedFile: File | Blob | undefined;
let ocrApplied = false;
let compressed = false;
try {
// 1. Сжатие изображений
if (enableCompression && isImageFile(file)) {
onProgress?.({
stage: 'compressing',
progress: 10,
message: 'Сжатие изображения...'
});
const sizeMB = file.size / (1024 * 1024);
if (sizeMB > maxImageSizeMB) {
processedFile = await compressImage(file, {
maxSizeMB: maxImageSizeMB,
quality: 0.85
});
compressed = true;
}
}
const fileToProcess = processedFile || file;
// 2. Извлечение текста
if (enableTextExtraction) {
onProgress?.({
stage: 'extracting_text',
progress: 30,
message: 'Извлечение текста...'
});
// PDF
if (file.type === 'application/pdf') {
extractedText = await extractPDFText(file, (pdfProgress) => {
onProgress?.({
stage: 'extracting_text',
progress: 30 + (pdfProgress.progress * 0.3),
message: `Обработка страницы ${pdfProgress.currentPage} из ${pdfProgress.totalPages}...`
});
});
}
// DOCX
else if (isDOCXFile(file)) {
extractedText = await extractDOCXText(file);
}
// Images с OCR
else if (enableOCR && isOCRSupported(file)) {
onProgress?.({
stage: 'ocr',
progress: 40,
message: 'Распознавание текста (OCR)...'
});
extractedText = await performOCR(file, {
languages: ['rus', 'eng'],
onProgress: (ocrProgress) => {
onProgress?.({
stage: 'ocr',
progress: 40 + ocrProgress.progress * 0.4,
message: `OCR: ${Math.round(ocrProgress.progress)}%`
});
}
});
ocrApplied = true;
}
}
// 3. Генерация thumbnail
if (enableThumbnail) {
onProgress?.({
stage: 'generating_thumbnail',
progress: 85,
message: 'Создание превью...'
});
if (file.type === 'application/pdf') {
thumbnail = await generatePDFThumbnail(file, 0.5);
} else if (isImageFile(file)) {
thumbnail = await generateImageThumbnail(file, 200);
}
}
onProgress?.({
stage: 'complete',
progress: 100,
message: 'Обработка завершена'
});
const processingTime = Date.now() - startTime;
return {
extractedText,
thumbnail,
processedFile,
metadata: {
originalSize: file.size,
processedSize: processedFile?.size,
mimeType: file.type,
hasText: extractedText.length > 0,
processingTime,
ocrApplied,
compressed
}
};
} catch (error) {
console.error('[DocumentProcessor] Error:', error);
throw error;
}
}
/**
* Быстрая проверка: нужна ли обработка этому файлу
*/
export function needsProcessing(file: File): boolean {
return (
file.type === 'application/pdf' ||
isDOCXFile(file) ||
isImageFile(file)
);
}
/**
* Оценивает время обработки в секундах
*/
export function estimateProcessingTime(file: File): number {
const sizeMB = file.size / (1024 * 1024);
if (file.type === 'application/pdf') {
return Math.ceil(sizeMB * 2); // ~2 сек на MB
} else if (isDOCXFile(file)) {
return Math.ceil(sizeMB * 1); // ~1 сек на MB
} else if (isImageFile(file)) {
return Math.ceil(sizeMB * 3); // ~3 сек на MB (OCR)
}
return 1;
}