New "Reports" tab for the 3-month LinkedIn campaign, combining dashboard data with the Follower Directory rather than a standalone HTML/PDF workflow. For each campaign month it shows KPI tiles (including a new Monthly Growth Rate, this month's net new followers as a percentage of the followers already on the page at the start of that month, resets every month against its own starting count), the follower growth chart, every post published that month, an Audience Composition breakdown sourced live from the Follower Directory (sector/seniority/country, both this month's cohort and the all-time snapshot), and curated monthly narrative notes (features shipped, data quality fixes, Directory updates, outstanding items). Reuses the existing reportlab PDF pipeline, extended with the same sections, and mirrored in the static exporter with live-only controls disabled. New files: monthly_report_notes.py (per-month curated narrative, July populated), follower_directory_data.py (parses the Directory's DATA array directly so numbers can't drift from the hand-maintained source of truth). Also fixes three pre-existing bugs found while building this: average engagement rate was displaying 100x too small (a 0-1 fraction shown as-is instead of scaled to a percentage), the Sector/Seniority/Country breakdown tables were silently truncated below their own stated totals (a hardcoded top-5 display cap plus a separate top-10 country cap inside composition_stats itself), and the static exporter had no support for dbc.Table, dropping the <table> tag entirely. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
383 lines
20 KiB
Python
383 lines
20 KiB
Python
"""Builds a leadership-facing PDF summary of LinkedIn performance for a chosen
|
|
timeframe. Charts are rendered to PNG via kaleido and embedded as images (reportlab
|
|
has no native Plotly support).
|
|
|
|
Deliberately has zero import-time dependency on app.py — app.py is run directly
|
|
(`python3 app.py`), so it's registered in sys.modules as "__main__", not "app". If this
|
|
module did `import app`, Python wouldn't recognize that as the same module and would
|
|
re-execute app.py from scratch as a second copy, which is still mid-import when this
|
|
module's functions get called — a circular-import AttributeError. Passing everything
|
|
in as arguments avoids that entirely.
|
|
"""
|
|
import io
|
|
|
|
import pandas as pd
|
|
import plotly.express as px
|
|
from reportlab.lib import colors
|
|
from reportlab.lib.pagesizes import A4
|
|
from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
|
|
from reportlab.lib.units import cm
|
|
from reportlab.platypus import (SimpleDocTemplate, Table, TableStyle, Paragraph,
|
|
Spacer, Image, HRFlowable)
|
|
|
|
import follower_directory_data
|
|
|
|
NAVY = colors.HexColor("#1b3a5c")
|
|
LIGHT_GRAY = colors.HexColor("#f0f4f8")
|
|
TEXT_GRAY = colors.HexColor("#6b7a8d")
|
|
|
|
|
|
def report_month_options(fts_df):
|
|
"""'All Time', then one entry per year, then one entry per (year, month) — lets the
|
|
report be scoped to as broad or as narrow a window as the export data supports."""
|
|
import calendar as calmod
|
|
dates = fts_df["Date"]
|
|
years = sorted({d.year for d in dates})
|
|
months = sorted({(d.year, d.month) for d in dates})
|
|
options = [{"label": "All Time", "value": "all"}]
|
|
options += [{"label": str(y), "value": f"year-{y}"} for y in years]
|
|
options += [{"label": f"{calmod.month_name[m]} {y}", "value": f"{y}-{m:02d}"} for y, m in months]
|
|
return options
|
|
|
|
|
|
def _filtered(df, date_col, value):
|
|
if not value or value == "all":
|
|
return df
|
|
if value.startswith("year-"):
|
|
year = int(value.split("-", 1)[1])
|
|
return df[df[date_col].dt.year == year]
|
|
year, month = (int(p) for p in value.split("-"))
|
|
return df[(df[date_col].dt.year == year) & (df[date_col].dt.month == month)]
|
|
|
|
|
|
def _period_label(value, fts_df):
|
|
if not value or value == "all":
|
|
return "All Time"
|
|
return next((o["label"] for o in report_month_options(fts_df) if o["value"] == value), value)
|
|
|
|
|
|
def _fig_to_image(fig, width_cm=17, height_px=300, width_px=900):
|
|
buf = io.BytesIO()
|
|
fig.write_image(buf, format="png", width=width_px, height=height_px, scale=2)
|
|
buf.seek(0)
|
|
aspect = height_px / width_px
|
|
return Image(buf, width=width_cm * cm, height=width_cm * cm * aspect)
|
|
|
|
|
|
def compute_period_stats(ctx, timeframe_value="all"):
|
|
"""Filters D/posts down to timeframe_value and computes the headline numbers shared
|
|
by the PDF report and the live Reports tab, so both always show the same figures
|
|
instead of two independently-computed versions drifting apart."""
|
|
D = ctx["D"]
|
|
posts = ctx["posts"]
|
|
|
|
fts_f = _filtered(D["fts"], "Date", timeframe_value)
|
|
posts_f = _filtered(posts, "Created date", timeframe_value)
|
|
cts_f = _filtered(D["cts"], "Date", timeframe_value)
|
|
vts_f = _filtered(D["vts"], "Date", timeframe_value)
|
|
|
|
new_followers = int(fts_f["Total followers"].sum()) if not fts_f.empty else 0
|
|
total_impressions = int(cts_f["Impressions (total)"].sum()) if not cts_f.empty else 0
|
|
total_page_views = int(vts_f["Total page views (total)"].sum()) if not vts_f.empty else 0
|
|
posts_published = len(posts_f)
|
|
best_eng = float(posts_f["Eng %"].max()) if len(posts_f) else 0.0
|
|
avg_eng_series = cts_f["Engagement rate (total)"].replace(0, float("nan")) if not cts_f.empty else pd.Series(dtype=float)
|
|
# "Engagement rate (total)" is stored as a 0-1 fraction (confirmed against raw export
|
|
# data, max observed 5.9 i.e. 590%), not already a percentage like "Eng %"/"CTR %" —
|
|
# scale to percentage-point form so the "%" already in every display string is correct.
|
|
avg_eng = float(avg_eng_series.mean()) * 100 if not avg_eng_series.dropna().empty else 0.0
|
|
|
|
# Monthly Growth Rate: this month's net new followers as a percentage of the
|
|
# cumulative total the page already had at the start of that month, resets every
|
|
# month against its own starting count rather than a single fixed campaign baseline
|
|
# (e.g. July: 22 new / 85 at start of July = +25.9%). Only defined for a specific
|
|
# "YYYY-MM" timeframe_value, not "all" or a whole year, since "start of the period"
|
|
# is only a clean concept for a single calendar month.
|
|
start_of_month_followers = None
|
|
monthly_growth_rate = None
|
|
if timeframe_value and timeframe_value != "all" and not timeframe_value.startswith("year-"):
|
|
year, month = (int(p) for p in timeframe_value.split("-"))
|
|
first_day = pd.Timestamp(year=year, month=month, day=1)
|
|
prior = D["fts"][D["fts"]["Date"] < first_day]
|
|
if not prior.empty:
|
|
start_of_month_followers = int(prior["Cumulative Followers"].iloc[-1])
|
|
if start_of_month_followers:
|
|
monthly_growth_rate = (new_followers / start_of_month_followers) * 100
|
|
|
|
return {
|
|
"fts_f": fts_f, "posts_f": posts_f, "cts_f": cts_f, "vts_f": vts_f,
|
|
"new_followers": new_followers, "total_impressions": total_impressions,
|
|
"total_page_views": total_page_views, "posts_published": posts_published,
|
|
"best_eng": best_eng, "avg_eng": avg_eng,
|
|
"start_of_month_followers": start_of_month_followers,
|
|
"monthly_growth_rate": monthly_growth_rate,
|
|
"period_label": _period_label(timeframe_value, D["fts"]),
|
|
}
|
|
|
|
|
|
def _bullets(items, style):
|
|
return [Paragraph(f"• {item}", style) for item in items]
|
|
|
|
|
|
def _counter_rows(counter, header_left, top_n=None):
|
|
"""A Counter (or list of (key, count) pairs, for the `country` field
|
|
composition_stats() returns) as reportlab Table rows, header included.
|
|
No truncation by default, sector/seniority/function are a small fixed taxonomy and
|
|
country is uncapped by composition_stats() for the same reason, so the table's own
|
|
sum matches the cohort total instead of silently dropping the tail categories."""
|
|
items = counter.most_common(top_n) if hasattr(counter, "most_common") else list(counter)[:top_n]
|
|
return [[header_left, "Count"]] + [[str(k), str(v)] for k, v in items]
|
|
|
|
|
|
def build_report_pdf(ctx, timeframe_value="all"):
|
|
"""ctx is a dict bundling everything the report needs from the live app:
|
|
D, posts, comp_ordered, current_total_followers, daily_followers_figure (the
|
|
figure-builder function), std_fn (the _std styling helper), TMPL, FORMAT_COLORS,
|
|
growth_snapshot, and optionally monthly_notes (a curated per-month narrative dict,
|
|
see monthly_report_notes.py) and directory_data (the parsed Follower Directory,
|
|
see follower_directory_data.py). monthly_notes/directory_data are only rendered
|
|
when monthly_notes is present, so "All Time"/year-level reports are unaffected."""
|
|
D = ctx["D"]
|
|
posts = ctx["posts"]
|
|
comp_ordered = ctx["comp_ordered"]
|
|
current_total_followers = ctx["current_total_followers"]
|
|
daily_followers_figure = ctx["daily_followers_figure"]
|
|
std_fn = ctx["std_fn"]
|
|
tmpl = ctx["TMPL"]
|
|
format_colors = ctx["FORMAT_COLORS"]
|
|
growth_snapshot = ctx["growth_snapshot"]
|
|
monthly_notes = ctx.get("monthly_notes")
|
|
directory_data = ctx.get("directory_data") or []
|
|
|
|
period = compute_period_stats(ctx, timeframe_value)
|
|
fts_f, posts_f, cts_f = period["fts_f"], period["posts_f"], period["cts_f"]
|
|
new_followers = period["new_followers"]
|
|
total_impressions = period["total_impressions"]
|
|
total_page_views = period["total_page_views"]
|
|
posts_published = period["posts_published"]
|
|
best_eng = period["best_eng"]
|
|
avg_eng = period["avg_eng"]
|
|
monthly_growth_rate = period["monthly_growth_rate"]
|
|
start_of_month_followers = period["start_of_month_followers"]
|
|
period_label = period["period_label"]
|
|
|
|
styles = getSampleStyleSheet()
|
|
title_style = ParagraphStyle("TXTitle", parent=styles["Title"], textColor=NAVY, fontSize=20, spaceAfter=2)
|
|
subtitle_style = ParagraphStyle("TXSubtitle", parent=styles["Normal"], textColor=TEXT_GRAY, fontSize=10, spaceAfter=16)
|
|
h2_style = ParagraphStyle("TXH2", parent=styles["Heading2"], textColor=NAVY, fontSize=13, spaceBefore=16, spaceAfter=8)
|
|
body_style = ParagraphStyle("TXBody", parent=styles["Normal"], fontSize=9, textColor=colors.HexColor("#333333"))
|
|
note_style = ParagraphStyle("TXNote", parent=styles["Normal"], fontSize=8, textColor=TEXT_GRAY, spaceBefore=4)
|
|
|
|
story = []
|
|
story.append(Paragraph("TXproducts, LinkedIn Performance Report", title_style))
|
|
story.append(Paragraph(
|
|
f"Period: {period_label} | Generated {pd.Timestamp.now().strftime('%B %d, %Y')} "
|
|
f" | Data as of {D['last_updated'].strftime('%B %d, %Y')}",
|
|
subtitle_style))
|
|
story.append(HRFlowable(width="100%", color=NAVY, thickness=1.2))
|
|
|
|
# --- KPI summary table ---
|
|
story.append(Paragraph("Key Metrics", h2_style))
|
|
kpi_data = [
|
|
["Total Followers\n(as of today)", "New Followers", "Posts Published", "Total Impressions",
|
|
"Total Page Views", "Best Post Eng.", "Avg. Eng. Rate", "Monthly Growth Rate"],
|
|
[f"{current_total_followers:,}", f"{new_followers:,}", f"{posts_published:,}",
|
|
f"{total_impressions:,}", f"{total_page_views:,}", f"{best_eng:.1f}%",
|
|
f"{avg_eng:.2f}%" if avg_eng else "—",
|
|
f"{monthly_growth_rate:+.1f}%" if monthly_growth_rate is not None else "—"],
|
|
]
|
|
kpi_table = Table(kpi_data, colWidths=[2.25 * cm] * 8)
|
|
kpi_table.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY),
|
|
("FONTSIZE", (0, 0), (-1, 0), 7),
|
|
("FONTSIZE", (0, 1), (-1, 1), 12),
|
|
("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"),
|
|
("TEXTCOLOR", (0, 1), (-1, 1), NAVY),
|
|
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
]))
|
|
story.append(kpi_table)
|
|
if monthly_growth_rate is not None:
|
|
story.append(Paragraph(
|
|
f"Monthly Growth Rate: this month's net new followers as a percentage of the "
|
|
f"followers already on the page at the start of the month. {period_label} started "
|
|
f"at {start_of_month_followers:,} and gained {new_followers:,}, "
|
|
f"<b>{monthly_growth_rate:+.1f}%</b>. Resets every month against that month's own "
|
|
f"starting count, not a single fixed baseline for the whole campaign.",
|
|
note_style))
|
|
|
|
# --- Weekly growth pace ---
|
|
story.append(Paragraph("Weekly Growth Pace", h2_style))
|
|
on_track = growth_snapshot["on_track"]
|
|
if on_track is None:
|
|
status_color, status_label = TEXT_GRAY, "Building History"
|
|
elif on_track:
|
|
status_color, status_label = colors.HexColor("#52C41A"), "On Track"
|
|
else:
|
|
status_color, status_label = colors.HexColor("#d43f3a"), "Behind"
|
|
weekly_pct = growth_snapshot["week_to_date_pct"]
|
|
target_pct = growth_snapshot["rolling_best_weekly_pct"]
|
|
objective_data = [
|
|
["This Week", f"Target (best of {growth_snapshot['rolling_best_weeks']} wks)", "Pace Status"],
|
|
[f"{weekly_pct:+.2f}%" if weekly_pct is not None else "—",
|
|
f"{target_pct:+.2f}%" if target_pct is not None else "—",
|
|
status_label],
|
|
]
|
|
objective_table = Table(objective_data, colWidths=[5 * cm, 6 * cm, 4 * cm])
|
|
objective_table.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), TEXT_GRAY),
|
|
("FONTSIZE", (0, 0), (-1, 0), 8),
|
|
("FONTSIZE", (0, 1), (-1, 1), 11),
|
|
("FONTNAME", (0, 1), (-1, 1), "Helvetica-Bold"),
|
|
("TEXTCOLOR", (0, 1), (-1, 1), NAVY),
|
|
("TEXTCOLOR", (2, 1), (2, 1), status_color),
|
|
("ALIGN", (0, 0), (-1, -1), "CENTER"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
|
("TOPPADDING", (0, 0), (-1, -1), 6),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
|
|
]))
|
|
story.append(objective_table)
|
|
story.append(Paragraph(
|
|
f"Target is a rolling personal best, the highest trailing-7-day growth rate seen in "
|
|
f"the last {growth_snapshot['rolling_best_weeks']} weeks, not a fixed percentage. It "
|
|
f"rises automatically as performance improves, but a one-off spike ages out of the "
|
|
f"window after {growth_snapshot['rolling_best_weeks']} weeks rather than setting an "
|
|
f"unreachable target permanently.",
|
|
note_style))
|
|
|
|
# --- Monthly narrative sections (only when curated notes exist for this period) ---
|
|
if monthly_notes:
|
|
story.append(Paragraph("Features / Fixes Shipped", h2_style))
|
|
story.extend(_bullets(monthly_notes.get("features_shipped", []), body_style))
|
|
|
|
story.append(Paragraph("Data Quality Issues Found and Fixed", h2_style))
|
|
story.extend(_bullets(monthly_notes.get("data_quality", []), body_style))
|
|
|
|
story.append(Paragraph("Audience Composition (Follower Directory Coverage)", h2_style))
|
|
cohort = follower_directory_data.composition_stats(directory_data, timeframe_value)
|
|
all_time = follower_directory_data.composition_stats(directory_data)
|
|
if cohort["total"]:
|
|
story.append(Paragraph(
|
|
f"LinkedIn recorded {new_followers} new followers in {period_label}. Of those, "
|
|
f"<b>{cohort['total']} have been identified and profiled</b> in the Follower "
|
|
f"Directory so far (a separate, hand-maintained audit of who our followers are, "
|
|
f"not a second follower count), out of {all_time['total']} audited across the "
|
|
f"whole page to date.", body_style))
|
|
for field, label in (("sector", "Sector (this month)"),
|
|
("seniority", "Seniority (this month)"),
|
|
("country", "Country (this month)")):
|
|
comp_table = Table(_counter_rows(cohort[field], label), colWidths=[9 * cm, 3 * cm])
|
|
comp_table.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), LIGHT_GRAY),
|
|
("FONTSIZE", (0, 0), (-1, -1), 8),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
|
("TOPPADDING", (0, 0), (-1, -1), 4), ("BOTTOMPADDING", (0, 0), (-1, -1), 4),
|
|
]))
|
|
story.append(comp_table)
|
|
story.append(Spacer(1, 6))
|
|
else:
|
|
story.append(Paragraph(
|
|
f"LinkedIn recorded {new_followers} new followers in {period_label}, but none "
|
|
f"have been identified and profiled in the Follower Directory yet.", body_style))
|
|
story.append(Paragraph(
|
|
"The Follower Directory is a hand-maintained audit that lags real LinkedIn "
|
|
"follows and gets corrected after the fact as more profiles are screenshotted "
|
|
"and cross-referenced, so this is a coverage figure that can still grow in "
|
|
"later updates, not a final count.", note_style))
|
|
|
|
story.append(Paragraph("Follower Study / Directory Updates", h2_style))
|
|
story.extend(_bullets(monthly_notes.get("follower_study", []), body_style))
|
|
|
|
story.append(Paragraph("Outstanding", h2_style))
|
|
story.extend(_bullets(monthly_notes.get("outstanding", []), body_style))
|
|
|
|
# --- Follower growth chart ---
|
|
if not fts_f.empty:
|
|
story.append(Paragraph("Follower Growth", h2_style))
|
|
fig = daily_followers_figure(fts_f, posts_f)
|
|
fig.update_layout(showlegend=True, font=dict(size=14))
|
|
story.append(_fig_to_image(fig))
|
|
|
|
# --- Posts table: every post for a monthly campaign report (monthly_notes present),
|
|
# otherwise the top 5 by engagement, same as before, for the generic sidebar PDF
|
|
# control where "All Time"/a whole year could mean far too many rows to list in full.
|
|
if monthly_notes:
|
|
story.append(Paragraph(f"All Posts, {period_label}", h2_style))
|
|
shown_posts = posts_f.sort_values("Created date")
|
|
else:
|
|
story.append(Paragraph("Top Posts" + ("" if timeframe_value == "all" else f", {period_label}"), h2_style))
|
|
shown_posts = posts_f.sort_values("Eng %", ascending=False).head(5)
|
|
if len(posts_f):
|
|
rows = [["Post", "Date", "Type", "Impressions", "Eng %", "CTR %"]]
|
|
for _, r in shown_posts.iterrows():
|
|
rows.append([
|
|
Paragraph(str(r["Short title"])[:60], body_style),
|
|
r["Created date"].strftime("%d/%m/%Y"),
|
|
r["Content Type"],
|
|
f"{int(r['Impressions']):,}",
|
|
f"{r['Eng %']:.1f}%",
|
|
f"{r['CTR %']:.1f}%",
|
|
])
|
|
posts_table = Table(rows, colWidths=[7 * cm, 2.2 * cm, 2.2 * cm, 2.4 * cm, 1.8 * cm, 1.8 * cm])
|
|
posts_table.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
("FONTSIZE", (0, 0), (-1, -1), 8),
|
|
("ALIGN", (1, 0), (-1, -1), "CENTER"),
|
|
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]),
|
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
|
]))
|
|
story.append(posts_table)
|
|
else:
|
|
story.append(Paragraph("No posts published in this period.", body_style))
|
|
|
|
# --- Content format performance ---
|
|
if len(posts_f):
|
|
story.append(Paragraph("Impressions by Content Format", h2_style))
|
|
fmt_imp = (posts_f.groupby("Content Type", as_index=False)["Impressions"].sum().sort_values("Impressions"))
|
|
fig2 = std_fn(px.bar(
|
|
fmt_imp, x="Impressions", y="Content Type", orientation="h",
|
|
template=tmpl, color="Content Type", color_discrete_map=format_colors))
|
|
fig2.update_layout(showlegend=False, font=dict(size=14))
|
|
story.append(_fig_to_image(fig2, height_px=220))
|
|
|
|
# --- Competitor snapshot ---
|
|
story.append(Paragraph("Competitor Snapshot", h2_style))
|
|
story.append(Paragraph(
|
|
"Cumulative export snapshot, not specific to the selected period.", note_style))
|
|
comp_rows = [["Page", "New Followers", "Posts", "Comments", "Comments/day", "Reactions"]]
|
|
for _, r in comp_ordered.iterrows():
|
|
label = r["Page"] + (" (You)" if r["Page"] == "TXproducts" else "")
|
|
comp_rows.append([label, r["New Followers"], r["Posts"], r["Comments"], r["Comments per day"], r["Reactions"]])
|
|
comp_table = Table(comp_rows, colWidths=[5 * cm, 2.6 * cm, 2 * cm, 2.2 * cm, 2.6 * cm, 2.2 * cm])
|
|
comp_table.setStyle(TableStyle([
|
|
("BACKGROUND", (0, 0), (-1, 0), NAVY),
|
|
("TEXTCOLOR", (0, 0), (-1, 0), colors.white),
|
|
("FONTSIZE", (0, 0), (-1, -1), 8),
|
|
("ALIGN", (1, 0), (-1, -1), "CENTER"),
|
|
("GRID", (0, 0), (-1, -1), 0.5, colors.HexColor("#e0e7ef")),
|
|
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, LIGHT_GRAY]),
|
|
("TOPPADDING", (0, 0), (-1, -1), 5),
|
|
("BOTTOMPADDING", (0, 0), (-1, -1), 5),
|
|
]))
|
|
story.append(comp_table)
|
|
|
|
story.append(Spacer(1, 16))
|
|
story.append(Paragraph("TXproducts · Internal Report · Generated automatically from LinkedIn Page Analytics exports.", note_style))
|
|
|
|
buf = io.BytesIO()
|
|
doc = SimpleDocTemplate(buf, pagesize=A4, topMargin=1.5 * cm, bottomMargin=1.5 * cm,
|
|
leftMargin=1.5 * cm, rightMargin=1.5 * cm)
|
|
doc.build(story)
|
|
buf.seek(0)
|
|
return buf.getvalue()
|