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("оно же видно читателю как текст:", "<b>Россия</b> & Ко" in page)
print("файл:", out.name, "| создан:", out.exists())
Выводит:
== почему нельзя вставлять данные как есть
как есть: <td><b>Россия</b> & Ко</td>
с экранированием: <td><b>Россия</b> & Ко</td>
== что получилось
строк в таблице: 3 | столбцов: 3
картинка внутри файла: True
опасное имя попало в разметку тегом: False
оно же видно читателю как текст: True
файл: otchet.html | создан: True
А рядом появляется otchet.html — страница с картинкой внутри.
Разбор
Экранирование — не перестраховка
Первый блок вывода — вся суть урока:
как есть: <td><b>Россия</b> & Ко</td>
с экранированием: <td><b>Россия</b> & Ко</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 добавляет треть), и её нельзя закешировать отдельно. Для отчёта на одну-две картинки это правильный размен; для страницы с двадцатью — нет, там картинки кладут рядом файлами.
Что обязано быть на странице
То же, что и на графике в прошлом уроке, плюс одно:
- заголовок — что это;
- дата подготовки — когда;
- число наблюдений — сколько;
- источник — откуда числа;
- сама таблица — чтобы рядом с картинкой были точные значения.
Картинка отвечает на вопрос «как это выглядит», таблица — «сколько именно», и вместе они закрывают оба.
Карта урока
Скажите своими словами
Не подглядывая, ответьте вслух или на бумаге. Ответы — в конце урока.
- Что произойдёт со страницей, если вставить в неё название
<b>Россия</b> & Кобез экранирования? - Зачем класть картинку внутрь файла, если можно положить её рядом?
- Почему пропуск в таблице показывают прочерком, а не пустой ячейкой?
Разминка
Три коротких шага перед заданием: предсказать, дописать, починить. Ответы — в конце урока, но сначала ответьте сами.
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.
Ответы
Показать ответы
На вопросы
- Угловые скобки станут разметкой: «Россия» приедет жирным, а сами теги исчезнут из текста.
&браузер попробует прочитать как начало спецсимвола. В лучшем случае страница выглядит странно, в худшем — на неё попадает чужой код.html.escapeпревращает эти символы в безопасные последовательности. - Чтобы страница осталась одним файлом. Её пересылают письмом и мессенджером, и картинка, лежащая рядом, при этом теряется. Плата — размер: base64 добавляет примерно треть к весу картинки.
- Пустая ячейка читается как «забыли заполнить», прочерк — как «данных нет». Это разные утверждения, и второе — правда.
К разминке
html.escapeзаменяет<,>,&, а сquote=True(по умолчанию) — ещё и кавычки. Строка становится длиннее ровно на эти замены.
<b>Россия</b> & Ко "юг"
<b>Россия</b> & Ко "юг"
длина до: 23 | после: 49
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 & 13</td><td><b>связь</b></td></tr>
тегов в строке: 3 | разметка из данных: False
- То же самое:
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 & 13</td><td><b>связь</b></td></tr>
тегов в строке: 3 | разметка из данных: False
К заданию
Проверка снова спрашивает результат, а не намерение: page.count("<tr>"), поиск даты и источника в тексте страницы. Такую проверку стоит оставить в коде отчёта — она ловит и забытый источник, и случайно потерянную строку таблицы.
Прочерк в ячейке ставится до экранирования и не через него: это ваш собственный символ, а не данные. Поэтому в функции cell сначала проверяется пропуск, и только потом экранируется всё остальное.
Источники
- Модуль html —
escapeи что именно он заменяет. - string.Template — простые шаблоны из стандартной библиотеки.
- Jinja2 — шаблоны, когда
Templateстановится тесен.
Если вы нашли ошибку или опечатку в тексте статьи, то сообщите нам об этом
Комментарии (0)
Войдите, чтобы оставить комментарий →
Пока нет комментариев. Будьте первым.