Рукописный редактор на Python: инструкция для тех, кто хочет «рисовать» код

от автора

Привет, меня зовут Лёня! Я автор YouTube‑канала eleday о программировании на Python. Недавно в школе была проверочная работа и мне пришлось писать код на бумаге. Такой подход показался странным: все-таки программа может исполняться только на компьютере и логично набирать ее там же. Подобная цепочка рассуждений привела к интересной идее — редактору рукописного ввода. В этой статье расскажу о задумке и деталях ее реализации. Создадим виртуальный лист, на котором можно набросать код от руки — и он будет исполняться!

Используйте оглавление, если не хотите читать текст полностью:

Основная идея
Создание поля для рисования
Улучшение интерфейса
Серверная часть
Отправка изображения на сервер
Исполнение кода
Деплой

Основная идея


Концепция проста: создаем поле для рисования, распознаем написанный текст с учетом отступов и пытаемся его «запустить». С точки зрения архитектуры проект представляет собой веб-приложение. Фронтенд — JavaScript для работы «пера», а также исполнения кода в браузере. Бэкенд — Python для распознавания рукописного ввода.

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

Создание поля для рисования


Первым шагом стало проектирование веб-интерфейса. Для разметки страницы я создал index.html, где разместил несколько компонентов.

Кнопки для управления кистью

 <div class="brushControls"> <div class="sliderOuter"> <input type="range" min="2" max="100" step="1" value="4" id="brushSize"> </div> <span class="material-symbols-rounded active notranslate" id="brushBtn">brush</span> <span class="material-symbols-rounded notranslate" id="eraserBtn">ink_eraser</span> </div> 

Кнопки для запуска кода и очистки экрана

 <div class="controls">   <span class="material-symbols-rounded notranslate" id="runBtn">play_arrow</span>    <span class="material-symbols-rounded notranslate" id="clearScreenBtn">delete</span> </div> 

Поле для отображения распознанного кода и результата его выполнения

 <div class="codePreviewOuter">     <span class="material-symbols-rounded notranslate" id="hideBtn">arrow_back_ios_new</span>     <div>         <textarea name="codePreview notranslate" id="codePreview" readonly>код</textarea>         <textarea name="codeOutput notranslate" id="codeOutput" readonly>вывод</textarea>     </div> </div> 

И, конечно же, главный элемент для рисования — холст

 <canvas oncontextmenu="return false;"></canvas> 

Затем добавил стили, чтобы сделать интерфейс приятным, и подключил drawing.js, в котором реализовал логику рисования.

Как работает «холст»

Как только пользователь касается экрана, запускается процесс рисования: переменной isDrawing присваивается true, а текущие координаты сохраняются. При движении по экрану предыдущие координаты соединяются с текущей линией. Когда палец отходит от экрана (или отпускается кнопка мыши), isDrawing становится false, завершая процесс.

 // Объявляем переменные var canvas = document.querySelector('canvas'); var sendBtn = document.querySelector('.sendBtn'); var codePreview = document.querySelector('#codePreview'); var loading = document.querySelector('.loading');  var ctx = canvas.getContext('2d');  var isDrawing = false; var lastX = 0; var lastY = 0;  var brushSize = 2; var color = '#fff'  // Разворачиваем холст на весь экран canvas.width = window.innerWidth; canvas.height = window.innerHeight;  // Функция начала рисования function startDrawing(e) { isDrawing = true; [lastX, lastY] = [e.offsetX, e.offsetY]; }  // Функция рисования function draw(e) { if (!isDrawing) return;  // Задаем параметры кисти ctx.strokeStyle = color;  ctx.lineWidth = brushSize; ctx.lineCap = 'round'; ctx.lineJoin = 'round';  // Соединяем линией предыдущие координаты и текущие ctx.beginPath(); ctx.moveTo(lastX, lastY); ctx.lineTo(e.offsetX, e.offsetY); ctx.stroke();  // Обновляем предыдущие координаты [lastX, lastY] = [e.offsetX, e.offsetY]; }  // Функция окончания рисования function stopDrawing(e) { if (!isDrawing) return;  isDrawing = false; ctx.closePath(); }  // Привязываем вышеописанные функции к действиям пользователя canvas.addEventListener('mousedown', startDrawing); canvas.addEventListener('mousemove', draw); canvas.addEventListener('mouseup', stopDrawing); canvas.addEventListener('mouseout', stopDrawing); 

