Запись текста с фотографии листа или из аудиозаписи в текстовый файл, доступный для редактирования – довольно часто встречающаяся задача при работе в офисах или учёбы. Для распознавания текстов и аудио в платных сервисах и программах сегодня используются такие подходы, как машинное зрение и распознавание речи с использованием глубоких нейронных сетей.

Детектирование (обнаружение) и классификация символов на изображении осуществляется с использованием различных архитектур свёрточных нейронных сетей [1]. Обработка естественного языка основана на использовании глубоких рекуррентных нейронных сетей, состоящих из ячеек долгой краткосрочной памяти LSTM [2]. При создании соответствующих приложений для работы с текстами, этап реализации нейронных сетей можно пропустить, используя соответствующие свободно распространяемые библиотеки.

В данной статье я хочу поделиться реализацией приложения, позволяющего пользователю преобразовать и сохранить текстовую информацию из изображения листа или аудио-файла.

Архитектура приложения и используемый стек технологий

Архитектура разрабатываемого приложения приведена на рисунке 1.

Рисунок 1 – Архитектура разрабатываемого приложения
Рисунок 1 – Архитектура разрабатываемого приложения

Архитектура на рисунке 1 реализована как клиент-сервер, в парадигме MVC (Model-View-Controller). Все данные разделяются на компоненты трёх видов: модель, представление и контроллер. Такая архитектура позволяет добиться расширяемости, за счёт независимости изменений каждого компонента.

Модуль интерфейса пользователя предназначен для формирования элементов пользовательского интерфейса и вызовов операций обработки данных. Код модуля размещается на сервере и выполняется в браузере пользователя. Пользователь работает с приложением с использованием браузера с поддержкой Java Script, для тестирования клиентской части использовался Google Chrome. В функции для клиентов входят: отображение информации, загрузка на сервер документов, получение результатов с сервера.

Серверные компоненты системы могут функционировать под управлением любого типа операционной системы, разработка и тестирование велась на Windows.

Модуль обработки операций реализует бизнес-логику программной системы. Он обрабатывает запросы пользователей, вызывает операции сервера проведения расчётов и сервера базы данных. Для реализации модуля была выбрана технология Java Servlet.

Для хранения и функционирования модуля обработки операций и модуля интерфейса пользователя был выбран веб сервер Apache Tomcat, обладающий высокой производительностью, гибким функционалом. Кроме того, Apache Tomcat не требует затрат на лицензирование. Взаимодействие между клиентской и серверной частями программной системы осуществляется по протоколу http (браузер на ПК).

Сервер проведения операций обработки изображений и аудио отвечает за задачи, связанные собственно с извлечением текстов из изображений и аудио-записей и их сохранения в текстовый файлы (использованы файлы *.docx). Указанный сервер реализован на языке Python с использованием библиотеки обработки изображений OpenCV и фреймворка TensorFlow.

Для хранения информации программной системы была выбрана система управления базами данных (СУБД) PostgreSQL. Плюсами выбранной СУБД являются отсутствие платы за лицензионное использование и кроссплатформенностью.

После разработки архитектуры были определены интегрированные среды разработки (IDE) компонент сервера и клиентской части. Для сервера проведения расчётов на Python используется PyCharm, для остальных компонент серверной и клиентской части системы - IntelliJ IDEA; обе IDE разработана компанией JetBrains, похожи по функционалу и интерфейсу, являются кроссплатформенными.

Исходный код приложения находится в репозитории, далее раскрою подробнее реализацию основных компонент приложения.

Разработка сервера обработки текстовых и аудио файлов

Для выполнения задачи распознавания текста с изображения используется технология OCR — обнаружение текстового содержимого на изображениях и перевод изображений в закодированный текст, который компьютер может легко понять. Изображение, содержащее текст, сканируется и анализируется, чтобы идентифицировать символы в нем. После идентификации символ преобразуется в машинно‑кодированный текст.

Для целей OCR использовался пакет PyTesseract, являющейся оболочной для Google Tesseract‑OCR Engine. Tesseract использует нейронные сети и двухступенчатый подход к распознаванию. В первый проход происходит распознавание символов. На втором этапе производится заполнение любых символов, которые имеют низкое значение вероятности правильного определения класса, символами, наиболее подходящими по контексту. Этапы выполняются на базе рекуррентной нейронной сети LSTM, наиболее подходящей для обработки естественного языка.

При работе с документом в формате PDF сначала документ извлекается из массива байт, сохраняется как временный файл и читается в память с использованием библиотеки fitz. Статический метод для этой операции приведён ниже.

import fitz

@staticmethod
def __getPDF(dictOfProject, parameters):
    for imageFromList in dictOfProject['InputDocument']:
        byteImage = bytes.fromhex(imageFromList)
        fileName = str(parameters.imagesFolder.joinpath('example.pdf').resolve())
        file = open(fileName, 'wb')
        file.write(byteImage)
        file.close()
        pdfDocument = fitz.open(fileName)

    return pdfDocument

Затем документ постранично сохраняется в изображения в формате PNG, и с использованием библиотеки pytesseract извлекается текст из каждого изображения, сохраняясь в текстовый файл, созданный с использованием библиотеки docx.

Код методов для выполнения OCR:

import os
import docx
import cv2

@staticmethod
def __recognitionText(pdfDocument, parameters):
    mydoc = docx.Document()
    for current_page in range(len(pdfDocument)):
        for image in pdfDocument.get_page_images(current_page):
            xref = image[0]
            fileName = str(parameters.imagesFolder.joinpath("page%s-%s.png" % (current_page, xref)).resolve())
            # читать изображение с помощью OpenCV
            ConvertPdfToTextCalculator.__takeImage(parameters.pytesseract, mydoc,fileName)
    pdfDocument.close()
    return mydoc

@staticmethod
def __takeImage(pytesseract, mydoc,fileName):
    image = cv2.imread(fileName)
    # получаем строку
    pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'
    string = pytesseract.image_to_string(image, lang='rus')
    # добавляем в документ
    mydoc.add_paragraph(string)

Для обработки аудио была использована библиотека Speech Recognition (в коде обозначена как sr), основанная на использовании скрытой марковской модели [3] и представляющая собой инструмент для передачи речевых API (мной был использован Google Speech). Для работы со звуком как с объектом и разбивки его на отрезки по параметру тишины использован высокоуровневый интерфейс pydub, а именно класс AudioSegment и метод split_on_silence.

