This commit is contained in:
parent
4234a181e5
commit
dbee0c4817
|
|
@ -752,53 +752,143 @@ class PDFGenerator:
|
||||||
|
|
||||||
buffer = io.BytesIO()
|
buffer = io.BytesIO()
|
||||||
|
|
||||||
# Create PDF document
|
# Define team structure
|
||||||
doc = SimpleDocTemplate(
|
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,
|
buffer,
|
||||||
pagesize=A4,
|
pagesize=A4,
|
||||||
rightMargin=1.5*cm,
|
rightMargin=1.5*cm,
|
||||||
leftMargin=1.5*cm,
|
leftMargin=1.5*cm,
|
||||||
topMargin=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 = []
|
elements = []
|
||||||
styles = self._get_styles()
|
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.extend(self._create_cover_page(ticker, analysis_date, styles))
|
||||||
|
elements.append(NextPageTemplate('toc'))
|
||||||
elements.append(PageBreak())
|
elements.append(PageBreak())
|
||||||
|
|
||||||
# === TABLE OF CONTENTS PAGE (Page 2) ===
|
# Check if we have chart data
|
||||||
# Pass has_chart flag to TOC so it knows to include chart page entry
|
|
||||||
has_chart = price_data and len(price_data) >= 5
|
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())
|
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:
|
if has_chart:
|
||||||
elements.extend(self._create_chart_page(ticker, price_data, price_stats, styles))
|
elements.extend(self._create_chart_page(ticker, price_data, price_stats, styles))
|
||||||
elements.append(PageBreak())
|
elements.append(PageBreak())
|
||||||
|
|
||||||
# === ANALYST REPORTS (Starting from Page 3 or 4) ===
|
# === ANALYST REPORTS BY TEAM ===
|
||||||
for i, report in enumerate(reports):
|
for team_idx, team in enumerate(TEAMS):
|
||||||
analyst_name = report.get('analyst_name', 'Unknown')
|
# Get reports for this team
|
||||||
report_content = report.get('report_content', '')
|
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
|
continue
|
||||||
|
|
||||||
# Add analyst report section
|
# Add team separator page
|
||||||
elements.extend(self._create_analyst_section(
|
elements.extend(self._create_team_separator(team['name'], len(team_reports), styles))
|
||||||
analyst_name=analyst_name,
|
elements.append(PageBreak())
|
||||||
ticker=ticker,
|
|
||||||
analysis_date=analysis_date,
|
|
||||||
report_content=report_content,
|
|
||||||
styles=styles,
|
|
||||||
))
|
|
||||||
|
|
||||||
# Page break between analysts (except for the last one)
|
# Add each analyst report in this team
|
||||||
if i < len(reports) - 1:
|
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())
|
elements.append(PageBreak())
|
||||||
|
|
||||||
# Build PDF
|
# Build PDF
|
||||||
|
|
@ -975,7 +1065,8 @@ class PDFGenerator:
|
||||||
price_data: list,
|
price_data: list,
|
||||||
price_stats: dict,
|
price_stats: dict,
|
||||||
styles: dict,
|
styles: dict,
|
||||||
has_chart: bool = False
|
has_chart: bool = False,
|
||||||
|
teams: list = None
|
||||||
) -> list:
|
) -> list:
|
||||||
"""Create a clean table of contents page with page numbers"""
|
"""Create a clean table of contents page with page numbers"""
|
||||||
from reportlab.platypus import Spacer, Table, TableStyle
|
from reportlab.platypus import Spacer, Table, TableStyle
|
||||||
|
|
@ -987,13 +1078,14 @@ class PDFGenerator:
|
||||||
|
|
||||||
# TOC Title
|
# TOC Title
|
||||||
elements.append(Paragraph("目 錄", styles['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
|
# Track which analysts are in the reports
|
||||||
# Cover page = 1, TOC = 2
|
report_analyst_names = [r.get('analyst_name', '') for r in reports]
|
||||||
# If has_chart: Chart page = 3, analysts start from page 4
|
|
||||||
# If no chart: analysts start from page 3
|
# Page numbering: Page 1 starts from chart page
|
||||||
current_page = 3
|
# Cover and TOC don't have page numbers
|
||||||
|
current_page = 1
|
||||||
|
|
||||||
# Build TOC table data
|
# Build TOC table data
|
||||||
table_data = []
|
table_data = []
|
||||||
|
|
@ -1005,48 +1097,37 @@ class PDFGenerator:
|
||||||
# Add chart page entry if available
|
# Add chart page entry if available
|
||||||
if has_chart:
|
if has_chart:
|
||||||
table_data.append([
|
table_data.append([
|
||||||
Paragraph('價格走勢圖 & 交易量柱狀圖', styles['toc_item']),
|
Paragraph(' 價格走勢圖 & 交易量柱狀圖', styles['toc_item']),
|
||||||
Paragraph(f'{current_page}', styles['toc_item'])
|
Paragraph(f'{current_page}', styles['toc_item'])
|
||||||
])
|
])
|
||||||
current_page += 1
|
current_page += 1
|
||||||
|
|
||||||
# Add separator
|
# Use teams if provided
|
||||||
table_data.append([
|
if teams:
|
||||||
Paragraph('<b>分析報告</b>', styles['toc_section']),
|
for team in teams:
|
||||||
''
|
team_name = team['name']
|
||||||
])
|
team_members = team['members']
|
||||||
|
|
||||||
# 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
|
|
||||||
|
|
||||||
# Add category header
|
# Count how many members have reports
|
||||||
table_data.append([
|
team_report_count = sum(1 for m in team_members if m in report_analyst_names)
|
||||||
Paragraph(f' <b>{category}</b>', styles['toc_item']),
|
if team_report_count == 0:
|
||||||
''
|
continue
|
||||||
])
|
|
||||||
|
# Add team separator entry
|
||||||
# Add each analyst
|
table_data.append([
|
||||||
for analyst_name in analysts:
|
Paragraph(f'<b>{team_name} ({team_report_count} 位)</b>', styles['toc_section']),
|
||||||
if analyst_name in report_analyst_names:
|
Paragraph(f'{current_page}', styles['toc_item'])
|
||||||
table_data.append([
|
])
|
||||||
Paragraph(f' {analyst_name}', styles['toc_item']),
|
current_page += 1 # Team separator page
|
||||||
Paragraph(f'{current_page}', styles['toc_item'])
|
|
||||||
])
|
# Add each analyst in this team
|
||||||
current_page += 1
|
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
|
# Create table
|
||||||
col_widths = [14*cm, 2*cm]
|
col_widths = [14*cm, 2*cm]
|
||||||
|
|
@ -1059,8 +1140,8 @@ class PDFGenerator:
|
||||||
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
('VALIGN', (0, 0), (-1, -1), 'MIDDLE'),
|
||||||
('FONTNAME', (0, 0), (-1, -1), self.primary_font),
|
('FONTNAME', (0, 0), (-1, -1), self.primary_font),
|
||||||
('FONTSIZE', (0, 0), (-1, -1), 11),
|
('FONTSIZE', (0, 0), (-1, -1), 11),
|
||||||
('BOTTOMPADDING', (0, 0), (-1, -1), 8),
|
('BOTTOMPADDING', (0, 0), (-1, -1), 6),
|
||||||
('TOPPADDING', (0, 0), (-1, -1), 8),
|
('TOPPADDING', (0, 0), (-1, -1), 6),
|
||||||
('LINEBELOW', (0, 0), (-1, 0), 1, black), # Header line
|
('LINEBELOW', (0, 0), (-1, 0), 1, black), # Header line
|
||||||
('LINEBELOW', (0, -1), (-1, -1), 0.5, lightgrey), # Bottom line
|
('LINEBELOW', (0, -1), (-1, -1), 0.5, lightgrey), # Bottom line
|
||||||
])
|
])
|
||||||
|
|
@ -1070,6 +1151,52 @@ class PDFGenerator:
|
||||||
|
|
||||||
return elements
|
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(
|
def _create_chart_page(
|
||||||
self,
|
self,
|
||||||
ticker: str,
|
ticker: str,
|
||||||
|
|
@ -1301,8 +1428,8 @@ class PDFGenerator:
|
||||||
text = line[2:-2]
|
text = line[2:-2]
|
||||||
elements.append(Paragraph(self._escape_html(text), styles['heading']))
|
elements.append(Paragraph(self._escape_html(text), styles['heading']))
|
||||||
elif line.startswith('- ') or line.startswith('* '):
|
elif line.startswith('- ') or line.startswith('* '):
|
||||||
# Bullet points
|
# Bullet points - use simple dash instead of Unicode bullet
|
||||||
text = ' ● ' + line[2:]
|
text = ' - ' + line[2:]
|
||||||
elements.append(Paragraph(self._escape_html(text), styles['body']))
|
elements.append(Paragraph(self._escape_html(text), styles['body']))
|
||||||
else:
|
else:
|
||||||
# Clean any remaining markdown
|
# Clean any remaining markdown
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue