Shanraq.org Shanraq.org
Отчёт-страница: таблица, картинка и источник в одном файле
IT

Python: от данных до своей сводки Урок 35 из 56

Отчёт-страница: таблица, картинка и источник в одном файле

Тридцать четвёртый урок курса по Python. Собираем HTML из данных: шаблон вместо склейки строк, `html.escape` для всего, что пришло из таблицы, картинка внутри файла — и страница, которая проверяет саму себя. Один файл, который открывается везде и печатается в PDF.

Зачем это нужно

У сводки уже есть три файла: CSV с числами, PNG с картинкой и журнал на экране. Получателю приходится собирать их самому: открыть таблицу в чём-то, что умеет CSV, посмотреть картинку отдельно и догадаться, что они про одно и то же.

Страница решает это одним файлом. Она открывается в современном браузере без дополнительной программы, печатается в PDF и отправляется одним вложением. При этом шрифты, настройки браузера и печати могут немного изменить переносы и вид страницы, поэтому итоговый PDF нужно проверить.

HTML для этого знать не нужно: хватает пяти тегов. Нужно другое — понимать, что данные в разметку нельзя вставлять как есть.

Сразу целиком

Файл otchet.py. В данных нарочно лежит название, которое ломает разметку.

"""Урок 34: отчёт-страница.

Таблица, картинка и источник собираются в один HTML-файл. Данные в разметку
попадают только через экранирование — иначе страница ломается от одного «&».
"""

import base64
import html
from datetime import date
from pathlib import Path
from string import Template

import matplotlib

matplotlib.use("Agg")

import matplotlib.pyplot as plt
import pandas as pd

HERE = Path(__file__).resolve().parent

# В названии нарочно то, что ломает разметку: угловые скобки и амперсанд.
rows = [
    ("Казахстан", 2024, 8.7),
    ("Узбекистан", 2024, 9.6),
    ("<b>Россия</b> & Ко", 2024, 8.4),
]
table = pd.DataFrame(rows, columns=["страна", "год", "инфляция"])
SOURCE = "Источник: Всемирный банк, FP.CPI.TOTL.ZG"

print("== почему нельзя вставлять данные как есть")
raw = f"<td>{rows[2][0]}</td>"
safe = f"<td>{html.escape(rows[2][0])}</td>"
print("как есть:", raw)
print("с экранированием:", safe)

# Картинка сразу внутрь страницы: один файл, который можно переслать письмом.
fig, ax = plt.subplots(figsize=(6, 3))
ax.bar(table["страна"].str.replace("<b>", "", regex=False).str.replace("</b>", "", regex=False),
       table["инфляция"], color="#b03a2e")
ax.set_ylim(0, 12)
ax.set_ylabel("инфляция, %")
ax.set_title("Инфляция, 2024")
png = HERE / "kartinka.png"
fig.savefig(png, dpi=110, bbox_inches="tight")
plt.close(fig)
picture = base64.b64encode(png.read_bytes()).decode("ascii")

PAGE = Template("""<!doctype html>
<meta charset="utf-8">
<title>$title</title>
<h1>$title</h1>
<p>Подготовлено: $day. Наблюдений: $count.</p>
<img alt="$title" src="data:image/png;base64,$picture">
<table border="1" cellspacing="0" cellpadding="6">
$head
$body
</table>
<p>$source</p>
""")

head = "<tr>" + "".join(f"<th>{html.escape(name)}</th>" for name in table.columns) + "</tr>"
body = "\n".join(
    "<tr>" + "".join(f"<td>{html.escape(str(cell))}</td>" for cell in row) + "</tr>"
    for row in table.itertuples(index=False)
)

page = PAGE.substitute(
    title=html.escape("Инфляция в трёх странах"),
    day=date(2026, 9, 11).isoformat(),
    count=len(table),
    picture=picture,
    head=head,
    body=body,
    source=html.escape(SOURCE),
)

out = HERE / "otchet.html"
out.write_text(page, encoding="utf-8")

print()
print("== что получилось")
print("строк в таблице:", page.count("<tr>") - 1, "| столбцов:", page.count("<th>"))
print("картинка внутри файла:", "data:image/png;base64," in page)
print("опасное имя попало в разметку тегом:", "<b>Россия</b>" in page)
print("оно же видно читателю как текст:", "&lt;b&gt;Россия&lt;/b&gt; &amp; Ко" in page)
print("файл:", out.name, "| создан:", out.exists())

Выводит:

== почему нельзя вставлять данные как есть
как есть: <td><b>Россия</b> & Ко</td>
с экранированием: <td>&lt;b&gt;Россия&lt;/b&gt; &amp; Ко</td>

== что получилось
строк в таблице: 3 | столбцов: 3
картинка внутри файла: True
опасное имя попало в разметку тегом: False
оно же видно читателю как текст: True
файл: otchet.html | создан: True