Метод создания объекта класса AudioSegment:

@staticmethod
def __getWav(dictOfProject, parameters):
    for imageFromList in dictOfProject['InputDocument']:
        byteFile = bytes.fromhex(imageFromList)
        fileName = str(parameters.imagesFolder.joinpath('example.wav').resolve())
        file = open(fileName, 'wb')
        file.write(byteFile)
        file.close()
        sound = parameters.AudioSegment.from_wav(fileName)

    return sound

Метод разбивки объекта на звуки, разделённые тишиной:

@staticmethod
def __getSounds(soundDocument, parameters):
    chunks = parameters.split_on_silence(soundDocument,
                              
                              min_silence_len=500,
                              
                              silence_thresh=soundDocument.dBFS - 15,
                              
                              keep_silence=500,
                              )
    return chunks

Метод расшифровки и записи текстового файла:

def __recognitionSound(chunks, parameters):
    mydoc = docx.Document()
    r = parameters.sr.Recognizer()
    
    for i, audio_chunk in enumerate(chunks, start=1):
        
        chunk_filename = os.path.join(parameters.imagesFolder, f"chunk{i}.wav")
        audio_chunk.export(chunk_filename, format="wav")
        start_time = datetime.now()
        print(datetime.now() - start_time)
        
        with parameters.sr.AudioFile(chunk_filename) as source:
            audio_listened = r.record(source)
            
            try:
                text = r.recognize_google(audio_listened, language=parameters.language)
            except parameters.sr.UnknownValueError as e:
                print("Error:", str(e))
            else:
                text = f"{text.capitalize()}. "
                print(" -- :", text)
                mydoc.add_paragraph(text)
        print("Время выполнения: ")
        print(datetime.now() - start_time)
    return mydoc

Файлы *.pdf или *.wav поступают на сервер от клиента через модуль обработки операций, в виде массива байт. Для передачи данных на сервере использована технология сокетов. Для хранения настроек сервера и обработки входящих запросов был создан отдельный класс NetworkServer.

Основной файл запуска cvserver.py выглядит следующим образом:

import keyboard
from core.clientServer.networkServer import NetworkServer

if __name__ == '__main__':
    server = NetworkServer()
    port = 10000
    server.init(port)
    server.start()
    print("Система запущена!")
    print("Для выхода введите q")
    keyboard.wait("q")
    server.stop()
    server.done()
    print("Работа завершена")

Код класса NetworkServer:

import socket
from threading import Thread

from core.clientsManager import ClientsManager
from core.clientServer.clientHandler import ClientHandler
from core.requests.requestFactory import RequestFactory


class NetworkServer(object):
    def __init__(self):
        self._socket = None
        self._continueWork = False
        self._thread = None
        self._clientsManager = ClientsManager()
        self._requestFactory = RequestFactory()

    def init(self, portNumber):
        self._socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        self._socket.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
        self._socket.settimeout(0.1)
        self._socket.bind(("127.0.0.1", portNumber))
        self._socket.listen(0)

    def done(self):
        self.stop()
        if not (self._socket is None):
            self._socket.close()
            self._socket = None

    def start(self):
        if not (self._thread is None):
            self.stop()
        self._continueWork = True
        self._thread = Thread(target=self.__work)
        self._thread.start()

    def stop(self):
        if self._thread is None:
            return
        self._continueWork = False
        self._thread.join()
        self._thread = None

    def __work(self):
        while self._continueWork:
            try:
                clientSocket, clientAddress = self._socket.accept()
                client = ClientHandler(self, clientSocket)
                self.getClientsManager().Add(client)
                client.run()
            except socket.timeout:
                pass
            except Exception as e:
                print("Exception occur: " + e.__str__())
                raise

    def getClientsManager(self):
        return self._clientsManager

    def getRequestFactory(self):
        return self._requestFactory

Сервер может работать с несколькими потоками за счёт использования класса Thread(). Для выстраивания очередей используется вспомогательный класс ClientsManager.

from threading import Lock


class ClientsManager(object):
    def __init__(self):
        self._clientsLock = Lock()
        self._clients = []

    def Add(self, client):
        self._clientsLock.acquire()
        if not (client in self._clients):
            self._clients.append(client)
        self._clientsLock.release()

    def Remove(self, client):
        self._clientsLock.acquire()
        if client in self._clients:
            self._clients.remove(client)
        self._clientsLock.release()

Для работы с входящими запросами и отсылкой ответов используется класс  ClientHandler.

from threading import Thread

from core.requests.requestResponseBuilder import RequestResponseBuilder


class ClientHandler(object):
    RECEIVE_TIMEOUT = 30

    def __init__(self, parent, clientSocket):
        self._parent = parent
        self._socket = clientSocket
        self._thread = None

    def run(self):
        self._thread = Thread(target=self.__work)
        self._thread.start()

    def __work(self):
        self._socket.settimeout(self.RECEIVE_TIMEOUT)

        requestCode, requestBody = RequestResponseBuilder.readRequest(self._socket)
        responseBody = self._parent.getRequestFactory().handle(requestCode, requestBody)
        RequestResponseBuilder.writeResponse(self._socket, responseBody)

        self._socket.close()
        self._parent.getClientsManager().Remove(self)

Вспомогательный класс RequestFactory выполняет адресацию запросов к классам-обработчикам (RecognitionTextHandler и RecognitionSpeechHandler), в зависимости от кода запроса.

Код RequestFactory:

from typing import Optional

from core.requests.recognitionTextHandler import RecognitionTextHandler
from core.requests.recognitionSpeechHandler import RecognitionSpeechHandler
from core.requests.requestCodes import RequestCodes


class RequestFactory(object):

    def __init__(self) -> None:
        self._handlers = {
            RequestCodes.RecognitionText: RecognitionTextHandler(),
            RequestCodes.RecognitionSpeech: RecognitionSpeechHandler()
        }

    def handle(self, requestCode: int, requestBody: bytearray) -> Optional[bytes]:
        if requestCode in self._handlers:
            response = self._handlers[requestCode].handle(requestBody)
        else:
            response = None
        return response

В класс RequestResponseBuilder вынесены статические методы работы для «упаковки» и «распаковки» запросов и ответов в массивы байт.

