Глава 18 · Финальные штрихи

Сохранение и загрузка JSON

Сохраняется не картинка, а документ фигур — Canvas умеет заново построить из него холст в любой момент.

Canvas не сохраняет рисунок как файл — документ сохраняет

Важная честная оговорка: Canvas сам по себе не умеет экспортировать себя в обычный PNG или JPEG — надёжный кроссплатформенный растровый экспорт требует дополнительных зависимостей и выходит за рамки этой главы. Вместо этого мы сохраняем ДОКУМЕНТ — список фигур, из которого Canvas и так строится через render_document() (раздел 18.29):

document: list[Shape]
save_document()
JSON-файл на диске
Сохранение.
JSON-файл на диске
load_document()
document: list[Shape]
render_document()
Canvas
Загрузка — обязательно заканчивается перерисовкой, а не просто чтением файла.
drawing.json
{
  "version": 1,
  "canvas": {"background": "#ffffff"},
  "items": [
    {
      "kind": "line",
      "coords": [34, 52, 180, 90],
      "color": "#2563eb",
      "width": 4
    },
    {
      "kind": "rectangle",
      "coords": [80, 120, 220, 210],
      "color": "#dc2626",
      "width": 3
    }
  ]
}
sohranenie_json.py
def save_document(self):
    path_str = filedialog.asksaveasfilename(
        defaultextension=".json", filetypes=[("Рисунок JSON", "*.json")]
    )
    if not path_str:  # пользователь нажал "Отмена" — path_str == ""
        return
    data = {
        "version": 1,
        "canvas": {"background": CANVAS_BG},
        "items": [shape.to_dict() for shape in self.document],
    }
    Path(path_str).write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
Реальное окно: рисунок с несколькими фигурами непосредственно перед сохранением
Реальное окно: рисунок перед Ctrl+S — этот же документ окажется в JSON-файле.
Реальное окно: тот же самый рисунок, восстановленный после загрузки JSON в новом сеансе
Реальное окно: тот же рисунок после Ctrl+O в другом запуске программы — восстановлен из JSON, а не из памяти прежнего процесса.
Отмена диалога — не ошибка, а нормальный путь
И asksaveasfilename(), и askopenfilename() возвращают пустую строку "", если пользователь нажал «Отмена» — не None и не бросают исключение. Код обязан проверить это ДО того, как передавать путь в Path(...): Path("") — валидный, но бессмысленный объект, указывающий на текущую директорию, а не на файл, который выбрал пользователь.

А если файл повреждён?

Открывает файл пользователь — значит, выбрать он может что угодно: чужой JSON, картинку, недописанный при сбое файл, отредактированный вручную рисунок с опечаткой. Глава 15 (раздел 15.25) уже показывала, что json.load() сообщает об этом через json.JSONDecodeError. Здесь к нему добавляются ещё несколько возможностей: ключа "items" может не быть вовсе, у фигуры может не хватать поля, а цвет может оказаться строкой, которой Tk не знает.

Важно не просто «не упасть», а не потерять рисунок, над которым пользователь уже работал. Поэтому загрузка разбирает файл в локальный список и заменяет документ только тогда, когда разбор целиком удался:

zagruzka_bezopasno.py
def load_from_path(self, path):
    try:
        data = json.loads(path.read_text(encoding="utf-8"))
        shapes = [Shape.from_dict(item) for item in data["items"]]
        for shape in shapes:
            self.canvas.winfo_rgb(shape.color)   # цвет существует?
    except json.JSONDecodeError as exc:
        messagebox.showerror("Не удалось открыть файл", f"Это не корректный JSON.\n{exc}")
        return
    except (KeyError, TypeError, ValueError, tk.TclError) as exc:
        messagebox.showerror("Не удалось открыть файл", f"Это не сохранённый рисунок.\n{exc}")
        return

    # Разбор удался — только теперь меняем состояние приложения.
    self.document = shapes
    self.undo_stack.clear()
    self.redo_stack.clear()
    self.render_document()
Сначала разобрать, потом заменять — а не наоборот
Соблазн написать self.document = [...] сразу, а try навесить только на json.loads(). Тогда файл с неизвестным цветом пройдёт разбор, документ уже будет заменён — и упадёт render_document(). Итог: прежний рисунок потерян, история отмены очищена, а каждая следующая попытка нарисовать хоть что-нибудь снова падает, потому что перерисовка каждый раз натыкается на ту же плохую фигуру. Проверка ДО присваивания стоит одной лишней переменной и спасает работу пользователя.
Проверять цвет умеет только холст
Shape сознательно ничего не знает о Tkinter (раздел 18.29), поэтому строку цвета он проверить не может — для него "#2563eb" и "не-цвет" одинаково похожи на цвет. А вот canvas.winfo_rgb(color) спрашивает у самого Tk и бросает TclError, если такого цвета нет. Разделение обязанностей сохранено: модель проверяет структуру, холст — то, что относится к рисованию.
Практика: сохранение и загрузка рисунка в JSON
Модуль tkinter открывает нативное окно Python — выполните локально в VS Code, PyCharm или Jupyter
Практика выполняется локально
Открыть практику →