А рядом появляется otchet.html — страница с картинкой внутри.

Разбор

Экранирование — не перестраховка

Первый блок вывода — вся суть урока:

как есть:          <td><b>Россия</b> & Ко</td>
с экранированием:  <td>&lt;b&gt;Россия&lt;/b&gt; &amp; Ко</td>

Без экранирования угловые скобки из данных стали разметкой: часть текста исчезла, часть стала жирной, а & вообще может сломать страницу. html.escape превращает <, > и & в безопасные последовательности, и читатель видит ровно то, что было в данных.

Это та же мысль, что и с params= в двадцать первом уроке и с query в двадцать седьмом: данные не должны становиться кодом. В SQL это инъекция, в HTML — сломанная вёрстка в лучшем случае и чужой скрипт на вашей странице в худшем.

Правило простое: экранируется всё, что пришло не от вас, — заголовки, ячейки, названия, источник. Своё сегодня безопасно, а завтра в справочнике появится «ТОО “Заря & Ко”».

Шаблон вместо склейки

string.Template из стандартной библиотеки: в тексте стоят $title, $body, а substitute подставляет значения. Чем это лучше склейки через + и f-строк на всю страницу:

  • разметка лежит целиком, её видно и можно править, не трогая код;
  • забытая подстановка — ошибка, а не тихо пустое место;
  • $ внутри данных не страшен: подставляются только именованные места.

Когда шаблон перерастает Template — появляются циклы, условия, включения одного куска в другой, — берут Jinja2. Он устроен так же, только умеет больше, и escape в нём включён по умолчанию. В этом курсе хватает стандартной библиотеки.

Таблица из DataFrame

Двумя строками: заголовок из table.columns, тело из itertuples. У pandas есть и готовый to_html, и он экранирует по умолчанию, — но он же тащит свои классы и индекс, и подгонять его под нужный вид обычно дольше, чем написать две строки самому.

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

Картинка внутри файла

base64.b64encode(png.read_bytes()) кладёт картинку прямо в страницу: src="data:image/png;base64,…". Так страница остаётся одним файлом — его пересылают письмом, и картинка не отваливается.

Плата: страница становится примерно на треть больше самой картинки (base64 добавляет треть), и её нельзя закешировать отдельно. Для отчёта на одну-две картинки это правильный размен; для страницы с двадцатью — нет, там картинки кладут рядом файлами.

Что обязано быть на странице

То же, что и на графике в прошлом уроке, плюс одно:

  1. заголовок — что это;
  2. дата подготовки — когда;
  3. число наблюдений — сколько;
  4. источник — откуда числа;
  5. сама таблица — чтобы рядом с картинкой были точные значения.

Картинка отвечает на вопрос «как это выглядит», таблица — «сколько именно», и вместе они закрывают оба.

Карта урока

Карта урока: шаблон, экранирование и один файл

Скажите своими словами

Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.

  1. Что произойдёт со страницей, если вставить в неё название <b>Россия</b> & Ко без экранирования?
  2. Зачем класть картинку внутрь файла, если можно положить её рядом?
  3. Почему пропуск в таблице показывают прочерком, а не пустой ячейкой?

Разминка

Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.

1. Предскажите. Что напечатает эта программа?

import html

name = '<b>Россия</b> & Ко "юг"'
print(html.escape(name))
print(html.escape(name, quote=False))
print("длина до:", len(name), "| после:", len(html.escape(name)))

2. Заполните пропуск. Вместо ... сделайте так, чтобы данные не стали разметкой.

# в ячейку попадает то, что пришло из таблицы
import html

cells = ["еда", "12 & 13", "<b>связь</b>"]
row = "<tr>" + "".join(f"<td>{...}</td>" for cell in cells) + "</tr>"
print(row)
print("тегов в строке:", row.count("<td>"), "| разметка из данных:", "<b>" in row)

3. Почините. Строка таблицы собрана без экранирования, и данные превратились в теги.

# «связь» приедет жирной, а «12 & 13» может сломать страницу
cells = ["еда", "12 & 13", "<b>связь</b>"]
row = "<tr>" + "".join(f"<td>{cell}</td>" for cell in cells) + "</tr>"
print(row)
print("тегов в строке:", row.count("<td>"), "| разметка из данных:", "<b>" in row)

Задание

Обязательное. Соберите отчёт за пять лет одной страницей: заголовок, дата подготовки, число наблюдений, картинка внутри файла, таблица из трёх столбцов (год, инфляция, изменение к прошлому году) и источник внизу. Пропуск в столбце изменения покажите прочерком. Сохраните otchet.html и напечатайте проверку страницы — пройдясь по тому же списку, что и в прошлом уроке.

Ожидаемый вывод:

проверка страницы:
  строк данных: 5 | столбцов: 3
  дата указана: True
  объём данных назван: True
  источник на странице: True
  картинка внутри файла: True
  пропуск показан прочерком: True
  файл: otchet.html | создан: True