Используемые в RequestFactory классы-обработчики запросов по сути нужны для передачи распакованного запроса и инструментов для обработки в классы-калькуляторы для выполнения собственно обработки изображений и аудио, статические методы из которых приведены в начале раздела.

Код класса – обработчика для распознавания текста:

import json
from typing import Optional

import pytesseract

from core.calculators.convertPdfToTextCalculator import ConvertPdfToTextCalculator
from core.optParameters import OptParameters
from core.commonUtils import CommonUtils


class RecognitionTextHandler(object):
    def __init__(self):
        self.pytesseract = pytesseract
        self.pytesseract.pytesseract.tesseract_cmd = r'C:\Program Files\Tesseract-OCR\tesseract.exe'

    def handle(self, packedParameters: bytearray) -> Optional[bytes]:
        if packedParameters is None or len(packedParameters) == 0:
            return None

        parametersStr = packedParameters.decode("utf8")

        parameters = json.loads(parametersStr)

        optParameters = OptParameters()
        optParameters.pytesseract = self.pytesseract
        optParameters.imagesFolder = CommonUtils.getSolutionFolder().joinpath("CVServer").joinpath("main").joinpath("data")
        result = ConvertPdfToTextCalculator.calculate(parameters, optParameters)
        try:
            response = json.dumps(result).encode("utf8")
        except:
            print('Error')

        return response

Код класса – обработчика для распознавания речи:

import json
from typing import Optional

import speech_recognition as sr


from core.calculators.convertWavToTextCalculator import ConvertWavToTextCalculator

from core.optParameters import OptParameters
from core.commonUtils import CommonUtils

from pydub import AudioSegment
from pydub.silence import split_on_silence


class RecognitionSpeechHandler(object):
    def __init__(self):
        self.sr = sr
        self.AudioSegment = AudioSegment
        self.split_on_silence = split_on_silence

    def handle(self, packedParameters: bytearray) -> Optional[bytes]:
        if packedParameters is None or len(packedParameters) == 0:
            return None

        parametersStr = packedParameters.decode("utf8")

        parameters = json.loads(parametersStr)

        optParameters = OptParameters()
        optParameters.AudioSegment = self.AudioSegment
        optParameters.split_on_silence = self.split_on_silence
        optParameters.sr = self.sr
        optParameters.imagesFolder = CommonUtils.getSolutionFolder().joinpath("CVServer").joinpath("main").joinpath("data").joinpath("sound")

        result = ConvertWavToTextCalculator.calculate(parameters, optParameters)
        try:
            response = json.dumps(result).encode("utf8")
        except:
            print('Error')

        return response

Ответ передается в виде словаря со структурой {'result': True/False, 'value': itemshex},  где itemhex – байтовое представление файла с результатами распознавания, к которому применена функция hex().

Опишем структуру базы данных и остальные модули приложения.

Разработка базы данных

Используемая в ходе работы информация храниться в реляционной базе данных. На рисунке 2 приведена ER диаграмма (сущность-связь) спроектированной для системы базы данных.

Рисунок 2 – ER диаграмма базы данных системы
Рисунок 2 – ER диаграмма базы данных системы

Модель данных, приведённая на рисунке 2, содержит 4 сущности – таблицы базы данных системы. Каждый экземпляр каждой сущности имеет идентификатор ID, представляющий собой уникальную строку символов, задаваемую с использованием текстового представления стандарта UUID.

Структура базы данных имеет две группы сущностей: 1) отвечающих за авторизацию и права пользователей; 2) отвечающих за хранение информации по распознаваемым файлам.

Опишем сущности первой группы, отвечающие за параметры безопасности и разграничение уровней доступа в системе. Сущность Users содержит информацию по личным данным пользователя: Name (имя), Surname (фамилия), Email (адрес почты), Username (логин), Password (пароль).

Сущность Roles содержит информацию о ролях, имеет поле Role, принимающие два значения: user и admin.

Связь между Users и Roles осуществляется с использованием таблицы UserRoles, в которой хранятся идентификаторы UserID и RoleID.

Вторая группа включает только лишь одну сущность Documents, содержащую поля UserID (идентификатор для связи с Users), название распознаваемого файла Title и его содержимого FilePDF и результата его расшифровки в текст FileTXT. FilePDF - это документ *.pdf или *.wav в виде массива байт, FileTXT - документ *.docx в виде массива байт.

Для создания тестового варианта базы данных использовался код на Python с использованием SQLAlchemy, позволяющий создать структуру и тестовое наполнения базы данных PostgreSQL. Код для генерации теста находится в папке Tools/ DBGenerate проекта
на Github.

Разработка остальных модулей

Структура клиентской и серверной частей, за исключением сервера проведения операций обработки изображений и аудио, приведена на рисунке 3.

Рисунок 3 – Структура модулей клиентской и серверной частей
Рисунок 3 – Структура модулей клиентской и серверной частей

Ядром модуля обработки операций является класс MainServlet, наследующий абстрактный класс HttpServlet. Он содержит экземпляры внутреннего класса HandlerInfo в виде структуры «словарь» handlers, хранящий все обработчики запросов и способ их распаковки, а также способ упаковки ответов клиенту. Код класса MainServlet:

import classes.RequestCode;
import core.interaction.*;
import handlers.DocumentsHandler;
import handlers.SessionHandler;
import handlers.UsersInfoHandler;
import jakarta.servlet.ServletConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.MultipartConfig;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;

import java.io.IOException;
import java.util.HashMap;
import java.util.Map;


/**
 * Класс сервлета, через который осуществляется взаимодействие клиента и сервера в проекте.
 *
 */
@WebServlet(name = "MainServlet", urlPatterns = "/handler")
@MultipartConfig(
        fileSizeThreshold = 1024 * 1024 * 8,
        maxFileSize = 1024 * 1024 * 8,
        maxRequestSize = 1024 * 1024 * 9
)
public class MainServlet extends HttpServlet {


    private static class HandlerInfo {
        RequestHandler requestHandler;
        RequestExtractor requestExtractor;
        ResponsePacker responsePacker;

        public HandlerInfo(RequestHandler handler, RequestExtractor requestExtractor, ResponsePacker responsePacker) {
            this.requestHandler = handler;
            this.requestExtractor = requestExtractor;
            this.responsePacker = responsePacker;
        }
    }

