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