
Привет, меня зовут Лёня! Я автор 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‑адрес или домен сервера>
Готово! Делитесь своими вариантами, как можно улучшить проект. Мне интересно услышать ваше мнение. А также задавайте интересующие вопросы — с удовольствием отвечу на них в комментариях.