    private volatile Boolean isInitialized;
    private final Object isInitializedLock = new Object();
    private final Map<String, HandlerInfo> handlers;
    private MainServletEnvironment environment;

    public MainServlet() {
        handlers = new HashMap<>();
    }


    @Override
    public void init(ServletConfig config) throws ServletException {
        super.init(config);
        //Запуск сессии
        if(isInitialized == null) {
            synchronized(isInitializedLock) {
                if(isInitialized == null) {
                    isInitialized = initializeImpl();
                }
            }
        }
        log("Method init =)");
    }

    @Override
    protected void service(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse) throws ServletException, IOException, IOException {
        String command = null;
        try {
            if (isInitialized) {
                command = httpServletRequest.getParameter("cmd");
                HandlerInfo handlerInfo = handlers.get(command);
                if (handlerInfo != null) {
                    Request request = handlerInfo.requestExtractor.extract(httpServletRequest);
                    InnerResponseRecipient responseRecipient = new InnerResponseRecipient();
                    handlerInfo.requestHandler.executeRequest(responseRecipient, request);
                    handlerInfo.responsePacker.pack(request, responseRecipient.response, httpServletRequest, httpServletResponse);
                } else {
                    handleError(httpServletResponse, "templates/CommandNotSupported.html");
                }
            } else {
                handleError(httpServletResponse, "templates/InitializationFailed.html");
            }
        } catch (Exception e) {
            System.out.printf("Команда не поддерживается, код %s", command);
            handleError(httpServletResponse, "templates/ExceptionOccur.html");
            e.printStackTrace();
        }
        log("Method service =)");
    }

    private void handleError(HttpServletResponse httpServletResponse, String errorTemplate) throws IOException {
        String pageText = environment.resourceManager.getResource(errorTemplate);
        HttpServletResponseBuilder.onStringResponse(httpServletResponse, HttpServletResponse.SC_BAD_REQUEST, HttpServletResponseBuilder.HTMLContentType, pageText);
    }

    @Override
    public void destroy() {
        super.destroy();
        log("Method desctoy =)");
    }

    private boolean initializeImpl() {
        boolean result;
        try {
            environment = MainServletEnvironment.create();
            result = environment != null;

            if (result) {
                RequestHandler sessionHandler = new SessionHandler(environment.sessionManager, environment.securityManager);
                register(RequestCode.SESSION_OPEN, sessionHandler, environment.editContentRequestExtractor, environment.sessionOpenResponsePacker);
                register(RequestCode.SESSION_CLOSE, sessionHandler, environment.baseRequestExtractor, environment.sessionCloseResponsePacker);

                RequestHandler usersInfoHandler = new UsersInfoHandler(environment.sessionManager, environment.securityManager, environment.documentManager);
                register(RequestCode.USERS_INFO, usersInfoHandler, environment.editContentRequestExtractor, environment.objectResponsePacker);
                register(RequestCode.CURRENT_USER_INFO, usersInfoHandler, environment.baseRequestExtractor, environment.objectResponsePacker);
                register(RequestCode.REGISTRATION_USER_INFO, usersInfoHandler, environment.entityWithViolationsRequestExtractor, environment.sessionOpenResponsePacker);
                register(RequestCode.GET_DOCUMENTS_HISTORY, usersInfoHandler, environment.baseRequestExtractor, environment.objectResponsePacker);
                register(RequestCode.GET_DOCUMENT_BY_ID, usersInfoHandler, environment.editContentRequestExtractor, environment.objectResponsePacker);

                RequestHandler documentsHandler = new DocumentsHandler(environment.sessionManager, environment.documentManager);
                register(RequestCode.RECOGNIZE_DOCUMENT, documentsHandler, environment.requestWithAttachmentsExtractor, environment.objectResponsePacker);
                register(RequestCode.RECOGNIZE_AUDIO_DOCUMENT, documentsHandler, environment.requestWithAttachmentsExtractor, environment.objectResponsePacker);
                register(RequestCode.SAVE_DOCUMENT, documentsHandler, environment.requestWithAttachmentsExtractor, environment.baseResponsePacker);
            }
        } catch (Exception e) {
            e.printStackTrace();
            result = false;
        }

        return result;
    }

    private void register(RequestCode code, RequestHandler handler, RequestExtractor requestExtractor, ResponsePacker responsePacker) {
        handlers.put(code.toString(), new HandlerInfo(handler, requestExtractor, responsePacker));
    }

}

Кроме того, MainServlet содержит экземпляр класса MainServlet Environment, содержащий экземпляры классов‑менеджеров для работы с базой данных, экземпляры классов для работы с запросами‑ответами и для работы с базой данных. MainServlet имеет следующие методы: initialize() — инициализирует единственный раз запуск метода initializeImpl(); initializeImpl() — запускает метод create() класса MainServlet Environment, и заполняет словарь handlers посредством метода register(); метод service() принимает запросы и отправляет ответы; метод handleError() нужен для обработки ошибок, когда приходит запрос, отсутствующий в словаре handlers.Код класса WebHandlerEnvironment:

import core.ResourceManager;
import core.SessionManager;
import core.documentManager.DocumentManager;
import core.interaction.RequestExtractor;
import core.interaction.ResponsePacker;
import core.interaction.requestExtractors.BaseRequestExtractor;
import core.interaction.requestExtractors.EditContentRequestExtractor;
import core.interaction.requestExtractors.RequestWithAttachmentsExtractor;
import core.interaction.requestExtractors.entityRequestExtractor.EntityRequestExtractor;
import core.interaction.requestExtractors.entityRequestExtractor.EntityWithViolationsRequestExtractor;
import core.interaction.responsePackers.BaseResponsePacker;
import core.interaction.responsePackers.ObjectResponsePacker;
import core.interaction.responsePackers.SessionCloseResponsePacker;
import core.interaction.responsePackers.SessionOpenResponsePacker;
import db.HibernateSessionFactory;
import org.hibernate.SessionFactory;
import core.securityManager.SecurityManager;

