From dbee0c4817e0c92573ad317a3fdbb5fba32b8beb Mon Sep 17 00:00:00 2001 From: MarkLo Date: Tue, 16 Dec 2025 01:42:30 +0800 Subject: [PATCH] --- backend/app/services/pdf_generator.py | 269 +++++++++++++++++++------- 1 file changed, 198 insertions(+), 71 deletions(-) diff --git a/backend/app/services/pdf_generator.py b/backend/app/services/pdf_generator.py index d5a42a37..46c5393f 100644 --- a/backend/app/services/pdf_generator.py +++ b/backend/app/services/pdf_generator.py @@ -752,53 +752,143 @@ class PDFGenerator: buffer = io.BytesIO() - # Create PDF document - doc = SimpleDocTemplate( + # Define team structure + TEAMS = [ + { + 'name': '分析師團隊', + 'count': 4, + 'members': ['市場分析師', '社群媒體分析師', '新聞分析師', '基本面分析師'], + }, + { + 'name': '研究團隊', + 'count': 3, + 'members': ['看漲研究員', '看跌研究員', '研究經理'], + }, + { + 'name': '交易與風險團隊', + 'count': 5, + 'members': ['交易員', '激進分析師', '保守分析師', '中立分析師', '風險經理'], + }, + ] + + # Create a mapping of analyst names to their reports + report_map = {r.get('analyst_name', ''): r.get('report_content', '') for r in reports} + + # Create PDF document with custom page numbering + # Cover and TOC don't have page numbers + # Page numbering starts from chart page = Page 1 + + buffer = io.BytesIO() + + from reportlab.platypus import BaseDocTemplate, PageTemplate, Frame, NextPageTemplate, PageBreak as PB + from reportlab.lib.pagesizes import A4 + from io import BytesIO + + # Track pages for numbering (pages after TOC) + self._page_offset = 2 # Cover + TOC = 2 pages without numbers + + def add_page_number(canvas, doc): + """Add page number to footer for content pages""" + page_num = doc.page - self._page_offset + if page_num > 0: + canvas.saveState() + canvas.setFont(self.primary_font, 10) + page_text = f"- {page_num} -" + canvas.drawCentredString(A4[0] / 2, 1 * cm, page_text) + canvas.restoreState() + + def no_page_number(canvas, doc): + """No page number for cover and TOC""" + pass + + # Create document + doc = BaseDocTemplate( buffer, pagesize=A4, rightMargin=1.5*cm, leftMargin=1.5*cm, topMargin=1.5*cm, - bottomMargin=1.5*cm, + bottomMargin=2*cm, ) + # Define frames + frame = Frame( + doc.leftMargin, + doc.bottomMargin, + doc.width, + doc.height, + id='normal' + ) + + # Define page templates + # 'cover' and 'toc' templates have no page numbers + # 'content' template has page numbers + doc.addPageTemplates([ + PageTemplate(id='cover', frames=frame, onPage=no_page_number), + PageTemplate(id='toc', frames=frame, onPage=no_page_number), + PageTemplate(id='content', frames=frame, onPage=add_page_number), + ]) + elements = [] styles = self._get_styles() - # === COVER PAGE (Page 1) === + # === COVER PAGE (no page number) === + elements.append(NextPageTemplate('cover')) elements.extend(self._create_cover_page(ticker, analysis_date, styles)) + elements.append(NextPageTemplate('toc')) elements.append(PageBreak()) - # === TABLE OF CONTENTS PAGE (Page 2) === - # Pass has_chart flag to TOC so it knows to include chart page entry + # Check if we have chart data has_chart = price_data and len(price_data) >= 5 - elements.extend(self._create_toc_page(ticker, analysis_date, reports, price_data, price_stats, styles, has_chart)) + + # === TABLE OF CONTENTS PAGE (no page number) === + elements.extend(self._create_toc_page(ticker, analysis_date, reports, price_data, price_stats, styles, has_chart, TEAMS)) + elements.append(NextPageTemplate('content')) elements.append(PageBreak()) - # === PRICE CHART PAGE (Page 3, if data available) === + # ======================================== + # CONTENT PAGES WITH PAGE NUMBERS + # ======================================== + + # === PRICE CHART PAGE (Page 1, if data available) === if has_chart: elements.extend(self._create_chart_page(ticker, price_data, price_stats, styles)) elements.append(PageBreak()) - # === ANALYST REPORTS (Starting from Page 3 or 4) === - for i, report in enumerate(reports): - analyst_name = report.get('analyst_name', 'Unknown') - report_content = report.get('report_content', '') + # === ANALYST REPORTS BY TEAM === + for team_idx, team in enumerate(TEAMS): + # Get reports for this team + team_reports = [] + for member_name in team['members']: + if member_name in report_map and report_map[member_name]: + team_reports.append({ + 'analyst_name': member_name, + 'report_content': report_map[member_name] + }) - if not report_content: + # Skip team if no reports + if not team_reports: continue - # Add analyst report section - elements.extend(self._create_analyst_section( - analyst_name=analyst_name, - ticker=ticker, - analysis_date=analysis_date, - report_content=report_content, - styles=styles, - )) + # Add team separator page + elements.extend(self._create_team_separator(team['name'], len(team_reports), styles)) + elements.append(PageBreak()) - # Page break between analysts (except for the last one) - if i < len(reports) - 1: + # Add each analyst report in this team + for report_idx, report in enumerate(team_reports): + analyst_name = report.get('analyst_name', 'Unknown') + report_content = report.get('report_content', '') + + # Add analyst report section + elements.extend(self._create_analyst_section( + analyst_name=analyst_name, + ticker=ticker, + analysis_date=analysis_date, + report_content=report_content, + styles=styles, + )) + + # Page break after each analyst elements.append(PageBreak()) # Build PDF @@ -975,7 +1065,8 @@ class PDFGenerator: price_data: list, price_stats: dict, styles: dict, - has_chart: bool = False + has_chart: bool = False, + teams: list = None ) -> list: """Create a clean table of contents page with page numbers""" from reportlab.platypus import Spacer, Table, TableStyle @@ -987,13 +1078,14 @@ class PDFGenerator: # TOC Title elements.append(Paragraph("目 錄", styles['toc_title'])) - elements.append(Spacer(1, 1*cm)) + elements.append(Spacer(1, 0.5*cm)) - # Calculate page numbers for each section - # Cover page = 1, TOC = 2 - # If has_chart: Chart page = 3, analysts start from page 4 - # If no chart: analysts start from page 3 - current_page = 3 + # Track which analysts are in the reports + report_analyst_names = [r.get('analyst_name', '') for r in reports] + + # Page numbering: Page 1 starts from chart page + # Cover and TOC don't have page numbers + current_page = 1 # Build TOC table data table_data = [] @@ -1005,48 +1097,37 @@ class PDFGenerator: # Add chart page entry if available if has_chart: table_data.append([ - Paragraph('價格走勢圖 & 交易量柱狀圖', styles['toc_item']), + Paragraph(' 價格走勢圖 & 交易量柱狀圖', styles['toc_item']), Paragraph(f'{current_page}', styles['toc_item']) ]) current_page += 1 - # Add separator - table_data.append([ - Paragraph('分析報告', styles['toc_section']), - '' - ]) - - # Group analysts by category with page numbers - analyst_categories = [ - ('分析師組', ['市場分析師', '基本面分析師', '社群媒體分析師', '新聞分析師']), - ('研究員組', ['看漲研究員', '看跌研究員']), - ('風險辯論組', ['激進分析師', '保守分析師', '中立分析師']), - ('決策組', ['研究經理', '風險經理', '交易員']), - ] - - # Track which analysts are in the reports - report_analyst_names = [r.get('analyst_name', '') for r in reports] - - for category, analysts in analyst_categories: - # Check if any analyst in this category exists - category_has_analysts = any(name in report_analyst_names for name in analysts) - if not category_has_analysts: - continue + # Use teams if provided + if teams: + for team in teams: + team_name = team['name'] + team_members = team['members'] - # Add category header - table_data.append([ - Paragraph(f' {category}', styles['toc_item']), - '' - ]) - - # Add each analyst - for analyst_name in analysts: - if analyst_name in report_analyst_names: - table_data.append([ - Paragraph(f' {analyst_name}', styles['toc_item']), - Paragraph(f'{current_page}', styles['toc_item']) - ]) - current_page += 1 + # Count how many members have reports + team_report_count = sum(1 for m in team_members if m in report_analyst_names) + if team_report_count == 0: + continue + + # Add team separator entry + table_data.append([ + Paragraph(f'{team_name} ({team_report_count} 位)', styles['toc_section']), + Paragraph(f'{current_page}', styles['toc_item']) + ]) + current_page += 1 # Team separator page + + # Add each analyst in this team + for analyst_name in team_members: + if analyst_name in report_analyst_names: + table_data.append([ + Paragraph(f' {analyst_name}', styles['toc_item']), + Paragraph(f'{current_page}', styles['toc_item']) + ]) + current_page += 1 # Create table col_widths = [14*cm, 2*cm] @@ -1059,8 +1140,8 @@ class PDFGenerator: ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('FONTNAME', (0, 0), (-1, -1), self.primary_font), ('FONTSIZE', (0, 0), (-1, -1), 11), - ('BOTTOMPADDING', (0, 0), (-1, -1), 8), - ('TOPPADDING', (0, 0), (-1, -1), 8), + ('BOTTOMPADDING', (0, 0), (-1, -1), 6), + ('TOPPADDING', (0, 0), (-1, -1), 6), ('LINEBELOW', (0, 0), (-1, 0), 1, black), # Header line ('LINEBELOW', (0, -1), (-1, -1), 0.5, lightgrey), # Bottom line ]) @@ -1070,6 +1151,52 @@ class PDFGenerator: return elements + def _create_team_separator( + self, + team_name: str, + member_count: int, + styles: dict + ) -> list: + """Create a team separator page""" + from reportlab.platypus import Spacer + from reportlab.lib.units import cm + from reportlab.lib.styles import ParagraphStyle + from reportlab.lib.enums import TA_CENTER + from reportlab.lib.colors import HexColor + + elements = [] + + # Center the team name vertically + elements.append(Spacer(1, 8*cm)) + + # Team name with member count + team_style = ParagraphStyle( + 'TeamSeparator', + fontName=self.primary_font, + fontSize=36, + textColor=HexColor('#2c3e50'), + alignment=TA_CENTER, + spaceAfter=20, + leading=50, + ) + + # Add spaces for better letter spacing + spaced_name = ' '.join(team_name) + elements.append(Paragraph(spaced_name, team_style)) + + # Member count + count_style = ParagraphStyle( + 'TeamCount', + fontName=self.primary_font, + fontSize=24, + textColor=HexColor('#7f8c8d'), + alignment=TA_CENTER, + spaceAfter=10, + ) + elements.append(Paragraph(f"({member_count} 位)", count_style)) + + return elements + def _create_chart_page( self, ticker: str, @@ -1301,8 +1428,8 @@ class PDFGenerator: text = line[2:-2] elements.append(Paragraph(self._escape_html(text), styles['heading'])) elif line.startswith('- ') or line.startswith('* '): - # Bullet points - text = ' ● ' + line[2:] + # Bullet points - use simple dash instead of Unicode bullet + text = ' - ' + line[2:] elements.append(Paragraph(self._escape_html(text), styles['body'])) else: # Clean any remaining markdown