From 047e1c1763274f420802bee68d894a9ba771b60b Mon Sep 17 00:00:00 2001 From: igor_nadein Date: Sat, 28 Feb 2026 09:29:47 +0300 Subject: [PATCH] feat(frontend): Phase 2 & 3 - Client-side document processing utilities MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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! --- frontend/src/lib/document-utils/docx.ts | 55 +++++ frontend/src/lib/document-utils/image.ts | 242 +++++++++++++++++++ frontend/src/lib/document-utils/index.ts | 60 +++++ frontend/src/lib/document-utils/ocr.ts | 84 +++++++ frontend/src/lib/document-utils/pdf.ts | 201 +++++++++++++++ frontend/src/lib/document-utils/processor.ts | 208 ++++++++++++++++ 6 files changed, 850 insertions(+) create mode 100644 frontend/src/lib/document-utils/docx.ts create mode 100644 frontend/src/lib/document-utils/image.ts create mode 100644 frontend/src/lib/document-utils/index.ts create mode 100644 frontend/src/lib/document-utils/ocr.ts create mode 100644 frontend/src/lib/document-utils/pdf.ts create mode 100644 frontend/src/lib/document-utils/processor.ts diff --git a/frontend/src/lib/document-utils/docx.ts b/frontend/src/lib/document-utils/docx.ts new file mode 100644 index 00000000..9cd6546d --- /dev/null +++ b/frontend/src/lib/document-utils/docx.ts @@ -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 { + 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 { + 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') + ); +} diff --git a/frontend/src/lib/document-utils/image.ts b/frontend/src/lib/document-utils/image.ts new file mode 100644 index 00000000..eca0fa78 --- /dev/null +++ b/frontend/src/lib/document-utils/image.ts @@ -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 { + 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 { + 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 { + 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 { + 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 + }; +} diff --git a/frontend/src/lib/document-utils/index.ts b/frontend/src/lib/document-utils/index.ts new file mode 100644 index 00000000..81e1534a --- /dev/null +++ b/frontend/src/lib/document-utils/index.ts @@ -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'; diff --git a/frontend/src/lib/document-utils/ocr.ts b/frontend/src/lib/document-utils/ocr.ts new file mode 100644 index 00000000..acbd2501 --- /dev/null +++ b/frontend/src/lib/document-utils/ocr.ts @@ -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 { + 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); +} diff --git a/frontend/src/lib/document-utils/pdf.ts b/frontend/src/lib/document-utils/pdf.ts new file mode 100644 index 00000000..b47f1c45 --- /dev/null +++ b/frontend/src/lib/document-utils/pdf.ts @@ -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 { + 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 { + 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 { + 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 { + 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 { + 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'); + } +} diff --git a/frontend/src/lib/document-utils/processor.ts b/frontend/src/lib/document-utils/processor.ts new file mode 100644 index 00000000..ca3f864c --- /dev/null +++ b/frontend/src/lib/document-utils/processor.ts @@ -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 { + 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; +}