public class MainServletEnvironment {
    final public SessionFactory hibernateSessionFactory;
    final public SessionManager sessionManager;
    final public SecurityManager securityManager;
    final public DocumentManager documentManager;
    final public ResourceManager resourceManager;
    final public RequestExtractor baseRequestExtractor;
    final public RequestExtractor editContentRequestExtractor;
    final public RequestWithAttachmentsExtractor requestWithAttachmentsExtractor;
    final public EntityRequestExtractor entityRequestExtractor;
    final public EntityWithViolationsRequestExtractor entityWithViolationsRequestExtractor;
    final public ResponsePacker sessionOpenResponsePacker;
    final public ResponsePacker sessionCloseResponsePacker;
    final public BaseResponsePacker baseResponsePacker;
    final public ObjectResponsePacker objectResponsePacker;

    private MainServletEnvironment(SessionFactory hibernateSessionFactory, SessionManager sessionManager,
                                  SecurityManager securityManager,DocumentManager documentManager) {
        this.hibernateSessionFactory = hibernateSessionFactory;
        this.sessionManager = sessionManager;
        this.securityManager = securityManager;
        this.resourceManager = new ResourceManager();
        this.documentManager = documentManager;

        this.baseRequestExtractor = new BaseRequestExtractor();
        this.editContentRequestExtractor = new EditContentRequestExtractor();
        this.entityRequestExtractor = new EntityRequestExtractor();
        this.entityWithViolationsRequestExtractor = new EntityWithViolationsRequestExtractor();
        this.requestWithAttachmentsExtractor = new RequestWithAttachmentsExtractor();

        this.baseResponsePacker = new BaseResponsePacker();
        this.objectResponsePacker = new ObjectResponsePacker(resourceManager);
        this.sessionCloseResponsePacker = new SessionCloseResponsePacker(resourceManager);
        this.sessionOpenResponsePacker = new SessionOpenResponsePacker(resourceManager);

    }

    public static MainServletEnvironment create() {
        boolean result;
        SessionFactory hibernateSessionFactory = null;
        SessionManager sessionManager = null;
        SecurityManager securityManager = null;
        DocumentManager documentManager = null;
        try {
            hibernateSessionFactory = HibernateSessionFactory.getSessionFactory();
            sessionManager = new SessionManager();
            securityManager = new SecurityManager(hibernateSessionFactory, sessionManager);
            documentManager = new DocumentManager(hibernateSessionFactory, sessionManager);
            result = securityManager.init();
            } catch (Exception e) {
                e.printStackTrace();
                result = false;
            }
            return result ? new MainServletEnvironment(hibernateSessionFactory, sessionManager, securityManager, documentManager): null;
        }
}

В качестве контейнера сервлетов используется Apache Tomcat. Обработка каждого запроса выделено в отдельные классы в пакете handlers, которые связываются с классами сервисов в пакете core. Работа с базой данных осуществляется с использованием классов пакетов dbclasses и db, где хранятся классы сущностей и сервисы работы с ними соответственно.

Рисунок 4 – Структурная схема приложения
Рисунок 4 – Структурная схема приложения

Запросы от клиента к серверу проведения операций обработки изображений и аудио (CVServer на диаграмме рисунка 4) производятся через класс RecognizeTextClient пакета core. Запросы клиента и ответы сервера производятся по протоколу http. Код класса RecognizeTextClient:

import classes.RecognitionDocument;
import core.recognitionClient.handlers.TextRecognitionHandler;

import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.List;
import java.util.Objects;

/**
 * Класс для отправки на сервер распознавания запроса на распознавания текста с pdf документа
 */
public class RecognizeTextClient {
    private static final String SERVER_IP = "127.0.0.1";
    private static final int SERVER_PORT = 10000;
    private static final int RequestCodeLength = 3;
    private static final int RequestBodyLength = 8;
    private static final int ResponseHeaderLength = 10;
    private final String requestHeaderMask;
    private final TextRecognitionHandler textRecognitionHandler;
    private Socket clientSocket;
    private InputStream in;
    private OutputStream out;

    public RecognizeTextClient(){
        requestHeaderMask = String.format("%%0%dd%%0%dd", RequestCodeLength, RequestBodyLength);
        textRecognitionHandler = new TextRecognitionHandler();
    }

    private boolean connect() {
        try {
            clientSocket = new Socket(SERVER_IP, SERVER_PORT);
            in = clientSocket.getInputStream();
            out = clientSocket.getOutputStream();
        } catch (IOException ignored) {
            clientSocket = null;
        }
        return clientSocket != null;
    }
    private void disconnect() {
        if(clientSocket != null) {
            try{
                in.close();
                out.close();
                clientSocket.close();
            } catch (IOException ignored) {
            }
        }
        clientSocket = null;
    }
    private String writeRequestReadResponse(int requestCode, String request) throws IOException {
        byte[] requestBody = request.getBytes(StandardCharsets.UTF_8);
        String requestHeaderStr = String.format(requestHeaderMask, requestCode, requestBody.length);
        byte[] requestHeader = requestHeaderStr.getBytes(StandardCharsets.UTF_8);
        byte[] responseHeader = new byte[ResponseHeaderLength];
        byte[] responseBody;
        String response;

        out.write(requestHeader);
        out.write(requestBody);
        out.flush();

        readNBytes(responseHeader, ResponseHeaderLength);
        String responseHeaderStr = new String(responseHeader, StandardCharsets.UTF_8);
        int responseBodyLength = Integer.parseInt(responseHeaderStr);
        if(responseBodyLength != 0 ) {
            responseBody = new byte[responseBodyLength];
            readNBytes(responseBody, responseBodyLength);
            response = new String(responseBody, StandardCharsets.UTF_8);
        }else {
            response = "";
        }

        return response;
    }
    private void readNBytes(byte[] b, int len) throws IOException {
        Objects.requireNonNull(b);
        if (len < 0 || len > b.length)
            throw new IndexOutOfBoundsException();
        int n = 0;
        while (n < len) {
            int count = in.read(b, n, len - n);
            if (count < 0)
                break;
            n += count;
        }
    }
    private String executeOperation(int operationCode, String operationParameters) {
        String response = null;
        if(connect()) {
            try{
                response = writeRequestReadResponse(operationCode, operationParameters);
            } catch (IOException ignored) {
            }
        }
        disconnect();
        return response;
    }

    public boolean recognitionText(List<byte[]> inputDocument, RecognitionDocument calculateResult) {
        String parameters = textRecognitionHandler.getRequestParameters(inputDocument);
        String response = executeOperation(CalculateServerRequestCode.RECOGNIZE_TEXT, parameters);
        if(!textRecognitionHandler.parseResponse(response)) return false;
        calculateResult.setValue(textRecognitionHandler.getInfos());
        return textRecognitionHandler.getResult();
    }

