1. 项目概述用 Plotly 搭建一个真正能用的双图联动仪表盘我做数据可视化项目快八年了从 Matplotlib 硬刚到 Seaborn 调参再到后来用 Plotly 做交互式看板——不是为了炫技而是因为业务方真的需要“点一下就看到背后的数据逻辑”。这篇讲的不是一个花哨的散点图加折线图堆砌页面而是一个有明确分析意图、支持用户主动探索、能嵌入真实工作流的轻量级仪表盘。核心关键词是Scatter Plot散点图、Line Chart折线图、Dashboard仪表盘、PlotlyPython、交互联动。它解决的是一个非常实际的问题当你要同时观察两个维度的趋势变化比如时间序列上的指标走势和它们之间的相关性比如销量 vs 广告投入静态图表根本不够用而用 Excel 手动切片、刷新、比对效率低、易出错、无法追溯操作路径。这个方案适合数据分析岗新人快速上手搭建周报看板也适合产品/运营同事自己拖拽验证假设更关键的是——它不依赖任何云服务或在线平台所有代码本地可跑、配置可复用、逻辑可审计。我去年给一家区域零售客户做的销售归因分析系统底层就是这套结构只是把散点图换成了气泡大小映射门店等级折线图叠加了滚动平均线。你不需要成为前端工程师只要会写 Python 函数、理解 DataFrame 结构、能看懂坐标轴含义就能在两小时内搭出第一个可用版本。2. 整体设计思路与架构选型解析2.1 为什么必须是 Plotly而不是 Matplotlib 或 Dash很多人一上来就想用 Dash觉得“仪表盘”就该配“框架”。但我在三个不同规模的项目里踩过坑Dash 启动慢、调试难、部署卡在 CORS 和静态资源路径上尤其当业务方临时要加个按钮导出 CSV你得重写 callback、改 layout、再测一遍权限逻辑。而本项目的核心诉求是轻量、可控、可解释——不是做一个企业级 BI 工具而是让一张图自己“说话”。Plotly 的优势在于它天然具备“声明式交互”基因你定义数据和视觉映射它自动处理 hover、zoom、lasso select、pan 这些基础交互且所有行为都基于 JSON schema 可序列化、可回溯。举个具体例子当用户用鼠标框选散点图中的一组高销量低转化样本时折线图会自动高亮对应时间段的曲线段这个联动不是靠 JS 监听事件再发请求而是通过FigureWidget的relayoutData和selectedData属性在 Python 层实时响应。Matplotlib 做不到这点它的交互是 matplotlib backend 控制的脱离 GUI 环境就失效而 Plotly 图形本质是 JSON 对象既能在 Jupyter 里 inline 渲染也能转成 HTML 静态文件发邮件还能嵌入 Flask 应用——这种“一次编写、多端适配”的能力才是工程落地的关键。2.2 为什么采用“散点图 折线图”双视图组合背后的分析逻辑是什么这不是随意拼凑。散点图和折线图在分析场景中承担完全不同的认知角色散点图是关系探测器它强制你把两个连续变量放在同一平面上肉眼识别是否存在线性/非线性趋势、异常簇、边界效应。比如广告花费Xvs 销售额Y如果点云呈扇形扩散说明高投入阶段边际效益递减如果左下角密集一堆“高花费低回报”点就得查渠道质量。折线图是时序解码器它把单一变量随时间的变化压缩成一条路径人脑对“上升/下降/拐点/周期”极其敏感。比如把“日均转化率”画成折线配合散点图中“当天广告花费 vs 当天转化数”你就能判断是某次大促活动拉高了单日转化折线尖峰还是长期优化提升了基线水平折线整体上移。二者联动的价值在于交叉验证假设。例如你怀疑“周末流量质量更高”那么在散点图中筛选出“星期六、星期日”的点看它们是否普遍位于高转化区同时在折线图中观察周末时段的转化率曲线是否持续高于周中。如果两个视图结论一致假设可信度大幅提升如果矛盾比如散点显示周末点分散但折线显示周末均值稳定说明存在未被控制的混杂变量比如周末流量来源更复杂。这种“双视角互锁”的分析模式正是本仪表盘的设计原点。2.3 架构分层数据层 → 逻辑层 → 视图层 → 交互层整个仪表盘按职责清晰切分为四层每层独立可测试数据层只负责加载、清洗、标准化。我坚持用pandas.read_csv()加载原始数据而非直接读数据库——因为业务数据常需脱敏、字段重命名、空值策略统一比如销售额为 0 的记录是真实无成交还是数据未上报必须人工确认后填np.nan或0。这一层输出一个严格符合 Schema 的DataFrame列名小写下划线时间列转为datetime64[ns]数值列确保 dtype 为float64。逻辑层封装所有计算逻辑。比如“7 日滚动转化率”不是写在绘图代码里而是单独函数calc_rolling_conversion(df, window7)输入原始 df输出带新列的 df。好处是调试时可直接 print 中间结果更换算法如改用指数加权只需改这一个函数后续加机器学习特征工程也在此层扩展。视图层纯粹负责图形渲染。调用plotly.express.scatter()和plotly.graph_objects.Scatter()构建图形对象不碰数据计算。所有颜色、尺寸、透明度参数都通过字典集中管理如SCATTER_STYLE {opacity: 0.6, size_max: 30}避免魔法数字散落各处。交互层处理用户动作到视图更新的映射。核心是plotly.graph_objects.FigureWidget的on_selection和on_relayout回调。这里有个关键经验永远不要在回调里重新计算全量数据。比如用户框选散点图回调函数只应提取selectedData[points]中的索引然后用df.iloc[indices]快速切片再用已预计算好的rolling_conversion列去过滤折线图数据。实测下来这样响应速度在万级数据点下仍保持毫秒级。3. 核心细节解析与实操要点3.1 数据准备不是“有数据就行”而是“有结构化的、带业务语义的数据”很多初学者卡在第一步数据格式不对。Plotly 对输入数据极其挑剔一个 dtype 错误就会导致整图空白且无报错。我整理了一套最小可行数据模板以电商销售分析为例datead_spendsalesconversion_rateday_of_weekis_weekend2023-01-011250.089000.032SundayTrue2023-01-02980.062000.028MondayFalse提示date列必须是datetime64[ns]类型不能是字符串。用pd.to_datetime(df[date])强制转换并检查df[date].dt.year.min()是否合理避免 1970 年代脏数据污染时间轴。ad_spend和sales必须是数值型用pd.to_numeric(df[ad_spend], errorscoerce)处理含字母的异常值errorscoerce会把非法值转为NaN比errorsraise更健壮。关键细节在于业务语义字段的构造day_of_week不是简单df[date].dt.day_name()而是映射为中文或英文缩写Mon/Tue方便散点图按类别着色is_weekend是布尔型用于后续条件筛选。这些字段看似简单但决定了后续交互的颗粒度——没有is_weekend你就无法一键筛选周末数据没有day_of_week散点图就只能按数值排序失去业务可读性。3.2 散点图配置如何让点“自己讲故事”而不是一团模糊的色块默认的px.scatter()生成的图在数据量稍大时2000 点会变成一片糊状。解决方案不是减少数据而是用视觉编码传递更多信息import plotly.express as px fig_scatter px.scatter( df, xad_spend, ysales, colorday_of_week, # 按星期几着色立刻区分周期模式 sizeconversion_rate, # 气泡大小映射转化率直观体现质量 size_max40, # 控制最大气泡直径避免遮挡 opacity0.6, # 降低透明度让重叠点可见 hover_data[date, ad_spend, sales, conversion_rate], # 鼠标悬停显示完整信息 labels{ ad_spend: 广告花费元, sales: 销售额元, day_of_week: 星期, conversion_rate: 转化率 } )这里color和size的选择有讲究day_of_week是离散分类变量适合用不同颜色区分而conversion_rate是连续变量用大小编码比用颜色编码更易感知差异人眼对长度变化比色相变化更敏感。opacity0.6是经验值——太透明0.3点看不清太不透明0.9重叠区域变成纯黑。hover_data必须显式指定否则默认只显示 x/y 值业务方看不到日期等关键上下文。注意如果conversion_rate有负值或极大异常值比如某天为 500%气泡会炸开。必须在数据层做截断df[conversion_rate] df[conversion_rate].clip(upper1.0)限定在 0~100% 区间。3.3 折线图配置不只是画一条线而是构建可探索的时间叙事折线图容易陷入“画完即止”的陷阱。真正的分析价值在于让用户能聚焦、对比、回溯。因此我禁用默认的px.line()改用go.Scatter手动构建以获得完全控制权import plotly.graph_objects as go # 预计算滚动指标在逻辑层完成 df[rolling_sales_7d] df[sales].rolling(window7).mean() df[rolling_conv_7d] df[conversion_rate].rolling(window7).mean() fig_line go.Figure() # 主线7日滚动销售额 fig_line.add_trace( go.Scatter( xdf[date], ydf[rolling_sales_7d], modelinesmarkers, name7日滚动销售额, linedict(color#1f77b4, width3), # 主色系宽度突出 markerdict(size4, color#1f77b4), hovertemplateb%{x|%Y-%m-%d}/bbr销售额: %{y:.0f} 元extra/extra ) ) # 辅助线7日滚动转化率共享X轴Y轴右侧 fig_line.add_trace( go.Scatter( xdf[date], ydf[rolling_conv_7d], modelines, name7日滚动转化率, linedict(color#ff7f0e, width2, dashdot), # 橙色虚线区分主次 yaxisy2, hovertemplateb%{x|%Y-%m-%d}/bbr转化率: %{y:.2%}extra/extra ) ) # 配置双Y轴 fig_line.update_layout( yaxisdict(title销售额元), yaxis2dict( title转化率, overlayingy, sideright, tickformat.1% # 自动显示为百分比 ), hovermodex unified, # 鼠标悬停时同一X坐标下所有线显示tooltip legenddict(orientationh, yanchorbottom, y1.02, xanchorright, x1) )关键点解析双Y轴设计销售额和转化率量纲不同强行放同一Y轴会导致小数值线扁平化。右侧Y轴专供转化率且tickformat.1%让标签自动加%符号省去手动格式化。hovermodex unified这是提升体验的神设置。鼠标移到某一天两条线的数值同时弹出无需反复移动鼠标对比极大加速决策。hovertemplate定制用b%{x|%Y-%m-%d}/b加粗显示日期%{y:.0f}控制销售额不显示小数%{y:.2%}将 0.032 显示为3.20%专业感立现。extra/extra清除默认的 trace 名称冗余信息。3.4 交互联动实现让两个图“说同一种语言”联动不是魔法本质是数据索引的同步映射。核心逻辑散点图的每个点对应数据表中的一行折线图的每个点也对应一行按日期对齐。因此联动的关键是建立“点索引 ↔ 行索引”的双向映射。# 创建 FigureWidget启用交互 fig_scatter_widget go.FigureWidget(fig_scatter) fig_line_widget go.FigureWidget(fig_line) # 初始化时先显示全量数据 fig_line_widget.data[0].x df[date] fig_line_widget.data[0].y df[rolling_sales_7d] fig_line_widget.data[1].x df[date] fig_line_widget.data[1].y df[rolling_conv_7d] # 定义散点图选择回调 def update_line_on_scatter_select(trace, points, selector): if points.point_inds: # 用户框选了点 # 获取被选中的日期列表散点图X/Y来自同一行取任意一个即可 selected_dates df.iloc[points.point_inds][date].tolist() # 筛选折线图数据只保留这些日期对应的点 mask df[date].isin(selected_dates) fig_line_widget.data[0].x df[mask][date] fig_line_widget.data[0].y df[mask][rolling_sales_7d] fig_line_widget.data[1].x df[mask][date] fig_line_widget.data[1].y df[mask][rolling_conv_7d] # 更新标题提示当前筛选状态 fig_line_widget.layout.title f已筛选 {len(selected_dates)} 天数据 else: # 用户取消选择 # 恢复全量数据 fig_line_widget.data[0].x df[date] fig_line_widget.data[0].y df[rolling_sales_7d] fig_line_widget.data[1].x df[date] fig_line_widget.data[1].y df[rolling_conv_7d] fig_line_widget.layout.title 7日滚动指标趋势 # 绑定回调 fig_scatter_widget.data[0].on_selection(update_line_on_scatter_select)这段代码的精妙之处在于不重绘只更新数据fig_line_widget.data[0].x直接赋值新列表Plotly 内部会高效 diff 并重绘比fig_line_widget.update_traces()更快。利用 Pandas 索引points.point_inds返回的是散点图内部的点序号但df.iloc[points.point_inds]能精准定位到原始 DataFrame 的行这是保证数据一致性的基石。状态反馈通过fig_line_widget.layout.title动态更新标题让用户明确知道当前视图范围避免“我到底选了什么”的困惑。4. 实操过程与核心环节实现4.1 环境准备与依赖安装避开版本地狱的实战经验Plotly 版本混乱是新手最大坑。我实测过plotly5.18.0在 M1 Mac 上与kaleido导出图片必需兼容性最好而plotly6.0会因kaleido未更新导致export_png()报错。因此我的标准环境配置如下# 创建干净虚拟环境强烈推荐避免全局污染 python -m venv plotly_env source plotly_env/bin/activate # Linux/Mac # plotly_env\Scripts\activate # Windows # 安装核心包指定版本杜绝意外升级 pip install pandas2.0.3 numpy1.24.3 pip install plotly5.18.0 pip install kaleido0.2.1 # 导出PNG/SVG必需 pip install jupyter1.0.0 # Jupyter Lab 4.x 需要此版本兼容实操心得永远不要用pip install plotly。我曾因没锁版本在客户现场演示时plotly自动升级到 6.xFigureWidget的on_selection回调失效当场黑屏。现在所有项目都用requirements.txt锁死版本并在 README 里写明“本项目经测试仅在 Python 3.9、plotly 5.18.0 下稳定运行”。4.2 完整可运行代码从零开始一步一执行以下代码是经过我生产环境验证的最小可用版本复制粘贴即可运行需准备data.csv文件格式见 3.1 节import pandas as pd import numpy as np import plotly.express as px import plotly.graph_objects as go from plotly.subplots import make_subplots import plotly.io as pio # ------------------- 1. 数据层 ------------------- def load_and_prepare_data(filepath): 加载并清洗数据返回结构化DataFrame df pd.read_csv(filepath) # 强制转换日期 df[date] pd.to_datetime(df[date]) # 数值列清洗 for col in [ad_spend, sales, conversion_rate]: df[col] pd.to_numeric(df[col], errorscoerce) # 构造业务字段 df[day_of_week] df[date].dt.day_name().str[:3] # Mon, Tue... df[is_weekend] df[date].dt.dayofweek 5 return df # 加载数据请替换为你的文件路径 df load_and_prepare_data(data.csv) # ------------------- 2. 逻辑层 ------------------- def calc_rolling_metrics(df, window7): 计算滚动指标返回新DataFrame df df.copy() df[rolling_sales_7d] df[sales].rolling(windowwindow).mean() df[rolling_conv_7d] df[conversion_rate].rolling(windowwindow).mean() return df df calc_rolling_metrics(df) # ------------------- 3. 视图层 ------------------- # 散点图 fig_scatter px.scatter( df, xad_spend, ysales, colorday_of_week, sizeconversion_rate, size_max40, opacity0.6, hover_data[date, ad_spend, sales, conversion_rate], labels{ad_spend: 广告花费元, sales: 销售额元} ) # 折线图双Y轴 fig_line go.Figure() fig_line.add_trace( go.Scatter( xdf[date], ydf[rolling_sales_7d], modelinesmarkers, name7日滚动销售额, linedict(color#1f77b4, width3), markerdict(size4, color#1f77b4), hovertemplateb%{x|%Y-%m-%d}/bbr销售额: %{y:.0f} 元extra/extra ) ) fig_line.add_trace( go.Scatter( xdf[date], ydf[rolling_conv_7d], modelines, name7日滚动转化率, linedict(color#ff7f0e, width2, dashdot), yaxisy2, hovertemplateb%{x|%Y-%m-%d}/bbr转化率: %{y:.2%}extra/extra ) ) fig_line.update_layout( yaxisdict(title销售额元), yaxis2dict(title转化率, overlayingy, sideright, tickformat.1%), hovermodex unified, title7日滚动指标趋势, showlegendTrue ) # ------------------- 4. 交互层 ------------------- # 创建可交互Widget fig_scatter_widget go.FigureWidget(fig_scatter) fig_line_widget go.FigureWidget(fig_line) # 初始化折线图显示全量数据 def init_line_plot(): fig_line_widget.data[0].x df[date] fig_line_widget.data[0].y df[rolling_sales_7d] fig_line_widget.data[1].x df[date] fig_line_widget.data[1].y df[rolling_conv_7d] init_line_plot() # 定义联动回调 def update_line_on_select(trace, points, selector): if points.point_inds: selected_dates df.iloc[points.point_inds][date].tolist() mask df[date].isin(selected_dates) fig_line_widget.data[0].x df[mask][date] fig_line_widget.data[0].y df[mask][rolling_sales_7d] fig_line_widget.data[1].x df[mask][date] fig_line_widget.data[1].y df[mask][rolling_conv_7d] fig_line_widget.layout.title f已筛选 {len(selected_dates)} 天数据 else: init_line_plot() fig_line_widget.layout.title 7日滚动指标趋势 fig_scatter_widget.data[0].on_selection(update_line_on_select) # ------------------- 5. 展示 ------------------- # 在Jupyter中显示需安装ipywidgets import ipywidgets as widgets from IPython.display import display # 用HBox水平排列两个图 dashboard widgets.HBox([fig_scatter_widget, fig_line_widget]) display(dashboard) # 可选导出为HTML发给同事 pio.write_html(fig_scatter_widget, scatter.html, auto_openFalse) pio.write_html(fig_line_widget, line.html, auto_openFalse) print(仪表盘已生成打开 scatter.html 和 line.html 查看)运行效果左侧散点图可框选、点击、放大右侧折线图实时响应标题动态更新。所有交互均在浏览器内完成无需网络请求。4.3 导出与分享不止于Jupyter更要融入工作流Jupyter 是开发环境不是交付物。我总结了三种最常用的交付方式按推荐度排序导出为独立 HTML 文件首选# 导出整个仪表盘为单个HTML需安装plotly-orca或kaleido pio.write_html(dashboard, dashboard.html, auto_openTrue)生成的dashboard.html是一个 2MB 左右的自包含文件双击即可在 Chrome/Firefox 中打开所有交互功能完好。发邮件、传钉钉、存网盘都行接收方无需任何环境。嵌入 Flask Web 应用进阶将fig_scatter_widget和fig_line_widget的to_json()输出作为 API 接口前端用Plotly.newPlot()渲染。这样可以加登录、权限、定时刷新适合嵌入公司内部系统。导出为 PNG/SVG 报告静态归档# 导出当前视图为高清PNG pio.write_image(fig_scatter_widget, scatter_current.png, width1200, height800, scale2)scale2保证 Retina 屏幕清晰width/height控制画布尺寸。适合插入 PPT 或周报 PDF。实操心得永远先导出 HTML 测试。我遇到过多次kaleido导出 PNG 时字体缺失显示为方块但 HTML 正常。原因在于kaleido使用的无头 Chrome 缺少中文字体。解决方案是在导出前用pio.kaleido.scope.config.server_url http://localhost:8888指向本地 Jupyter 服务让它复用浏览器字体渲染。5. 常见问题与排查技巧实录5.1 散点图一片空白90% 是数据类型或空值问题这是最高频问题。当你show()散点图只看到坐标轴没有点别急着重写代码按顺序检查检查项检查命令问题表现解决方案X/Y 列是否为空df[ad_spend].isnull().sum()返回非零值用df.dropna(subset[ad_spend,sales])删除空行或df[ad_spend].fillna(0)填充X/Y 列是否为数值型df[ad_spend].dtype返回objectdf[ad_spend] pd.to_numeric(df[ad_spend], errorscoerce)X/Y 值是否全为 0 或极小值df[ad_spend].describe()min和max相等检查数据源是否导出时格式错误如把数字当文本导出日期列是否正确df[date].head()显示1970-01-01用pd.to_datetime(df[date], errorscoerce)并检查NaT我踩过的坑某次客户给的 CSV 中ad_spend列有逗号分隔符1,250.00pd.read_csv()默认当字符串读入。describe()显示count0dtypeobject但print(df[ad_spend].head())看起来很正常。最终用df[ad_spend] df[ad_spend].str.replace(,, ).astype(float)解决。教训永远用describe()看统计摘要别信肉眼。5.2 折线图显示为直线或不连续时间序列对齐失败折线图“断开”或“画成直线”根本原因是 X 轴日期和 Y 轴指标长度不匹配或日期未排序。症状折线图只有一条斜线从左下到右上原因df[date]和df[rolling_sales_7d]长度不同Plotly 自动用range(len(y))当 X 轴。解决assert len(df[date]) len(df[rolling_sales_7d])并在计算滚动指标后df df.dropna(subset[rolling_sales_7d])因为前window-1行是NaN。症状折线图出现多条断开的线段原因df[date]未排序Plotly 按行顺序连线导致时间跳跃。解决df df.sort_values(date).reset_index(dropTrue)务必在计算滚动指标前执行。5.3 交互不生效回调绑定失效的隐蔽原因on_selection不触发常见于以下场景场景1在 Jupyter Lab 中未启用 ipywidgets检查运行jupyter labextension list确认jupyter-widgets/jupyterlab-manager已安装。解决jupyter labextension install jupyter-widgets/jupyterlab-manager然后重启 Jupyter Lab。场景2FigureWidget 被多次赋值覆盖错误写法fig_scatter_widget go.FigureWidget(px.scatter(...))后又写fig_scatter_widget go.FigureWidget(fig_scatter)。后者创建了新对象原回调丢失。解决只创建一次FigureWidget后续用update_traces()修改。场景3数据量过大浏览器内存溢出当df行数 50000Chrome 可能静默终止 JavaScript。解决在散点图中添加sample_n5000参数需自定义采样函数或用px.scatter(..., marginal_xhistogram)加直方图辅助概览。5.4 性能优化万级数据点下的流畅体验当数据量达到 10,000 行交互可能卡顿。我的优化清单散点图降采样不显示全部点而是按空间网格聚合。用datashader预处理import datashader as ds import datashader.transfer_functions as tf # 将DataFrame转为Canvas自动聚合 canvas ds.Canvas(plot_width800, plot_height600) agg canvas.points(df, ad_spend, sales) img tf.shade(agg, cmap[lightblue, darkblue]) # img 是PIL Image可转为Plotly Image Trace折线图简化对时间序列用scipy.signal.decimate()降频或只绘制关键点如每周第一个工作日。禁用动画fig_scatter_widget.layout.transition {duration: 0}关闭所有过渡动画响应更快。5.5 常见问题速查表问题现象可能原因快速验证命令解决方案散点图颜色全是灰色color字段含NaN或空字符串df[day_of_week].unique()df df.dropna(subset[day_of_week])折线图Y轴刻度乱码如1e6数值过大Plotly自动科学计数fig_line.update_yaxes(tickprefix¥, ticksuffix万)手动设置tickprefix和ticksuffix导出HTML后交互失效HTML中未包含plotly.js打开HTML查看script标签用pio.write_html(..., include_plotlyjscdn)框选后折线图无变化points.point_inds为空在回调中print(points.point_inds)检查散点图是否启用了selection默认开启图表中文字显示为方块系统缺少中文字体matplotlib.font_manager.findSystemFonts(fontpathsNone, fontextttf)将中文字体文件如 NotoSansCJK.ttc复制到~/.local/share/fonts/6. 进阶扩展与实用技巧6.1 添加筛选控件从“被动查看”到“主动探索”目前的联动是单向散点→折线我们可以加一个下拉菜单让用户主动筛选星期几import ipywidgets as widgets # 创建下拉控件 day_selector widgets.Dropdown( options[(全部, all)] [(d, d) for d in df[day_of_week].unique()], valueall, description筛选星期, disabledFalse ) # 定义控件回调 def on_day_change(change): if change[new] all: filtered_df df else: filtered_df df[df[day_of_week] change[new]] # 更新散点图数据 fig_scatter_widget.data[0].x filtered_df[ad_spend] fig_scatter_widget.data[0].y filtered_df[sales] fig_scatter_widget.data[0].marker.size filtered_df[conversion_rate] * 40 fig_scatter_widget.data[0].marker.color filtered_df[day_of_week] # 重置折线图 init_line_plot() fig_line_widget.layout.title f筛选{change[new]} day_selector.observe(on_day_change, namesvalue) # 将控件与图表一起显示 dashboard_with_control widgets.VBox([day_selector, dashboard]) display(dashboard_with_control)这个下拉菜单让分析流程更自然先选“周末”看散点分布再框选高转化点看对应时间趋势。控件和图表在同一作用域逻辑清晰。6.2 导出为PDF报告自动化周报生成用weasyprint将 HTML 仪表盘转为 PDF集成到定时任务中from weasyprint import HTML