"""PDF report generation for campaign sentiment analysis.""" from __future__ import annotations import re import tempfile from pathlib import Path from typing import Any from reportlab.lib import colors from reportlab.lib.pagesizes import A4 from reportlab.lib.styles import ParagraphStyle from reportlab.lib.units import mm from reportlab.platypus import ( HRFlowable, Paragraph, SimpleDocTemplate, Spacer, Table, TableStyle, ) # ---------- styles ---------- title_style = ParagraphStyle( "ReportTitle", fontSize=20, fontName="Helvetica-Bold", textColor=colors.HexColor("#1a1a2e"), spaceAfter=2, spaceBefore=6, ) subtitle_style = ParagraphStyle( "Subtitle", fontSize=13, fontName="Helvetica", textColor=colors.HexColor("#444444"), spaceAfter=6, spaceBefore=6, ) h1_style = ParagraphStyle( "H1", fontSize=15, fontName="Helvetica-Bold", textColor=colors.HexColor("#1a1a2e"), spaceBefore=14, spaceAfter=4, ) h2_style = ParagraphStyle( "H2", fontSize=12, fontName="Helvetica-Bold", textColor=colors.HexColor("#16213e"), spaceBefore=10, spaceAfter=3, ) h3_style = ParagraphStyle( "H3", fontSize=10, fontName="Helvetica-BoldOblique", textColor=colors.HexColor("#0f3460"), spaceBefore=8, spaceAfter=2, ) body_style = ParagraphStyle( "Body", fontSize=9, fontName="Helvetica", textColor=colors.HexColor("#2d2d2d"), leading=14, spaceAfter=6, ) bullet_style = ParagraphStyle( "Bullet", fontSize=9, fontName="Helvetica", textColor=colors.HexColor("#2d2d2d"), leading=13, leftIndent=12, spaceAfter=3, bulletIndent=0, ) numbered_style = ParagraphStyle( "Numbered", parent=bullet_style, spaceBefore=4, spaceAfter=10 ) cell_style = ParagraphStyle( "Cell", fontSize=8, fontName="Helvetica", textColor=colors.HexColor("#222222"), leading=12, ) cell_bold = ParagraphStyle( "CellBold", fontSize=8, fontName="Helvetica-Bold", textColor=colors.HexColor("#222222"), leading=12, ) PAGE_W = A4[0] - 40 * mm def _video_url(video_id: str) -> str: # TikTok resolves /video/ by ID alone — placeholder username is enough. return f"https://www.tiktok.com/@i/video/{video_id}" def inline_fmt(text: str) -> str: # Cap bold/italic span length to avoid censored profanity ("f**k") accidentally # matching as markdown delimiters across unrelated words. text = text.replace("&", "&").replace("&amp;", "&") text = text.replace("<", "<").replace(">", ">") text = re.sub(r"\*\*([^*\n]{1,80}?)\*\*", r"\1", text) text = re.sub(r"\*([^*\n]{1,80}?)\*", r"\1", text) text = re.sub(r"`([^`\n]{1,80}?)`", r'\1', text) return text def video_link(video_id: str) -> str: url = _video_url(video_id) return f'{video_id}' def meta_table(rows: list[list[str]]) -> Table: para_data = [] for label, value in rows: para_data.append( [ Paragraph(inline_fmt(label), cell_bold), Paragraph(inline_fmt(str(value)), cell_style), ] ) tbl = Table(para_data, colWidths=[PAGE_W * 0.28, PAGE_W * 0.72]) tbl.setStyle( TableStyle( [ ( "ROWBACKGROUNDS", (0, 0), (-1, -1), [colors.HexColor("#f5f8ff"), colors.HexColor("#ffffff")], ), ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cccccc")), ("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("VALIGN", (0, 0), (-1, -1), "TOP"), ] ) ) return tbl def _clean_remaining(text: str) -> str: text = re.sub(r"(?im)^#{1,4}\s*per[- ]video\s*breakdown\s*$", "", text) text = re.sub(r"\n[ \t]*\n[ \t]*(\n[ \t]*)+", "\n\n", text) return text.strip("\n") def extract_per_video_breakdown(reasoning_text: str) -> tuple[list[Any], str]: text = reasoning_text pattern_a = re.compile( r"\*\*Video:\s*(\d+)\*\*\s*—\s*Sentiment:\s*(\w+)\s*\(confidence:\s*([\d.]+)[^)]*\)\s*\n" r"Summary:\s*(.+?)(?=\n\*\*Video:|\n---|\Z)", re.S, ) matches_a = pattern_a.findall(text) if matches_a: rows = [ [vid, sent.capitalize(), f"{float(conf):.2f}", summ.strip()] for vid, sent, conf, summ in matches_a ] return rows, _clean_remaining(pattern_a.sub("", text)) pattern_b = re.compile( r"\*\*(\d+)\*\*\s*—\s*(\w+)\s*\(([\d.]+)[^)]*\)\s*\|\s*([\s\S]+?)(?=\n\n\*\*\d+\*\*|\n\n---|\Z)", ) matches_b = pattern_b.findall(text) if matches_b: rows = [ [vid, sent.capitalize(), f"{float(conf):.2f}", summ.strip()] for vid, sent, conf, summ in matches_b ] return rows, _clean_remaining(pattern_b.sub("", text)) return [], text def extract_per_track_breakdown(reasoning_text: str) -> tuple[list[Any], str]: text = reasoning_text pattern = re.compile( r"## Track:\s*([^\n]+?)\n+Sentiment:\s*([\w\s/\-]+?)\s*\(confidence:\s*([\d.]+)[^)]*\)\s*\n+Summary:\s*(.+?)" r"(?=\n+## Track:|\n+---|\n+## Overall|\Z)", re.S, ) matches = pattern.findall(text) if not matches: return [], text rows = [] for track, sent, conf, summ in matches: summ_clean = re.split(r"\nKey themes:", summ)[0].strip() rows.append( [ track.strip().strip('"'), sent.strip().capitalize(), f"{float(conf):.2f}", summ_clean, ] ) return rows, _clean_remaining(pattern.sub("", text)) def _sentiment_table( data: list[Any], col_widths: list[Any], first_col_bold: bool = False, col0_is_video_id: bool = False, ) -> Table: sentiment_colors = { "Positive": colors.HexColor("#1a7a3c"), "Negative": colors.HexColor("#b3261e"), "Neutral": colors.HexColor("#6b6b6b"), "Neutral-to-positive": colors.HexColor("#4a7a3c"), } header_labels = [ "Track" if first_col_bold else "Video ID", "Sentiment", "Conf.", "Summary", ] header = [Paragraph(f"{h}", cell_bold) for h in header_labels] rows = [header] for col0, sent, conf, summ in data: sent_color = sentiment_colors.get(sent.capitalize(), colors.HexColor("#555555")) sent_style = ParagraphStyle( "Sent", parent=cell_style, textColor=sent_color, fontName="Helvetica-Bold" ) col0_style = cell_bold if first_col_bold else cell_style col0_text = video_link(col0) if col0_is_video_id else inline_fmt(col0) rows.append( [ Paragraph(col0_text, col0_style), Paragraph(sent, sent_style), Paragraph(conf, cell_style), Paragraph(inline_fmt(summ), cell_style), ] ) tbl = Table(rows, colWidths=col_widths, repeatRows=1) tbl.setStyle( TableStyle( [ ("BACKGROUND", (0, 0), (-1, 0), colors.HexColor("#1a1a2e")), ("TEXTCOLOR", (0, 0), (-1, 0), colors.white), ( "ROWBACKGROUNDS", (0, 1), (-1, -1), [colors.HexColor("#f5f8ff"), colors.HexColor("#ffffff")], ), ("GRID", (0, 0), (-1, -1), 0.4, colors.HexColor("#cccccc")), ("LEFTPADDING", (0, 0), (-1, -1), 6), ("RIGHTPADDING", (0, 0), (-1, -1), 6), ("TOPPADDING", (0, 0), (-1, -1), 5), ("BOTTOMPADDING", (0, 0), (-1, -1), 5), ("VALIGN", (0, 0), (-1, -1), "TOP"), ] ) ) return tbl TRACK_SENTIMENT_COLORS = { "Positive": "#1a7a3c", "Negative": "#b3261e", "Neutral": "#6b6b6b", "Neutral-to-positive": "#4a7a3c", } def per_track_blocks(rows: list[Any]) -> list[Any]: """Flowing heading+paragraph per track — avoids ReportLab LayoutError when a per-track summary is taller than one page (a Table row can't split across pages).""" story: list[Any] = [] for track, sent, conf, summ in rows: sent_color = TRACK_SENTIMENT_COLORS.get(sent.capitalize(), "#555555") heading_style = ParagraphStyle( "TrackHeading", parent=h2_style, textColor=colors.HexColor("#1a1a2e") ) story.append( Paragraph( f'{inline_fmt(track)} — {sent} ' f'(confidence: {conf})', heading_style, ) ) story.append(Paragraph(inline_fmt(summ), body_style)) return story def per_track_table(rows: list[Any]) -> Table: return _sentiment_table( rows, [PAGE_W * 0.22, PAGE_W * 0.16, PAGE_W * 0.10, PAGE_W * 0.52], first_col_bold=True, ) def per_video_table(rows: list[Any]) -> Table: return _sentiment_table( rows, [PAGE_W * 0.20, PAGE_W * 0.14, PAGE_W * 0.10, PAGE_W * 0.56], first_col_bold=False, col0_is_video_id=True, ) def markdown_block_to_flowables(md_text: str) -> list[Any]: story = [] lines = md_text.strip("\n").split("\n") prev_blank = False for line in lines: stripped = line.strip() if stripped == "": if not prev_blank: story.append(Spacer(1, 4)) prev_blank = True continue prev_blank = False if stripped.startswith("#### "): story.append(Paragraph(inline_fmt(stripped[5:]), h3_style)) elif stripped.startswith("### "): story.append(Paragraph(inline_fmt(stripped[4:]), h2_style)) elif stripped.startswith("## "): story.append(Paragraph(inline_fmt(stripped[3:]), h1_style)) elif stripped.startswith("# "): story.append(Paragraph(inline_fmt(stripped[2:]), h1_style)) elif re.match(r"^\d+\. ", stripped): num = re.match(r"^(\d+)\. ", stripped).group(1) # type: ignore[union-attr] text = re.sub(r"^\d+\. ", "", stripped) story.append(Paragraph(f"{num}. {inline_fmt(text)}", numbered_style)) elif stripped.startswith(("- ", "* ")): story.append(Paragraph(f"• {inline_fmt(stripped[2:])}", bullet_style)) elif stripped == "---": story.append( HRFlowable( width="100%", thickness=0.5, color=colors.HexColor("#dddddd"), spaceBefore=4, spaceAfter=4, ) ) else: story.append(Paragraph(inline_fmt(stripped), body_style)) return story def section_header(text: str) -> list[Any]: return [ Paragraph(text, h1_style), HRFlowable( width="100%", thickness=0.5, color=colors.HexColor("#cccccc"), spaceAfter=4 ), ] def _reasoning_section(reasoning_text: str) -> list[Any]: rows, remaining = extract_per_video_breakdown(reasoning_text) section_label = "Per-Video Breakdown" if not rows: rows, remaining = extract_per_track_breakdown(reasoning_text) section_label = "Per-Track Breakdown" story: list[Any] = [] story += section_header("Reasoning") story += markdown_block_to_flowables(remaining) if rows: story.append(Spacer(1, 4)) story += section_header(section_label) story.append( Paragraph( "Confidence here follows the same model-assessed scale explained under " "Verdict above — higher means the sample gave clearer, more consistent signal.", body_style, ) ) if section_label == "Per-Track Breakdown": story += per_track_blocks(rows) else: story.append(per_video_table(rows)) story.append(Spacer(1, 4)) return story def build_story(data: dict[str, Any]) -> list[Any]: story = [] story.append(Paragraph("TikTok Intelligence Report", title_style)) story.append(Paragraph(data.get("campaign_config_key", ""), subtitle_style)) story.append( HRFlowable( width="100%", thickness=1.5, color=colors.HexColor("#1a1a2e"), spaceAfter=6 ) ) story.append( meta_table( [ ["Campaign key", data.get("campaign_config_key", "")], ["Run ID", data.get("run_id", "")], ["Observed at", data.get("observed_at", "")], ["Videos analysed", str(len(data.get("video_ids", [])))], ] ) ) story.append(Spacer(1, 6)) story += section_header("Verdict") story.append( meta_table( [ ["Sentiment", data.get("sentiment", "").capitalize()], ["Confidence", f"{round(data.get('confidence', 0) * 100)}%"], ] ) ) story.append( Paragraph( "Confidence is the model's own qualitative assessment of how well the collected " "sample supports its conclusions — not a statistical margin of error. See below for " "what specifically raised or capped this score.", body_style, ) ) if data.get("confidence_rationale"): story.append( Paragraph( f"Why: {inline_fmt(data['confidence_rationale'])}", body_style ) ) story.append(Spacer(1, 4)) story += section_header("Summary") story.append(Paragraph(inline_fmt(data.get("summary", "")), body_style)) if data.get("reasoning"): story += _reasoning_section(data["reasoning"]) if data.get("how_sound_is_used"): story += section_header("How the Sound Is Being Used") story.append(Paragraph(inline_fmt(data["how_sound_is_used"]), body_style)) if data.get("trend_signals"): story += section_header("Trend Signals") for sig in data["trend_signals"]: story.append(Paragraph(inline_fmt(sig.get("signal", "")), h2_style)) story.append(Paragraph(inline_fmt(sig.get("evidence", "")), body_style)) vids = sig.get("supporting_video_ids", []) if vids: story.append( Paragraph( f"Videos: {' · '.join(video_link(v) for v in vids)}", body_style, ) ) story.append(Spacer(1, 2)) if data.get("virality_factors"): story += section_header("Virality Factors") for item in data["virality_factors"]: story.append(Paragraph(f"• {inline_fmt(str(item))}", bullet_style)) if data.get("detected_topics"): story += section_header("Detected Topics") story.append( Paragraph(inline_fmt(" · ".join(data["detected_topics"])), body_style) ) story += section_header("Primary Hashtags") tags = data.get("primary_hashtags", []) story.append( Paragraph( inline_fmt(" · ".join(tags)) if tags else "None captured in this run.", body_style, ) ) if data.get("video_ids"): story += section_header("Video IDs Analysed") story.append( Paragraph(" · ".join(video_link(v) for v in data["video_ids"]), body_style) ) return story def build_pdf(data: dict[str, Any], pdf_path: str | Path) -> Path: pdf_path = Path(pdf_path) pdf_path.parent.mkdir(parents=True, exist_ok=True) doc = SimpleDocTemplate( str(pdf_path), pagesize=A4, leftMargin=20 * mm, rightMargin=20 * mm, topMargin=18 * mm, bottomMargin=18 * mm, ) doc.build(build_story(data)) return pdf_path def save_pdf_temp(data: dict[str, Any]) -> str: """Write the PDF to a temp file and return the path (caller must clean up).""" campaign_config_key = data.get("campaign_config_key", "report") with tempfile.NamedTemporaryFile( suffix=".pdf", delete=False, prefix=f"{campaign_config_key}_" ) as f: build_pdf(data, f.name) return f.name