    public boolean recognitionAudio(List<byte[]> inputDocument, RecognitionDocument calculateResult) {
        String parameters = textRecognitionHandler.getRequestParameters(inputDocument);
        String response = executeOperation(CalculateServerRequestCode.RECOGNIZE_AUDIO, parameters);
        if(!textRecognitionHandler.parseResponse(response)) return false;
        calculateResult.setValue(textRecognitionHandler.getInfos());
        return textRecognitionHandler.getResult();
    }
}

В пакете validators хранятся классы для проверки правильности заполнения полей формы при регистрации.

Для работы с базой данных, как отмечалось ранее, используется библиотека Hibernate. Ниже приведён код класса для инициализации SessionFactory (используется для получения объектов, необходимых для операции с базой данных). Код класса HibernateSessionFactory:

package db;

import dbclasses.*;
import org.hibernate.SessionFactory;
import org.hibernate.boot.registry.StandardServiceRegistryBuilder;
import org.hibernate.cfg.Configuration;

/**
 * Класс для создания SessionFactory.
 */
public class HibernateSessionFactory {

    private static volatile SessionFactory sessionFactory; //настройки и работа с сессиями (фабрика сессий)

    private static final Object sessionFactoryLock = new Object();

    public static SessionFactory getSessionFactory() {
        if (sessionFactory == null)
        {
            synchronized(sessionFactoryLock)
            {
                if (sessionFactory == null)
                {
                    try
                    {
                        Configuration configuration = new Configuration().configure();

                        configuration.addAnnotatedClass(User.class);
                        configuration.addAnnotatedClass(Role.class);
                        configuration.addAnnotatedClass(UserRole.class);
                        configuration.addAnnotatedClass(Document.class);

                        StandardServiceRegistryBuilder builder = new StandardServiceRegistryBuilder().applySettings(configuration.getProperties());
                        sessionFactory = configuration.buildSessionFactory(builder.build());
                    }
                    catch (Exception e)
                    {
                        System.out.println("Исключение!" + e);
                    }
                }
            }
        }
        return sessionFactory;
    }
}

В приведённом выше классе HibernateSessionFactory находятся все классы-сущности для таблиц базы данных. Приведем цепочку классов для работы с сущностью Document. Класс-обработчик, используемый в MainServlet (рисунок 4), носит название DocumentsHandler:

package handlers;

import classes.*;
import core.SessionManager;
import core.documentManager.DocumentManager;
import core.interaction.Request;
import core.interaction.RequestHandlerContainer;
import core.interaction.Response;
import core.interaction.ResponseRecipient;
import core.interaction.requests.RequestWithAttachments;
import core.interaction.responses.ObjectResponse;
import core.recognitionClient.RecognizeTextClient;
import dbclasses.Document;

import java.nio.charset.StandardCharsets;
import java.time.Clock;
import java.util.List;
import java.util.Objects;

public class DocumentsHandler extends RequestHandlerContainer {
    private final SessionManager sessionManager;
    private final DocumentManager documentManager;

    public DocumentsHandler(SessionManager sessionManager, DocumentManager documentManager) {
        super();
        this.sessionManager = sessionManager;
        this.documentManager = documentManager;
        register(RequestCode.RECOGNIZE_DOCUMENT.toString(), this::DocumentRecognize);
        register(RequestCode.RECOGNIZE_AUDIO_DOCUMENT.toString(), this::AudioDocumentRecognize);
        register(RequestCode.SAVE_DOCUMENT.toString(), this::DocumentSave);
    }

    private boolean DocumentRecognize(ResponseRecipient responseRecipient, Request requestBase) {
        RequestWithAttachments request = (RequestWithAttachments) requestBase;
        boolean result;

        Session session = sessionManager.getSession(request.sessionID);
        session.lastActivityTime = Clock.systemDefaultZone().instant();

        RecognizeTextClient recognizeTextClient = new RecognizeTextClient();
        RecognitionDocument calculateResult = new RecognitionDocument();

        result = recognizeTextClient.recognitionText(request.attachments, calculateResult);
        Response response = new ObjectResponse(request.code, request.sessionID, result, calculateResult);

        if (responseRecipient != null) {
            responseRecipient.ReceiveResponse(response);
        }
        return result;
    }

    private boolean AudioDocumentRecognize(ResponseRecipient responseRecipient, Request requestBase) {
        RequestWithAttachments request = (RequestWithAttachments) requestBase;
        boolean result;

        Session session = sessionManager.getSession(request.sessionID);
        session.lastActivityTime = Clock.systemDefaultZone().instant();

        RecognizeTextClient recognizeTextClient = new RecognizeTextClient();
        RecognitionDocument calculateResult = new RecognitionDocument();

        result = recognizeTextClient.recognitionAudio(request.attachments, calculateResult);
        Response response = new ObjectResponse(request.code, request.sessionID, result, calculateResult);

        if (responseRecipient != null) {
            responseRecipient.ReceiveResponse(response);
        }
        return result;
    }

    private boolean DocumentSave(ResponseRecipient responseRecipient, Request requestBase) {
        RequestWithAttachments request = (RequestWithAttachments) requestBase;
        boolean result = false;

        Session session = sessionManager.getSession(request.sessionID);
        session.lastActivityTime = Clock.systemDefaultZone().instant();

        byte[] filepdf = request.attachments.get(0);
        byte[] filetext = request.attachments.get(1);
        String titleOfDocument = new String(request.attachments.get(2), StandardCharsets.UTF_8);

        String[] userIDs = new String[] {session.currentUserID};

        Document document = new Document();
        if(session != null) {
            for (String userID : userIDs) {
                if (Objects.equals(userID, session.currentUserID)) {
                    document.setUserID(userID);
                    document.setTitle(titleOfDocument);
                    document.setFilepdf(filepdf);
                    document.setFiletext(filetext);
                    result = saveDocument(document, result);
                }
            }
        }
        Response response = new Response(request.code, request.sessionID, result);

        if (responseRecipient != null) {
            responseRecipient.ReceiveResponse(response);
        }

        return result;
    }

