Рукописный редактор на Python: как «рисовать» код

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

Создадим виртуальный лист, на котором можно набросать код от руки — и он будет исполняться!

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

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

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

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

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

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

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


    <div class="brushControls">
<div class="sliderOuter">
    
</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', () =&gt; {
    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', () =&gt; {
    document.documentElement.style.setProperty('--thumb-size', `15px`);
    brushPreview.style.opacity = 0;
});

// Изменение размера кисти при перетаскивании ползунка
slicer.addEventListener('input', () =&gt; {
    brushSize = slicer.value;
    document.documentElement.style.setProperty('--brush-size', `${brushSize}px`);
});

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


    var brushBtn = document.getElementById('brushBtn');
var eraserBtn = document.getElementById('eraserBtn');

// При нажатии кнопки кисти цвет меняется на белый
brushBtn.addEventListener('click', () =&gt; {
    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', () =&gt; {
    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) =&gt; {
    // Увеличение размера кисти
    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) -&gt; 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) -&gt; 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) -&gt; 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) &lt;= 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], &#039; &#039;.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] = &#039; &#039; * (4 * int(tabs)) + line[1]

    lines = [e[1] for e in lines]

    return &#039;\n&#039;.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) =&gt; response.json())
    .then((data) =&gt; {
        // В ответ сервер отдает распознанный текст, который вставляется в окно для отображения
        console.log(data.text);
        codePreview.textContent = data.text;
    })
    .catch((error) =&gt; {
            console.error(error);
    })
    .finally(() =&gt; {
        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) =&gt; {
        if (outputBlock.innerHTML != '') outputBlock.innerHTML += '\n' + 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. Нажимаем на раздел Продукты и выбираем вкладку Облачные серверы.

Переходим на страничку Создать сервер, выбираем подходящую конфигурацию, настраиваем 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‑адрес или домен сервера>

Готово!