Старый мем на новый лад.
Предыдущая моя статья "Gnuplot и с чем его едят" получила большой отклик и даже была переведена на несколько языков (видел на медиуме, встречал на немецком). Поэтому, раз тема актуальная, решил продолжить.
У меня появилась задача получать данные, а затем на удалённом сервере строить по ним графики и отправлять по почте. Причём графики должны иметь возможность отключать оси, приближать отдельные области графика, включать-выключать сетку. И вы знаете, gnuplot умеет выдавать подобные графики. Он даёт их в формате html или svg. Но вот незадача, вместе с этим файлом надо тащить ещё багаж данных в виде кучи javascript-файликов, картинок, css (в случае html), что сильно сужает применимость при отправке их по почте.
В результате, нашёл-таки решение данной проблемы и продемонстрирую её решение на примере svg-файла, для html будет аналогично. Поскольку нет возможности привести график реальных данных, где было использовано это решение, в пример взял шуточный мем про студентов
Постановка задачи
В gnuplot есть прекрасная возможность генерировать графики в html или svg. Они получаются интерактивными и их можно встраивать в ваши веб-страницы или в файлы отчётов. Существует очень хорошая статья на хабре по данной теме: Gnuplot на домашней страничке. Где весьма подробно было разобрано, как формировать данные в html. Посмотреть, как будут выглядеть подобные графики вживую можно на демонстрационной странице для svg и для html. Рекомендую попробовать пощёлкать мышью, повыделять правой кнопкой мыши области графика, включить-выключить сетку и т.п. Сразу станут ясны возможности.
В чём же собственно говоря кроется проблема? Проблема достаточно простая, для работы svg и html нужны дополнительные файлы. То есть, мы формируем отчёт в красивом файле, не глядя его отправляем, а на том конце он не работает и вместо картинок крестики.
Проще говоря, если открыть полученный html, то там можно будет увидеть следующие строки:
<script src="/usr/share/gnuplot5/gnuplot/5.0/js/canvasmath.js"></script>
<script src="/usr/share/gnuplot5/gnuplot/5.0/js/gnuplot_common.js"></script>
<script src="/usr/share/gnuplot5/gnuplot/5.0/js/gnuplot_dashedlines.js"></script>
<script src="/usr/share/gnuplot5/gnuplot/5.0/js/gnuplot_mouse.js"></script>
<script src="/usr/share/gnuplot5/gnuplot/5.0/js/canvasmath.js">
Скрипты, которые лежат по прямому пути, и не содержатся в результирующем html-файле.
То же самое с разметкой css:
<link type="text/css" href="/usr/share/gnuplot5/gnuplot/5.0/js/gnuplot_mouse.css" rel="stylesheet">
Картинки тоже лежат “где-то”, и как вы понимаете они не копируются вместе с файлом.
<script src="/usr/share/gnuplot5/gnuplot/5.0/js/canvasmath.js">
<tr>
<td class="icon"></td>
<td class="icon" onclick=gnuplot.toggle_grid><img src="/usr/share/gnuplot5/gnuplot/5.0/js/grid.png" id="gnuplot_grid_icon" class="icon-image" alt="#" title="toggle grid"></td>
<td class="icon" onclick=gnuplot.unzoom><img src="/usr/share/gnuplot5/gnuplot/5.0/js/previouszoom.png" id="gnuplot_unzoom_icon" class="icon-image" alt="unzoom" title="unzoom"></td>
<td class="icon" onclick=gnuplot.rezoom><img src="/usr/share/gnuplot5/gnuplot/5.0/js/nextzoom.png" id="gnuplot_rezoom_icon" class="icon-image" alt="rezoom" title="rezoom"></td>
<td class="icon" onclick=gnuplot.toggle_zoom_text><img src="/usr/share/gnuplot5/gnuplot/5.0/js/textzoom.png" id="gnuplot_textzoom_icon" class="icon-image" alt="zoom text" title="zoom text with plot"></td>
<td class="icon" onclick=gnuplot.popup_help()><img src="/usr/share/gnuplot5/gnuplot/5.0/js/help.png" id="gnuplot_help_icon" class="icon-image" alt="?" title="help"></td>
</tr>
Точно такая же история и с svg-файлом:
<script type="text/javascript" xlink:href="/usr/share/gnuplot5/gnuplot/5.0/js/gnuplot_svg.js"/>
Скрипт уже один, но он также не содержится внутри результирующего файла.
<image x='10' y='742' width='16' height='16' xlink:href='grid.png'
onclick='gnuplot_svg.toggleGrid();'/>
И если js понятно, где находится, то файл grid.png вообще вроде как должен лежать рядом с svg-шкой, но его нет и в результате мы получаем вот такую вот картинку:
Потерянная картинка в отправленном svg-файле.
На самом деле, grid.png лежит по тому же пути, где и gnuplot_svg.js, в чём несложно убедиться:
ls -1 /usr/share/gnuplot5/gnuplot/5.0/js/
canvasmath.js
canvastext.js
gnuplot_common.js
gnuplot_dashedlines.js
gnuplot_mouse.css
gnuplot_mouse.js
gnuplot_svg.js
grid.png
help.png
nextzoom.png
previouszoom.png
README
return.png
textzoom.png
В общем-то, если кто-то внимательно читал до этого места, то он уже знает решение задачи. Но обычно такое вскрывается только после того как вы сформировали отчёт и отправили его заказчику, вы же не заглядывается в структуру каждого выходного файла, например, документа docx.
Таким образом, необходимо доработать выходной файл, так чтобы он содержал все скрипты и картинки уже внутри себя.
Формируем исходные данные и скрипт для построения графика
В качестве примера, взял шуточный график про студентов, который я нашёл вот тут.
Исходный график.
Если кто-то считает, что юмор неуместен, то прошу меня простить, реальных данных предоставить, увы, не могу. Поэтому буду использовать эти.
Формирую из исходного графика текстовые данные, в любом табличном редакторе.
Сохраняем всё как csv, и запоминаем, что нам надо будет скорректировать разделитель “;” в скрипте gnuplot.
;Обычный студент;Отличник;Ботаник;Проплатил;Быдло
Начало семестра;0;0;0;0;0
Первый месяц;1;1;1;1;0
Второй месяц;1;2;2;0;0
Третий месяц;1;3;3;0;0
Четвёртый месяц;1;4;4;0;0
АААА... Уже 10 дней до экзамена;2;5;5;0;0
Ну ещё неделя до экзамена есть;2;6;6;0;1
3 дня до экзамена;3;3;5;0;1
1 день до экзамена;2;"1,5";5;0;1
Ночь перед экзаменом;5;0;5;0;1
Оценка на экзамене;4;6;6;0;3
Каникулы;1;0;5;0;0
Начало семестра...;0;0;5;0;0
Настало время набросать небольшой скрипт для gnuplot, чтобы построить подобный график.
#! /usr/bin/gnuplot -persist
set encoding utf8
set term png size 1024,768
set output "output.png"
set datafile separator ";"
set xtics rotate by 45 right
set grid
set key autotitle columnheader
set ytics ("А что?" 0, "А смысл приходить" 1, "Неуд" 2, "Удв" 3, "Хор" 4, "Отл" 5, "Автомат" 6)
plot for [i=2:6] 'student.csv' using i:xtic(1) with lines
Поясню, что же мы тут делаем:
-
set encoding utf8
— поскольку я использую русские символы в linux, необходимо установить кодировку (но это нам всё равно не поможет). -
set term png size 1024,768
иset output "output.png"
устанавливают тип выходных данных, размер поля и имя выходного файла. -
set datafile separator ";"
— настраиваем разделитель, по умолчанию это пробел или табуляция. -
set xtics rotate by 45 right
делаем подписи по оси x повёрнутыми на 45 градусов. -
set grid
— включаем сетку -
set key autotitle columnheader
— говорим, что названия осей мы берём из заголовка файла (те самые Обычный студент; Отличник; Ботаник; Проплатил). -
set ytics ("А что?" 0, "А смысл приходить" 1, "Неуд" 2, "Удв" 3, "Хор" 4, "Отл" 5, "Автомат" 6)
— настраиваем соответствие подписи осей по Y численным значениям. -
plot for [i=2:6] 'student.csv' using i:xtic(1) with lines
— строим график из файла student.csv, тут мы в цикле перебираем столбцы от 2 до 6, подписи по оси x берём из первого столбца файла, график в виде линий, тип графика — линии.
В результате получаем такой вот уродливый график.
Что нужно сделать: добавить сглаживание, увеличить толщину линии, поднять легенду и увеличить шрифт и добавить титульник. Делаем, добавляем следующие строки:
-
set title "{/:Bold Уровень знаний в течение семестра}" font ",30"
— добавляет титульный лист, жирным шрифтом, размером 30. -
set key noenhanced font ",20"
— легенда осей, шрифт 20 -
set yrange [0:9]
— увеличиваем размерность графика по Y, чтобы поднять легенду. -
plot for [i=2:6] 'student.csv' using i:xtic(1) smooth mcsplines with lines linewidth 3
— строим график со сплайновой интерполяцией, толщиной линии 3.
Получается вот такая вот красота, даже лучше исходного графика:
Напоследок бахнем тяжёлую артиллерию, поскольку знания в головах явление интегральное, то сделаем заливку под графиком и сделаем её полупрозрачной. В png это работать не будет, только csv или html. Меняем тип терминала на html.
set terminal canvas enhanced mousing size 1024,768
set output "output.html"
Добавляем функционал полупрозрачной заливки под графиком:
set style fill transparent solid 0.3
plot for [i=2:6] 'student.csv' using i:xtic(1) smooth mcsplines with filledcurves x1
И строим графики с полупрозрачной заливкой, и-и-и-и, получаем шляпу.
Отсутствие русских букв на графике.
Вообще, поддержка кириллицы весьма болезненная штука в gnuplot, но на выходном формате html её вряд ли будет возможно получить, потому что символы достаются из специальной таблицы и расставляются уже на холсте. В этой таблице существуют только латинские буквы. Поэтому, либо модифицировать исходный javascript, либо отказаться от русских букв, либо использовать svg, с меньшими возможностями. В данной статье пойду по последнему пути.
Кстати, вот вам готовый кейс, принять участие в разработке opensource пакета, которым все пользуются — это добавить поддержку кириллицы и вписать своё имя в список разработчиков этого пакета.
В результате надо сменить тип выходного файла на svg:
set terminal svg enhanced mousing size 1024,768
set output "output.svg"
Меняю тип выходного файла и получаю следующую красоту:
Результирующий svg-файл.
Чем мне нравится svg, что можно интерактивно отключать не нужные графики, включать или выключать сетку, а также смотреть реальное значение нужной точки. Например, можно посмотреть график только обычного студента. К сожалению, у меня нет возможности на хабр встроить интерактивный svg-файл, поэтому только картинка.
Теперь пришла пора отвязать svg от этой машины, чтобы иметь возможность пустить его в большое плавание. Поэтому займёмся программированием.
Модифицируем svg-файл
Писать буду на питоне, потому что могу, потому что люблю его, и потому что он идеально подходит для такой задачи. Прежде чем пойдём дальше, хочу сделать одну пометку. При разработке и отладке скриптов, неудобно каждый раз открывать картинку и смотреть что же получилось. Особенно, если это удалённый сервер, без графического интерфейса. Удобно бывает использовать просто обычный вывод в консоль, для этого достаточно настроить терминал:
set terminal dumb
И получать выхлоп картинки прямо в консоль:
Думаю тут всё понятно, можно идти дальше.
▍ Подготовка данных
Пример у меня совершенно синтетический, но тем же питоном можно получать данные откуда-то из другого места, и потом их нужно как-то отдавать гнуплоту. Вообще, гнуплот может принимать данные прямо также из своего скрипта (или командной оболочки), но я тестировал это решение на нескольких машинах, работает крайне нестабильно. Поэтому всё же рекомендую сохранять промежуточный файл.
def get_remote_data():
# Здесь мы каким-то образом получаем удалённые данные
# Но в данном случае будут передаваться текст фиксированных данных.
return ''';Обычный студент;Отличник;Ботаник;Проплатил;Быдло
Начало семестра;0;0;0;0;0
Первый месяц;1;1;1;1;0
Второй месяц;1;2;2;0;0
Третий месяц;1;3;3;0;0
Четвёртый месяц;1;4;4;0;0
АААА... Уже 10 дней до экзамена;2;5;5;0;0
Ну ещё неделя до экзамена есть;2;6;6;0;1
3 дня до экзамена;3;3;5;0;1
1 день до экзамена;2;"1,5";5;0;1
Ночь перед экзаменом;5;0;5;0;1
Оценка на экзамене;4;6;6;0;3
Каникулы;1;0;5;0;0
Начало семестра...;0;0;5;0;0'''
Тут мы типа получаем данные откуда-то. В идеале формировать словарь списка, чтобы можно было осуществлять сортировку и т.п. Но для примера подойдёт и так, чтобы не запутать читателя.
После того, как данные получены, их необходимо сохранить в промежуточный файл, из которого мы будем строить график. И вернуть имя этого файла.
import tempfile
def create_file_to_plot(data_for_plot):
datafile = ''
with tempfile.NamedTemporaryFile(delete=False, mode="w") as tmp:
datafile = tmp.name
tmp.write(data_for_plot)
return datafile
После того, как мы построим график, этот файл можно удалить.
os.unlink(datafile)
▍ Строим график
Те, кто работает с *nix системами знают, что основа системы: файлы и каналы. Поэтому мы можем открыть pipe до gnuplot и сыпать ему в интерактивном режиме нужными нам командами. Это будет выглядеть так, будто бы мы запустили gnuplot и вручную вводим в него команды.
Выглядит это следующим образом:
def plot_graph(datafile, ylabel='', title=''):
from subprocess import Popen, PIPE, STDOUT
# Делаем маркировку по оси Y
#...
cmd = f'''
set terminal svg enhanced mousing size 1024,768
set output "output.svg"
set encoding utf8
set datafile separator ";"
set xtics rotate by 45 right
set title "{{/:Bold {title} }}" font ",30"
set key noenhanced font ",20"
set grid
set key autotitle columnheader
{ytics_label}
set yrange [0:9]
set style fill transparent solid 0.3
plot for [i=2:6] '{datafile}' using i:xtic(1) \
smooth mcsplines with filledcurves x1
'''
p = Popen(['gnuplot'], stdout=PIPE, stdin=PIPE, stderr=STDOUT)
output = p.communicate(input=cmd.encode('utf-8'))[0]
print(output.decode('utf-8'))
Как видно, это тот же самый скрипт, что мы написали выше. Разве что я добавил систему формирования подписей по осям Y. Вся магия заключается в Popen. Это фактически выглядит так, будто бы мы поставили вертикальную черту в командной строке, и сделали неименованный pipe.
Для того, чтобы было всё красиво и по Феншую, сделал отдельную функцию формирования подписей по оси Y, в виде словаря.
def set_ytics_label():
return {0: "А что?", 1: "А смысл приходить", 2: "Неуд", 3: "Удв",
4: "Хор", 5: "Отл", 6: "Автомат"}
В свою очередь, в функции plot_graph формируется строка подписей следующим кодом:
# Делаем маркировку по оси Y
ytics_label = ''
if len(ylabel) != 0:
ytics_label = 'set ytics ('
for mark_ylabel in ylabel:
ytics_label += f'"{ylabel[mark_ylabel]}" "{mark_ylabel}", '
ytics_label = ytics_label.rstrip(' ,') + ')'
Осталось только модифицировать svg-файл, чтобы его можно было отправлять по почте или встраивать на сайте.
▍ Код модификации svg-файла
Как уже выше было сказано, js и png лежат отдельно от файла svg. Наша задача состоит в том, чтобы внедрить их непосредственно в код svg. Если джаваскрипт понятно как вставить: считай файл и вставь его содержимое внутри тех же тэгов, то как быть с картинкой?
На самом деле, есть простой способ вставить картинку в виде текста, что в svg, что в html — это преобразовать её в base64. Причём, поскольку картинка очень маленькая, то и текста выйдет не много. Вот пример представления картинки изображения сетки в base64:
base64 /usr/share/gnuplot5/gnuplot/5.0/js/grid.png
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAADFBMVEX///8AAAAAAAAAAAD4jAJN
AAAAA3RSTlMA7Psi0lOqAAAAHElEQVR4AWPABpjgCLsAExMzAjEOMS2MCIQFAABtJADJXH3sOwAA
AABJRU5ErkJggg==
Теперь просто необходимо найти теги содержащие js и заменить путь на файл, содержимым этого файла, а картинку заменить на её представление в base64.
def update_svg(svg_filename='output.svg'):
svg_out = list()
JS_STR_START = '<script type="text/javascript" xlink:href="'
JS_STR_END = '"/>'
IMG_STR_START = "xlink:href='"
IMG_STR_END = ".png'"
with open(svg_filename) as f:
svg_in = f.read().split('\n')
start = len(JS_STR_START)
for string in svg_in:
if JS_STR_START in string:
end = string.find(JS_STR_END)
js_path = string[start:end]
with open(js_path) as js_f:
svg_out.append('<script type="text/javascript"><![CDATA[' +
js_f.read() + ']]></script>')
elif 'grid.png' in string:
start = string.find(IMG_STR_START) + len(IMG_STR_START)
end = string.find(IMG_STR_END) + len(IMG_STR_END)
img_in_base64 = '''
iVBORw0KGgoAAAANSUhEUgAAABAAAAAQCAMAAAAoLQ9TAAAADFBMVEX///8AAAAAAAAAAAD4jAJN
AAAAA3RSTlMA7Psi0lOqAAAAHElEQVR4AWPABpjgCLsAExMzAjEOMS2MCIQFAABtJADJXH3sOwAA
AABJRU5ErkJggg=='''
svg_out.append(string[0:start] +
'data:image/png;charset=utf-8;base64, ' +
img_in_base64 + "'" + string[end:])
else:
svg_out.append(string)
with open('output.svg', 'w') as f:
f.write('\n'.join(svg_out))
Вначале идут паттерны поиска джаваскрипта и картинки, далее открываем svg-файл и превращаем его в список строк и по этому списку строк бежим. Если у нас не попадает в паттерн, то мы сохраняем строку в новый список, а если попадает, то модифицируем строку и добавляем её в новый список.
После всего сохраняем этот список в файл (можно даже формировать новый).
▍ Функция main
Всё просто:
- Получаем исходные данные
- Формируем метки по Y
- Сохраняем данные в промежуточный файл
- Строим график
- Обновляем svg
def main():
data_for_plot = get_remote_data()
ylabel = set_ytics_label()
datafile = create_file_to_plot(data_for_plot)
title = 'Уровень знаний в течение семестра'
plot_graph(datafile, ylabel, title)
os.unlink(datafile)
update_svg()
Вывод
Для меня было некоторым открытием, что такой известный, старейший инструмент, как gnuplot, который делает потрясающие графики, имеет столько недочётов и требуется делать вот такие вот костыли, чтобы формировать качественный выходной файл. То что не поддерживается кириллица (да и вообще unicode), в html выходном файле — это вообще печаль. Зато это хороший повод вписать своё имя в историю великого пакета. Всё это справедливо для пакета: Version 5.0 patchlevel 3 last modified 2016-02-21. Возможно на данный момент эти проблемы решены.
Касательно html, код выглядит практически также, разве что картинок больше, и их удобнее не хардкодить в тексте, а преобразовывать на лету в base64:
import base64
with open(pngfilename) as pngfile:
base64img = base64.b64encode(pngfile.read())
html_new.append(string[0:start] + 'data:image/png;base64, '
+ base64img.decode("utf-8) + string[end:])
Ну и следует помнить, что там ещё есть css и несколько js-файлов.
Умышленно не привожу целиком код для html, чтобы был повод размять мозги.
→ Код этого проекта на Github
Попробую вставить svg-картинку на хабр с гита, посмотрю что получится.
Конечно, скрипты на Хабре не работают (и это правильно), но картинка грузится.
Меня могут попрекнуть, мол подобрал бы инструмент, с которым бы не было таких проблем. Всё это хорошо, когда ты админ на машине. А когда у тебя есть суровый питон, запрет на доустановку пакетов и gnuplot, то приходится выкручиваться.
P/s есть ещё парочка хостингов svg, тут и тут (надо регаться).
Комментарии (11)
Alexander_The_Great
01.12.2021 12:17Хотелось бы разбор решения проблемы с легендой (key) в multiplot.
dlinyj Автор
01.12.2021 13:15А что за проблема? Я с ней просто ещё не сталкивался.
Alexander_The_Great
01.12.2021 13:18Строки накладываются друг на друга. Так и не нашёл как исправить, поэтому вероятнее всего баг.
Carburn
Зачем данные исказили по сравнению с исходным графиком?
dlinyj Автор
Специально ничего не искажал, может где-то ошибся, допускаю. Допустим график того, кто проплатил неверный. Мне чтобы снять данные пришлось заниматься вот такими вот глупостями.
Но ИМХО, статья-то не об этом. Но если вы мне подготовите верные данные, то я готов перестроить все графики и привести корректные в комментарии.
Carburn
да, я про график того, проплатил и отличника.
dlinyj Автор
Да, я видимо ошибся. Вечером скину верную версию.
dlinyj Автор
Спасибо за замечание, графики все в статье исправлены.