    private boolean saveDocument(Document entity, boolean result) {
        if (entity!=null){
            try{
                documentManager.save(entity);
                result = true;
            }
            catch(Exception e) {
                e.printStackTrace();
                result = false;
            }
        }
        return result;
    }

    private boolean DocumentInfoID(ResponseRecipient responseRecipient, Request request, String sessionID, String[] userIDs, String ID) {
        boolean result;
        List<Document>  documents = documentManager.getDocumentByID(sessionID, userIDs, ID);
        DocumentsInfo view = new DocumentsInfo(documents);
        result = documents.size() != 0;

        ObjectResponse response = new ObjectResponse(request.code, request.sessionID, result, view);

        if (responseRecipient != null) {
            responseRecipient.ReceiveResponse(response);
        }
        return true;
    }
}

Класс-обработчик содержит методы для работы с поступающими запросами (RECOGNIZE_DOCUMENT, RECOGNIZE_AUDIO_DOCUMENT, SAVE_DOCUMENT), использует для операций класс RecognizeTextClient для выполнения детектирования текста и DocumentManager для операций с базой данных, находящийся в пакете core (рисунок 4):

package core.documentManager;

import classes.Session;
import core.CommonUtils;
import core.SessionManager;
import db.DocumentManagerService;
import dbclasses.Document;

import java.util.*;

/**
 * Менеджер для работы с сущностью Document
 */
public class DocumentManager {
    private final DocumentManagerService service;
    private final SessionManager sessionManager;
    private final DocumentManagerData data;

    public DocumentManager(org.hibernate.SessionFactory hibernateSessionFactory, SessionManager sessionManager) {
        this.sessionManager = sessionManager;
        this.service = new DocumentManagerService(hibernateSessionFactory);
        this.data = new DocumentManagerData();
    }

    public boolean init() {
        boolean result = true;

        service.getData(data);
        if (data==null){
            result = false;
        }
        return result;
    }


    public List<Document> getDocuments(String currentSessionID, String[] ids) {
        List<Document> result = new ArrayList<>();
        Session session = sessionManager.getSession(currentSessionID);
        List<String> userIDs = new ArrayList<>();
        if(ids == null || ids.length == 0) {
            userIDs.add(session.currentUserID);
        } else {
            Collections.addAll(userIDs, ids);
        }
        if(session != null) {
            for(String userID : userIDs) {
                if(Objects.equals(userID, session.currentUserID)) {
                    service.getDataByUserID(data,userID);
                    result.addAll(data.documents);
                }
            }
        }
        return result;
    }

    public List<Document> getDocumentByID(String currentSessionID, String[] ids, String id) {
        List<Document> result = new ArrayList<>();
        Session session = sessionManager.getSession(currentSessionID);
        List<String> userIDs = new ArrayList<>();
        if(ids == null || ids.length == 0) {
            userIDs.add(session.currentUserID);
        } else {
            Collections.addAll(userIDs, ids);
        }
        if(session != null) {
            for(String userID : userIDs) {
                if(Objects.equals(userID, session.currentUserID)) {
                    service.getDataByID(data,id);
                    result.addAll(data.documents);
                }
            }
        }
        return result;
    }


    public void save(Document document) {
        document.setId(CommonUtils.createID());

        service.createDocument(document);
    }
}

DocumentManager работает со списком объектов класса Document, собранных в класс DocumentManagerData:

package core.documentManager;

import dbclasses.Document;


import java.util.ArrayList;
import java.util.List;


/**
 * Класс для хранения списка документов
 */
public class DocumentManagerData {
    public final List<Document> documents;

    public DocumentManagerData() {
        documents = new ArrayList<>();
    }

    public void clear() {
        documents.clear();
    }
}

Кроме того, в DocumentManager используется класс-сервис DocumentManagerService для выполнения транзакций.

package db;

import core.documentManager.DocumentManagerData;
import dbclasses.Document;
import org.hibernate.Session;
import org.hibernate.SessionFactory;
import org.hibernate.Transaction;
import org.hibernate.query.Query;

import java.util.List;

public class DocumentManagerService {
    private final SessionFactory sessionFactory;

    public DocumentManagerService(SessionFactory sessionFactory) {
        this.sessionFactory = sessionFactory;
    }

    public void getData(DocumentManagerData data) {
        data.clear();
        Session session = sessionFactory.openSession();
        Transaction transaction = session.beginTransaction();
        try {
            data.documents.addAll(session.createQuery("SELECT row FROM Document row", Document.class).list());
            transaction.commit();
        } catch (Exception e) {
            transaction.rollback();
            throw e;
        } finally {
            session.close();
        }
    }

    public void getDataByUserID(DocumentManagerData data, String id) {
        data.clear();
        Session session = sessionFactory.openSession();
        Transaction transaction = session.beginTransaction();
        try {
            data.documents.addAll(session.createQuery("SELECT row FROM Document row WHERE row.userID= : userID", Document.class).
                setParameter("userID", id).
                list());
            transaction.commit();
        } catch (Exception e) {
            transaction.rollback();
            throw e;
        } finally {
            session.close();
        }
    }

    public void getDataByID(DocumentManagerData data, String id) {
        data.clear();
        Session session = sessionFactory.openSession();
        Transaction transaction = session.beginTransaction();
        try {
            data.documents.addAll(session.createQuery("SELECT row FROM Document row WHERE row.id= : id", Document.class).
                    setParameter("id", id).
                    list());
            transaction.commit();
        } catch (Exception e) {
            transaction.rollback();
            throw e;
        } finally {
            session.close();
        }
    }

    public void createDocument(Document document) {
        Session session = sessionFactory.openSession();
        Transaction transaction = session.beginTransaction();
        try {
            session.persist(document);
            transaction.commit();
        } catch (Exception e) {
            transaction.rollback();
            throw e;
        } finally {
            session.close();
        }
    }
}

Осталось привести код класса-сущности Document:

package dbclasses;

import jakarta.persistence.Column;
import jakarta.persistence.Entity;
import jakarta.persistence.Table;

@Entity
@Table(name = "Documents")
public class Document extends ObjectWithID {

    @Column(name = "userid")
    private String userID;

    @Column(name = "title")
    private String title;

    @Column(name = "filepdf")
    private byte[] filepdf;

    @Column(name = "filetext")
    private byte[] filetext;

    public String getUserID() {
        return userID;
    }

    public void setUserID(String userID) {
        this.userID = userID;
    }