Рисовать уже можно, но интерфейс пока нельзя назвать удобным.


Улучшение интерфейса


Чтобы работать было удобнее, в модуле ui.js я реализовал несколько дополнительных возможностей.

Настройка толщины кисти через ползунок

 var slicer = document.getElementById('brushSize');  // Увеличение ползунка при наведении мыши slicer.addEventListener('mouseover', () => { document.documentElement.style.setProperty('--thumb-size', `25px`); document.documentElement.style.setProperty('--brush-size', `${brushSize}px`); brushPreview.style.opacity = 1; cursor.style.opacity = 0; });  // Уменьшение ползунка, когда мышь сдвинули slicer.addEventListener('mouseout', () => { document.documentElement.style.setProperty('--thumb-size', `15px`); brushPreview.style.opacity = 0; });  // Изменение размера кисти при перетаскивании ползунка slicer.addEventListener('input', () => { brushSize = slicer.value; document.documentElement.style.setProperty('--brush-size', `${brushSize}px`); }); 

Смена кисти и ластика

 var brushBtn = document.getElementById('brushBtn'); var eraserBtn = document.getElementById('eraserBtn');  // При нажатии кнопки кисти цвет меняется на белый brushBtn.addEventListener('click', () => { color = '#fff'; document.documentElement.style.setProperty('--cursor-color', '#fff'); brushSize = 2; document.documentElement.style.setProperty('--brush-size', `2px`); slicer.value = 2; brushBtn.classList.add('active'); eraserBtn.classList.remove('active'); });  // При нажатии кнопки ластика цвет меняется на черный eraserBtn.addEventListener('click', () => { color = '#000'; brushSize = 32; document.documentElement.style.setProperty('--brush-size', `32px`); document.documentElement.style.setProperty('--cursor-color', '#101010'); slicer.value = 32; brushBtn.classList.remove('active'); eraserBtn.classList.add('active'); }); 

Поддержка горячих клавиш