Готово, когда: вывод совпадает построчно; все значения проходят через html.escape; страница собрана шаблоном, а не склейкой; картинка лежит внутри файла; проверка спрашивает готовый текст страницы, а не повторяет то, что вы написали. И главное — откройте otchet.html в браузере и посмотрите на него глазами.

На своих данных. Сделайте страницу по своей таблице и отправьте её себе в мессенджер. Откройте на телефоне. Всё, что там не читается — слишком широкая таблица, мелкая картинка, — это то, что придётся чинить до того, как отчёт увидит кто-то ещё.

По желанию.

  • Напечатайте страницу в PDF из браузера и посмотрите, где она разрывается.
  • Сравните размер файла с картинкой внутри и с картинкой рядом.
  • Соберите ту же страницу через table.to_html() и решите, что вам ближе.

Куда это встанет в проекте

Четырнадцатый шаг: сводка перестаёт выдавать три файла. sholu/bet.py собирает таблицу, картинку и источник в один report.html — его открывают в браузере, печатают в PDF и отправляют одним вложением.

Три решения в этом файле стоит посмотреть в коде: картинка внутри страницы, экранирование всего, что пришло из данных, и строка источника с датой — тот самый долг, который прошлый урок объявил: картинка, отправленная отдельно, не говорила, откуда числа.

Долги. Страница собирается на string.Template, и внешний вид живёт в том же файле, что и код. Пока страница одна, это нормально; когда их станет две, разметка попросится в отдельный файл — и тогда же появится смысл в Jinja2.

Ответы

Показать ответы

На вопросы

  1. Угловые скобки станут разметкой: «Россия» приедет жирным, а сами теги исчезнут из текста. & браузер попробует прочитать как начало спецсимвола. В лучшем случае страница выглядит странно, в худшем — на неё попадает чужой код. html.escape превращает эти символы в безопасные последовательности.
  2. Чтобы страница осталась одним файлом. Её пересылают письмом и мессенджером, и картинка, лежащая рядом, при этом теряется. Плата — размер: base64 добавляет примерно треть к весу картинки.
  3. Пустая ячейка читается как «забыли заполнить», прочерк — как «данных нет». Это разные утверждения, и второе — правда.

К разминке

  1. html.escape заменяет <, >, &, а с quote=True (по умолчанию) — ещё и кавычки. Строка становится длиннее ровно на эти замены.
&lt;b&gt;Россия&lt;/b&gt; &amp; Ко &quot;юг&quot;
&lt;b&gt;Россия&lt;/b&gt; &amp; Ко "юг"
длина до: 23 | после: 49
  1. html.escape(cell). Экранируется каждое значение по отдельности, а не готовая строка целиком — иначе экранируются и ваши собственные теги.
import html

cells = ["еда", "12 & 13", "<b>связь</b>"]
row = "<tr>" + "".join(f"<td>{html.escape(cell)}</td>" for cell in cells) + "</tr>"
print(row)
print("тегов в строке:", row.count("<td>"), "| разметка из данных:", "<b>" in row)
<tr><td>еда</td><td>12 &amp; 13</td><td>&lt;b&gt;связь&lt;/b&gt;</td></tr>
тегов в строке: 3 | разметка из данных: False
  1. То же самое: html.escape(cell) внутри f-строки. Проверка «разметка из данных» отвечает False — значит, теги из ячеек в страницу не попали.
import html

cells = ["еда", "12 & 13", "<b>связь</b>"]
row = "<tr>" + "".join(f"<td>{html.escape(cell)}</td>" for cell in cells) + "</tr>"
print(row)
print("тегов в строке:", row.count("<td>"), "| разметка из данных:", "<b>" in row)
<tr><td>еда</td><td>12 &amp; 13</td><td>&lt;b&gt;связь&lt;/b&gt;</td></tr>
тегов в строке: 3 | разметка из данных: False

К заданию

Проверка снова спрашивает результат, а не намерение: page.count("<tr>"), поиск даты и источника в тексте страницы. Такую проверку стоит оставить в коде отчёта — она ловит и забытый источник, и случайно потерянную строку таблицы.

Прочерк в ячейке ставится до экранирования и не через него: это ваш собственный символ, а не данные. Поэтому в функции cell сначала проверяется пропуск, и только потом экранируется всё остальное.

Источники

  • Модуль htmlescape и что именно он заменяет.
  • string.Template — простые шаблоны из стандартной библиотеки.
  • Jinja2 — шаблоны, когда Template становится тесен.

Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом

Проверить задание

Сначала решите и запустите в VS Code — редактор покажет ошибку на месте. Готовое решение вставьте сюда. Проверяет модель: она укажет на ошибку, но не даст готовый ответ.

Чтобы проверить, нужно войти. Войти

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

Пока нет комментариев. Будьте первым.