    public String getTitle() {
        return title;
    }

    public void setTitle(String title) {
        this.title = title;
    }

    public byte[] getFilepdf() {
        return filepdf;
    }

    public void setFilepdf(byte[] filepdf) {
        this.filepdf = filepdf;
    }

    public byte[] getFiletext() {
        return filetext;
    }

    public void setFiletext(byte[] filetxt) {
        this.filetext = filetxt;
    }
}

Весь фронтэнд находится в пакете web (рисунок 3) и состоит из пяти страниц HTML, за внешний вид отвечают ассоциированные с ними файлы CSS, за функционал естественно файлы на JavaScript. Обмен данных с сервером проходит с помощью технологии AJAX, реализованной в буферном классе NetworkClient, через который проходят все запросы со всех страниц (экземпляр NetworkClient присутствует на каждом классе для страницы).

Например, реализация отправки данных для авторизации в NetworkClient выполнена следующим образом:

export default class NetworkClient {

    constructor(parent) {
        this._parent = parent;
        this._serverUrl = 'handler';
        this._defaultTimeout = 30000; //миллисек
    }
#executeCommand(commandName, commandParameters, onSuccess, onError) {
    let query = {
        method: 'POST',
        url: this._serverUrl,
        timeout: this._defaultTimeout,
        context: this._parent,
        success: onSuccess,
        error: onError
    };

    let parameters = commandParameters;
    if (parameters instanceof FormData) {
        parameters.append("cmd", commandName);
        query.data = parameters;
        query.processData = false;
        query.contentType = false;
    } else {
        parameters.cmd = commandName;
        query.data = parameters;
    }

    $.ajax(query);
}
commandLogin(username, password, onSuccess, onError) {
    let command = "SESSION_OPEN";
    let commandParameters = {"id": username.trim(), "string": md5(password.trim())};
    this.#executeCommand(command, commandParameters, onSuccess, onError);
}

Метод commandLogin принимает параметры от пользователя (логин и пароль), добавляет команду для сервера «SESSION_OPEN» и отправляет в метод executeCommand (принимающий и другие запросы). В executeCommand формируется POST запрос, который с использованием AJAX отправляется на сервер. В случае успеха информация далее поступает в соответствующий метод в контроллере страницы для авторизации, в случае ошибки данные уходят в другой метод, обычно показывающий соответствующее сообщение об ошибке.

В качестве итога - демонстрация работы приложения

Продемонстрирую кратко получившееся приложение в действии. При запуске приложения была настроена стартовая страница, startform.html (рисунок 5). Кроме запуска сервлета, необходимо параллельно запустить сервер на Python - файл cvserver.py.

Рисунок 5 – Настройки запуска приложения
Рисунок 5 – Настройки запуска приложения

Со стартовой страницы можно попасть либо на страницу для авторизации, либо на страницу регистрации (рисунок 6).

Рисунок 6 – Приветственное меню и форма для регистрации
Рисунок 6 – Приветственное меню и форма для регистрации

После успешного прохождения авторизации или регистрации пользователь попадает на главную страницу личного кабинета (рисунок 7), где он может загружать файлы формата *.pdf или *.wav.

Рисунок 7 – Главное меню личного кабинета
Рисунок 7 – Главное меню личного кабинета

При загрузке соответствующих файлов для распознавания, они отображаются в форме загрузки в центре, либо в виде PDF (рисунок 8), либо в виде проигрывателя звука (рисунок 9).

Рисунок 8 – Загруженный pdf документ и его распознавание
Рисунок 8 – Загруженный pdf документ и его распознавание

При нажатии на кнопку меню «Распознать» в случае успешной операции появляется сообщение о том, что документ распознан (рисунок 8). Текстовый документ с распознанным текстом можно скачать и сохранить в базу данных, задав при этом название файла.

Рисунок 9 – Загруженное аудио с возможностью воспроизведения
Рисунок 9 – Загруженное аудио с возможностью воспроизведения

Кроме того, пользователь может зайти на страницу «История операций» и выполнить загрузку исходного документа, скачать его и скачать результат распознавания (рисунок 10).

Рисунок 10 – История сохранённых документов пользователя
Рисунок 10 – История сохранённых документов пользователя

Таким образом пользователь может не только выполнять распознавание текущих документов, но и обращаться к истории лично им использованных документов, связанных с его профилем.

Ссылки

  1. LeCun, Y. Gradient-based learning applied to document recognition / Y. LeCun, L. Bottou, Y. Bengio, P. Haffner // Proceedings of the IEEE. – 1998. – Vol. 86, Issue 11. – P. 2278-2323.

  2. . Rabiner, L.R. A tutorial on hidden Markov models and selected applications in speech recognition / L.R. Rabiner // Proceedings of the IEEE. – 1989. – Vol. 77, issue 2. – P. 257 - 286.

  3. Hochreiter S. Long Short-term Memory / S. Hochreiter, J. Schmidhuber // Neural Computation. – 1997. – Vol. 9, no. 8. – P. 1735-80.

Комментарии (2)


  1. economist75
    06.09.2023 08:57
    +1

    Супер, доходчиво расписан весь стек и приемы. Уверен что это станет хорошим примером для построения собственных сервисов. Лоскутная автоматизация в web-обертке становится намного ближе к пользователю, чем классические "батники" на файловых серверах, делающие то же самое.

    Tesseract c версии 5 сделал мощный рывок по качеству распознавания и однозначно он сейчас лучший в СПО/OSS в части текстов "офисного" содержания и ксерографической размазанности.

    С распознаванием речи - SpeechRecognizer, имхо, все же уступает более тяжелым решениям от Vosk и Whisper в точности. Эти либы также доступны в Python.


  1. theurus
    06.09.2023 08:57
    +1

    У меня в телеграме бот таким занимается. Тоже тессеракт. Можно настроить язык распознавания, закинуть пдф и получить свой текст. Со звуком аналогично. Для распознавания звука используется гугол а если сообщение длиннее 1 минуты то vosk и whisper.

    Еще я пытался заставить chatGPT исправлять ошибки OCR :) получилось так себе. Текст приходится резать на небольшие куски что бы он смог прожевать, и трудно заставить его не выдавать ничего кроме текста, любит вставить от себя что-нибудь, типа "Вот исправленный вариант надеюсь вам понравится."