Клавиши [ и ] используются для изменения размера кисти, P — выбора кисти, E — включения ластика.

 window.addEventListener('keydown', (e) => { // Увеличение размера кисти if (e.key == ']' || e.key == '}' || e.key == 'ъ' || e.key == 'Ъ') {     let step = 1;     if (e.shiftKey) step = 10;     brushSize = Math.min(Number(slicer.max), brushSize + step);     slicer.value = brushSize;     document.documentElement.style.setProperty('--brush-size', `${brushSize}px`); } // Уменьшение размера кисти if (e.key == '[' || e.key == '{' || e.key == 'х' || e.key == 'Х') {     let step = 1;     if (e.shiftKey) step = 10;     brushSize = Math.max(Number(slicer.min), brushSize - step);     slicer.value = brushSize;     document.documentElement.style.setProperty('--brush-size', `${brushSize}px`); } // Выбор кисти if (e.key == 'p' || e.key == 'з') {     color = '#fff';     document.documentElement.style.setProperty('--cursor-color', '#fff');     brushSize = 2;     document.documentElement.style.setProperty('--brush-size', `2px`);     slicer.value = 2;     brushBtn.classList.add('active');     eraserBtn.classList.remove('active'); } // Выбор ластика if (e.key == 'e' || e.key == 'у') {     color = '#000';     document.documentElement.style.setProperty('--cursor-color', '#101010');     brushSize = 32;     document.documentElement.style.setProperty('--brush-size', `32px`);     slicer.value = 32;     brushBtn.classList.remove('active');     eraserBtn.classList.add('active'); } }); 

Теперь управление стало удобным. Пора переходить к серверной части.

Серверная часть


Серверная часть — Python‑программа, написанная с помощью микрофреймворка Flask.
Я создал папку app, в которой находятся:

  • __init__.py — инициализация Flask-приложения,
  • routes.py — маршруты,
  • image_utils.py — обработка изображений.

Для распознавания текста я сначала попробовал pytesseract. Однако выяснилось, что эта библиотека плохо справляется с рукописным вводом. Окончательный выбор пал на easyocr — она хоть и медленнее работает, зато точнее.

Обработка изображений

В image_utils.py реализовано несколько функций, необходимых для восприятия изображения.

Декодирование картинки из base64

 def base64_to_image(base64_string: str) -> np.ndarray: image = base64.b64decode(base64_string.split(',')[1]) image = np.frombuffer(image, np.uint8) image = cv2.imdecode(image, cv2.IMREAD_GRAYSCALE) return image 

Инвертирование цветов и увеличение контрастности

 def prepare_image(image: np.ndarray) -> str: image = cv2.bitwise_not(image) _, image = cv2.threshold(image, 127, 255, cv2.THRESH_BINARY)  files = list(map(lambda x: int(x.split('.')[0]), os.listdir('app/static/user_images'))) i = max(files) + 1 if files else 0  cv2.imwrite(f'app/static/user_images/{i}.png', image) return f'app/static/user_images/{i}.png' 

Распознавание текста с учетом отступов (по количеству пробелов перед строкой)

 def image_to_code(image: str) -> str: # Распознавание блоков текста на картинке blocks = reader.readtext(image) blocks = sorted(blocks, key=lambda x: x[0][0][1])  # Толерантность к высоте строки в пикселях. Чем больше значение - тем более дальние строки по вертикали будут определяться как одна строка tolerance = 20 # Список из средних значений ширины для символов в блоках symbol_widths = [(block[0][2][0] - block[0][0][0]) / len(block[1]) for block in blocks]  # Разбиение на строки last_y = None block_lines = [] for block in blocks:     if last_y is not None and abs(block[0][0][1] - last_y) <= tolerance:         block_lines[-1].append(block)     else:         block_lines.append([block])     last_y = block[0][0][1]  block_lines = [sorted(e, key=lambda x: x[0][0][0]) for e in block_lines] lines = [[line[0][0][:2], ' '.join([e[1] for e in line])] for line in block_lines]  # Вычисление средней ширины символа av_symbol_widths = float(sum(symbol_widths) / len(symbol_widths)) if symbol_widths else 0  for i, line in enumerate(lines[1:], 1): # поиск чего-то похожего на отступ и замена на реальный отступ     tabs = (float(line[0][0][0]) - float(lines[0][0][0][0])) // (av_symbol_widths * 3)     lines[i][1] = ' ' * (4 * int(tabs)) + line[1]  lines = [e[1] for e in lines]  return ' '.join(lines) 

Теперь сервер может преобразовывать рукописный текст в Python-код и отправлять его обратно на страницу.

Отправка изображения на сервер


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

 function sendImage() { if (waitingForServer) return;  waitingForServer = true; loading.style.opacity = 1; // Получаем изображение в виде base64 строки const dataURL = canvas.toDataURL('image/png');  // Делаем запрос к серверу, отправляя строку fetch('/image', {     method: 'POST',     headers: {         'Content-Type': 'application/json'     },     body: JSON.stringify({ image: dataURL }) }) .then((response) => response.json()) .then((data) => { // В ответ сервер отдает распознанный текст, который вставляется в окно для отображения     console.log(data.text);     codePreview.textContent = data.text; }) .catch((error) => {     console.error(error); }) .finally(() => {     loading.style.opacity = 0;     waitingForServer = false;     if (needToUpdate) {         needToUpdate = false;         serverAskTimeout = setTimeout(sendImage, 500);     } }); } 

Исполнение кода


Для выполнения кода прямо в браузере я использовал pyodide.

В codeEval.js инициализируется библиотека, которая блокирует страницу на пару секунд. Чтобы пользователи не испытывали неудобства от ожидания, я добавил экран загрузки.

 async function load() { let pyodide = await loadPyodide(); pyodide.setStdout({batched: (str) => {     if (outputBlock.innerHTML != '') outputBlock.innerHTML += ' ' + str;     else outputBlock.innerHTML = str; }});  document.querySelector('.loading_block').remove();  return pyodide; }; let pyodideReadyPromise = load(); 

Функция evaluatePython выполняет код и отображает результат на странице.

 async function evaluatePython(code) { if (code == '' || code == 'код') {     outputBlock.innerHTML = 'Ну хоть что-нибудь напиши';     return; } outputBlock.innerHTML = ''; let pyodide = await pyodideReadyPromise; try {     outputBlock.style.color = 'white';     let output = await pyodide.runPythonAsync(code);     console.log(output); } catch (err) {     console.log(err);     outputBlock.innerHTML = err;     outputBlock.style.color = 'red'; } } 

Деплой


Когда проект был готов, я развернул его на облачном сервере. Процесс несложен и включал в себя несколько шагов.

Шаг 1. Переходим в панели управления my.selectel.ru. Заходим в существующий аккаунт или создаем новый, если его еще нет.

Шаг 2. Нажимаем на раздел Продукты и выбираем вкладку Облачные серверы.

Переходим на страничку Создать сервер, выбираем подходящую конфигурацию, selectel.ru/blog/ssh-authentication настраиваем SSH-ключ и нажимаем кнопку Создать сервер.

Дожидаемся создания и запуска сервера. Статус можно отслеживать на странице, напротив названия сервера.

Шаг 3. Подключаемся к серверу по SSH и устанавливаем необходимые программы:

 ssh root@[ip сервера] (ssh root@31.128.50.164 для примера выше) sudo apt update sudo apt install git gunicorn ufw python3.12-venv certbot 

Шаг 4. Клонируем Git-репозиторий:

 git clone https://github.com/eledays/handCode 

После переходим в папку handCode, появившуюся в результате клонирования:

 cd handCode 

Шаг 5. Создаем виртуальное окружение и устанавливаем зависимости:

 python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt 

Осуществляем тестовый запуск, чтобы проверить сервер:

 flask run 

Шаг 6. Запускаем приложение с помощью gunicorn:

 /home/handCode/.venv/bin/python3 -m gunicorn --bind 0.0.0.0:5000 main:app 

Шаг 7. Создаем пользователя и группу handcodeuser, но без домашней директории и права на запуск интерактивного сеанса:

 sudo useradd -r -s /sbin/nologin -M -c "Пользователь для запуска приложения handCode" handcodeuser 

Делаем его владельцем проекта:

 sudo chown -R handcodeuser:handcodeuser /home/handCode 

Добавляем себя в группу, чтобы редактировать файлы:

 sudo usermod -aG handcodeuser <имя текущего пользователя> 

Шаг 8. Создаем системный сервис. Для этого подготавливаем специальный файл:

 sudo nano /etc/systemd/system/handCode.service 

Содержимое файла следующее:

 [Unit] Description=gunicorn daemon After=network.target  [Service] User=handcodeuser Group=handcodeuser WorkingDirectory=/home/handCode Environment="PATH=/home/handCode/.venv/bin" ExecStart=/home/handCode/.venv/bin/gunicorn --workers 3 --bind 0.0.0.0:80 main:app  [Install] WantedBy=multi-user.target 

В редакторе nano для сохранения сначала нажимаем Ctrl+X, а затем Y.
Запускаем системный сервис:

 sudo systemctl start handCode sudo systemctl enable handCode 

Проверить статус приложения можно следующей командой:

 sudo systemctl status handCode 

Шаг 9. Настраиваем межсетевой экран — он должен пропускать соединения по 80‑му порту.

 ufw allow 80 

Шаг 10. Подключаемся из интернета. Достаточно набрать в адресной строке браузера IP‑адрес нашего сервера. Можно приобрести доменное имя и привязать его к IP‑адресу.

 http://<IP‑адрес или домен сервера> 

Готово! Делитесь своими вариантами, как можно улучшить проект. Мне интересно услышать ваше мнение. А также задавайте интересующие вопросы — с удовольствием отвечу на них в комментариях.


ссылка на оригинал статьи https://habr.com/ru/articles/910602/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *