# -----------------------------------------------------------------------------
# Fabri-Ka — Agrégateur de produits québécois
# Auteur : Simon-Pierre Boucher — contact@spboucher.ai
# report.py : génération du « Rapport de marché Fabri-Ka » en PDF (reportlab).
# Portrait complet du commerce en ligne québécois : couverture de marque,
# KPIs, prix, catégories, régions, plateformes, origine, top boutiques.
# -----------------------------------------------------------------------------
from __future__ import annotations
import io
from datetime import date
from reportlab.lib import colors
from reportlab.lib.enums import TA_CENTER, TA_LEFT, TA_RIGHT
from reportlab.lib.pagesizes import A4
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
from reportlab.lib.units import cm, mm
from reportlab.platypus import (BaseDocTemplate, Flowable, Frame, NextPageTemplate,
PageBreak, PageTemplate, Paragraph, Spacer, Table,
TableStyle)
# Palette de marque
CREAM = colors.HexColor("#FAF6F0")
INK = colors.HexColor("#1E1B16")
TERRA = colors.HexColor("#C4532E")
PINE = colors.HexColor("#234438")
SAND = colors.HexColor("#F1E9DD")
BORDER = colors.HexColor("#E4D9C8")
MUTE = colors.HexColor("#6B6257")
ORIGIN_COLORS = {"A": PINE, "B": TERRA, "C": colors.HexColor("#8A6D3B"),
"D": colors.HexColor("#5B5B8A"), "E": colors.HexColor("#999999")}
ORIGIN_LABELS = {"A": "Fabriqué au Québec", "B": "Conçu au Québec",
"C": "Détaillant québécois", "D": "Mixte", "E": "À vérifier"}
def _money(v):
if v is None:
return "—"
try:
return f"{float(v):,.2f} $".replace(",", " ").replace(".", ",")
except (TypeError, ValueError):
return "—"
def _int(v):
try:
return f"{int(v):,}".replace(",", " ")
except (TypeError, ValueError):
return "0"
class Logo(Flowable):
"""Logo Fabri·Ka vectoriel : pastille pin + monogramme, wordmark."""
def __init__(self, size=18 * mm):
super().__init__()
self.size = size
self.width = size
self.height = size
def draw(self):
c = self.canv
s = self.size
c.setFillColor(PINE)
c.roundRect(0, 0, s, s, s * 0.22, fill=1, stroke=0)
c.setFillColor(CREAM)
c.setFont("Helvetica-Bold", s * 0.5)
c.drawCentredString(s / 2, s * 0.30, "F")
c.setFillColor(TERRA)
c.circle(s * 0.76, s * 0.72, s * 0.07, fill=1, stroke=0)
class Bars(Flowable):
"""Barres horizontales (label, valeur) avec pourcentage du total."""
def __init__(self, rows, width=17 * cm, bar_color=TERRA, unit="", max_rows=None,
show_pct=True):
super().__init__()
self.rows = rows[:max_rows] if max_rows else rows
self.width = width
self.bar_color = bar_color
self.unit = unit
self.show_pct = show_pct
self.row_h = 17
self.height = self.row_h * len(self.rows) + 4
def draw(self):
c = self.canv
if not self.rows:
return
mx = max((r[1] for r in self.rows), default=1) or 1
total = sum(r[1] for r in self.rows) or 1
label_w = 4.4 * cm
val_w = 3.2 * cm
track = self.width - label_w - val_w
y = self.height - self.row_h
for label, val in self.rows:
c.setFillColor(INK)
c.setFont("Helvetica", 8.5)
c.drawString(0, y + 5, (label or "")[:30])
c.setFillColor(SAND)
c.roundRect(label_w, y + 2, track, 11, 2.5, fill=1, stroke=0)
w = track * (val / mx)
c.setFillColor(self.bar_color)
c.roundRect(label_w, y + 2, max(w, 2), 11, 2.5, fill=1, stroke=0)
c.setFillColor(INK)
c.setFont("Helvetica-Bold", 8.5)
txt = _int(val) + self.unit
if self.show_pct:
txt += f" {val / total * 100:.1f}%"
c.drawRightString(self.width, y + 5, txt)
y -= self.row_h
class OriginBar(Flowable):
"""Barre segmentée proportionnelle A–E."""
def __init__(self, rows, width=16 * cm):
super().__init__()
self.rows = [r for r in rows if r.get("products")]
self.width = width
self.height = 30
def draw(self):
c = self.canv
total = sum(r["products"] for r in self.rows) or 1
x = 0
for r in self.rows:
w = self.width * (r["products"] / total)
c.setFillColor(ORIGIN_COLORS.get(r["key"], MUTE))
c.rect(x, 8, w, 16, fill=1, stroke=0)
if w > 20:
c.setFillColor(colors.white)
c.setFont("Helvetica-Bold", 9)
c.drawCentredString(x + w / 2, 13, r["key"])
x += w
def _h(text, style):
return Paragraph(text, style)
def _cover_page(canvas, stats):
"""Page de couverture pleine page, sharp."""
W, H = A4
t = stats["totals"]
_MOIS = ["", "janvier", "février", "mars", "avril", "mai", "juin", "juillet",
"août", "septembre", "octobre", "novembre", "décembre"]
d = date.today()
today = f"{d.day} {_MOIS[d.month]} {d.year}"
canvas.saveState()
# fond crème + large bande pin en bas
canvas.setFillColor(CREAM)
canvas.rect(0, 0, W, H, fill=1, stroke=0)
canvas.setFillColor(PINE)
canvas.rect(0, 0, W, 5.5 * cm, fill=1, stroke=0)
canvas.setFillColor(TERRA)
canvas.rect(0, 5.5 * cm, W, 0.15 * cm, fill=1, stroke=0)
# logo pastille
lx, ly, ls = 2.2 * cm, H - 4 * cm, 2.4 * cm
canvas.setFillColor(PINE)
canvas.roundRect(lx, ly, ls, ls, ls * 0.22, fill=1, stroke=0)
canvas.setFillColor(CREAM)
canvas.setFont("Helvetica-Bold", ls * 0.5)
canvas.drawCentredString(lx + ls / 2, ly + ls * 0.30, "F")
canvas.setFillColor(TERRA)
canvas.circle(lx + ls * 0.76, ly + ls * 0.72, ls * 0.07, fill=1, stroke=0)
canvas.setFillColor(INK)
canvas.setFont("Helvetica-Bold", 30)
canvas.drawString(lx + ls + 0.5 * cm, ly + ls * 0.42, "Fabri")
fw = canvas.stringWidth("Fabri", "Helvetica-Bold", 30)
canvas.setFillColor(TERRA)
canvas.drawString(lx + ls + 0.5 * cm + fw, ly + ls * 0.42, "·Ka")
# titre
canvas.setFillColor(TERRA)
canvas.setFont("Helvetica-Bold", 12)
canvas.drawString(2.2 * cm, H - 7.4 * cm, "RAPPORT DE MARCHÉ")
canvas.setFillColor(INK)
canvas.setFont("Helvetica-Bold", 40)
canvas.drawString(2.2 * cm, H - 9.3 * cm, "Le commerce")
canvas.drawString(2.2 * cm, H - 10.9 * cm, "en ligne québécois")
canvas.setFillColor(MUTE)
canvas.setFont("Helvetica", 13)
canvas.drawString(2.2 * cm, H - 12 * cm,
f"Portrait du marché agrégé par Fabri-Ka — {today}")
# chiffres clés en vedette sur la bande pin
kpis = [(_int(t.get("products")), "produits"),
(_int(t.get("stores_live")), "boutiques actives"),
(f"{t.get('regions') or 0}/17", "régions"),
(_money(t.get("price_median")), "prix médian")]
cw = W / len(kpis)
for i, (val, lab) in enumerate(kpis):
cx = cw * i + cw / 2
canvas.setFillColor(CREAM)
canvas.setFont("Helvetica-Bold", 22)
canvas.drawCentredString(cx, 3.1 * cm, str(val))
canvas.setFillColor(colors.HexColor("#B9C7BE"))
canvas.setFont("Helvetica", 9)
canvas.drawCentredString(cx, 2.35 * cm, lab.upper())
canvas.setFillColor(colors.HexColor("#9FB0A6"))
canvas.setFont("Helvetica", 8)
canvas.drawString(2.2 * cm, 1.1 * cm,
"www.fabri-ka.com · Simon-Pierre Boucher · contact@spboucher.ai")
canvas.restoreState()
def build_report(stats: dict) -> bytes:
buf = io.BytesIO()
ss = getSampleStyleSheet()
title = ParagraphStyle("t", parent=ss["Title"], fontName="Helvetica-Bold",
fontSize=30, leading=34, textColor=INK, spaceAfter=2)
sub = ParagraphStyle("s", fontName="Helvetica", fontSize=11, leading=15,
textColor=MUTE)
h2 = ParagraphStyle("h2", fontName="Helvetica-Bold", fontSize=15, leading=18,
textColor=PINE, spaceBefore=14, spaceAfter=8)
body = ParagraphStyle("b", fontName="Helvetica", fontSize=9.5, leading=14,
textColor=INK)
small = ParagraphStyle("sm", fontName="Helvetica", fontSize=8, leading=11,
textColor=MUTE)
eyebrow = ParagraphStyle("eb", fontName="Helvetica-Bold", fontSize=9,
leading=12, textColor=TERRA)
doc = BaseDocTemplate(buf, pagesize=A4,
leftMargin=2 * cm, rightMargin=2 * cm,
topMargin=1.6 * cm, bottomMargin=1.6 * cm,
title="Rapport de marché Fabri-Ka",
author="Simon-Pierre Boucher — Fabri-Ka")
frame = Frame(doc.leftMargin, doc.bottomMargin, doc.width, doc.height, id="main")
cover_frame = Frame(0, 0, A4[0], A4[1], id="cover")
def footer(canvas, d):
canvas.saveState()
canvas.setStrokeColor(BORDER)
canvas.setLineWidth(0.5)
canvas.line(doc.leftMargin, 1.3 * cm, A4[0] - doc.rightMargin, 1.3 * cm)
canvas.setFont("Helvetica", 7.5)
canvas.setFillColor(MUTE)
canvas.drawString(doc.leftMargin, 0.95 * cm,
"Fabri-Ka — www.fabri-ka.com · Simon-Pierre Boucher · contact@spboucher.ai")
canvas.drawRightString(A4[0] - doc.rightMargin, 0.95 * cm,
f"page {d.page - 1}")
canvas.restoreState()
doc.addPageTemplates([
PageTemplate(id="cover", frames=[cover_frame],
onPage=lambda c, d: _cover_page(c, stats)),
PageTemplate(id="main", frames=[frame], onPage=footer),
])
story = [NextPageTemplate("main"), PageBreak()]
t = stats["totals"]
# La page 1 est la couverture pleine page (dessinée par _cover_page).
# Le contenu commence page 2.
story.append(_h("APERÇU DU MARCHÉ", eyebrow))
story.append(_h("Les chiffres clés", h2))
story.append(_h(
"Fabri-Ka recense les boutiques en ligne du Québec et agrège leurs "
"catalogues publics (Shopify, WooCommerce, Wix, Squarespace), "
"resynchronisés aux 6 heures. Ce rapport présente l'état du marché : "
"volume de produits, boutiques actives, structure des prix, répartition "
"par catégorie, région, plateforme et origine de fabrication (A–E).",
body))
story.append(Spacer(1, 10))
# ---- KPI band
kpis = [
(_int(t.get("products")), "produits agrégés"),
(_int(t.get("stores_live")), "boutiques actives"),
(_int(t.get("stores_registry")), "boutiques au registre"),
(str(t.get("regions") or 0) + " / 17", "régions couvertes"),
(_money(t.get("price_median")), "prix médian"),
(_money(t.get("price_avg")), "prix moyen"),
]
cells = []
row = []
for i, (val, lab) in enumerate(kpis):
card = Table([[Paragraph(f'{val}', body)],
[Paragraph(f'{lab.upper()}', small)]],
colWidths=[5.1 * cm])
card.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, -1), colors.white),
("BOX", (0, 0), (-1, -1), 0.5, BORDER),
("TOPPADDING", (0, 0), (-1, -1), 6),
("BOTTOMPADDING", (0, 0), (-1, -1), 6),
("LEFTPADDING", (0, 0), (-1, -1), 8)]))
row.append(card)
if len(row) == 3:
cells.append(row); row = []
if row:
cells.append(row)
kpi_table = Table(cells, colWidths=[5.4 * cm] * 3, hAlign="LEFT")
kpi_table.setStyle(TableStyle([("TOPPADDING", (0, 0), (-1, -1), 3),
("BOTTOMPADDING", (0, 0), (-1, -1), 3),
("LEFTPADDING", (0, 0), (-1, -1), 0)]))
story.append(kpi_table)
# ---- Structure des prix
story.append(_h("Structure des prix", h2))
story.append(Bars([(b["bucket"] + " $", b["n"]) for b in stats.get("price_buckets", [])],
bar_color=TERRA))
# ---- Catégories (tableau)
story.append(_h("Marché par catégorie", h2))
cat_rows = [["Catégorie", "Produits", "Boutiques", "Prix médian", "Prix moyen"]]
for c in stats.get("by_category", [])[:18]:
cat_rows.append([c["label"], _int(c["products"]), _int(c["stores"]),
_money(c.get("price_median")), _money(c.get("price_avg"))])
cat = Table(cat_rows, colWidths=[5.5 * cm, 2.4 * cm, 2.4 * cm, 3 * cm, 3 * cm])
cat.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), SAND),
("TEXTCOLOR", (0, 0), (-1, 0), INK),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 8.5),
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]),
("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
story.append(cat)
story.append(PageBreak())
# ---- Régions
story.append(_h("Marché par région administrative", h2))
story.append(Bars([(r["key"], r["products"]) for r in stats.get("by_region", [])],
bar_color=PINE))
# ---- Origine
story.append(_h("Origine des produits (classes A–E)", h2))
story.append(OriginBar(stats.get("by_origin", [])))
story.append(Spacer(1, 4))
leg = []
for o in stats.get("by_origin", []):
hexcol = "#" + ORIGIN_COLORS.get(o["key"], MUTE).hexval()[2:]
leg.append(Paragraph(
f'■ {o["key"]} '
f'{ORIGIN_LABELS.get(o["key"], o["key"])} — '
f'{_int(o.get("products"))} produits, {_int(o.get("stores"))} boutiques', small))
story += leg
# ---- Plateformes
story.append(_h("Plateformes ecommerce", h2))
plat_rows = [["Plateforme", "Boutiques", "Produits"]]
for p in stats.get("by_platform", [])[:12]:
plat_rows.append([p["key"], _int(p["stores"]), _int(p.get("products"))])
plat = Table(plat_rows, colWidths=[6 * cm, 5 * cm, 5 * cm])
plat.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), SAND),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 8.5),
("ALIGN", (1, 0), (-1, -1), "RIGHT"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]),
("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
story.append(plat)
# ---- Top boutiques
story.append(_h("Boutiques les mieux garnies", h2))
top_rows = [["#", "Boutique", "Région", "Origine", "Produits"]]
for i, s in enumerate(stats.get("top_stores", [])[:15], 1):
top_rows.append([str(i), (s["name"] or s["id"])[:38], s.get("region") or "—",
s.get("origin_class") or "—", _int(s.get("products"))])
top = Table(top_rows, colWidths=[1 * cm, 6.5 * cm, 4 * cm, 1.8 * cm, 2.7 * cm])
top.setStyle(TableStyle([
("BACKGROUND", (0, 0), (-1, 0), SAND),
("FONTNAME", (0, 0), (-1, 0), "Helvetica-Bold"),
("FONTSIZE", (0, 0), (-1, -1), 8.5),
("ALIGN", (0, 0), (0, -1), "CENTER"),
("ALIGN", (3, 0), (-1, -1), "RIGHT"),
("ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.white, CREAM]),
("LINEBELOW", (0, 0), (-1, -1), 0.4, BORDER),
("TOPPADDING", (0, 0), (-1, -1), 4),
("BOTTOMPADDING", (0, 0), (-1, -1), 4)]))
story.append(top)
# ---- Note méthodo
story.append(_h("Méthodologie & portée", h2))
cov = stats.get("coverage", {})
story.append(_h(
"Les données proviennent des catalogues publics des boutiques "
"(Shopify, WooCommerce, Wix, Squarespace) agrégés automatiquement et "
"resynchronisés aux 6 heures. Les prix et disponibilités appartiennent "
"aux marchands sources. La classification d'origine A–E s'appuie sur les "
"certifications (Aliments du Québec, Les Produits du Québec), l'adhésion "
"à des associations de producteurs et les mentions des sites — jamais sur "
"la seule présomption qu'une entreprise est québécoise. "
f"Couverture : {_int(cov.get('with_image'))} produits avec image, "
f"{_int(cov.get('with_logo'))} boutiques avec logo, "
f"{_int(cov.get('with_region'))} boutiques géolocalisées.", body))
doc.build(story)
return buf.getvalue()