Привет, меня зовут Лёня! Я автор 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‑адрес или домен сервера>

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

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