#!/usr/bin/env python3 """ OCI Deal Accelerator — Customer-Facing PDF Generator Produces a branded PDF document using Oracle Redwood design system colors and typography. This is the CUSTOMER-FACING output — all internal KB references, field finding IDs, gotcha codes, and internal notes are stripped automatically. Usage: python oci_pdf_gen.py --spec proposal-data.yaml --output proposal.pdf python oci_pdf_gen.py --spec proposal-data.yaml --output proposal.pdf --diagram arch.png Or import and use programmatically: from oci_pdf_gen import OCIPDFGenerator gen = OCIPDFGenerator.from_spec(spec) gen.save("proposal.pdf") """ import re import yaml import argparse from datetime import datetime from pathlib import Path from typing import Optional from reportlab.lib.pagesizes import A4 from reportlab.lib.units import inch, mm from reportlab.lib.colors import HexColor, white, black from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle from reportlab.lib.enums import TA_LEFT, TA_CENTER, TA_RIGHT from reportlab.platypus import ( SimpleDocTemplate, Paragraph, Spacer, Table, TableStyle, PageBreak, Image, KeepTogether, HRFlowable, ) from reportlab.platypus.doctemplate import PageTemplate, BaseDocTemplate, Frame from reportlab.lib.pagesizes import letter # ============================================================ # Oracle Redwood Design System Colors # ============================================================ class Colors: DARK_BG = HexColor("#312D2A") PRIMARY_TEXT = HexColor("#312D2A") SECONDARY_TEXT = HexColor("#6F6964") WARM_BG = HexColor("#FCFBFA") ORACLE_RED = HexColor("#C74634") GOLD = HexColor("#FACD62") TEAL = HexColor("#2C5967") SAGE = HexColor("#759C6C") FOREST = HexColor("#2B6242") MUTED_TEAL = HexColor("#94AFAF") BURNT_ORANGE = HexColor("#AE562C") COPPER = HexColor("#AA643B") PURPLE = HexColor("#804998") SUCCESS = HexColor("#2B6242") WARNING = HexColor("#AE562C") ERROR = HexColor("#C74634") TABLE_ALT_ROW = HexColor("#F5F4F2") TABLE_HEADER_BG = HexColor("#2C5967") WHITE = HexColor("#FFFFFF") LIGHT_GRAY = HexColor("#E8E6E3") # ============================================================ # Internal reference sanitizer # ============================================================ # Patterns that identify internal-only content _INTERNAL_PATTERNS = [ r'FK-\d{3}', # Field knowledge IDs r'FF-\d{6}-\d{3}', # Field finding IDs r'\bkb/\S+', # KB file paths r'\bgotcha[s]?\b', # gotcha references r'field.?finding[s]?', # field finding mentions r'confidence:\s*(validated|observed|reported|inferred)', r'contributor:\s*\{[^}]+\}', # contributor blocks r'severity:\s*(HIGH|MEDIUM|LOW)\s*$', # raw severity tags r'SR#\s*\d+', # Oracle SR numbers ] _INTERNAL_RE = re.compile('|'.join(_INTERNAL_PATTERNS), re.IGNORECASE) def sanitize_text(text: str) -> str: """Remove internal KB references from customer-facing text.""" if not text: return text # Remove lines that are purely internal references lines = text.split('\n') cleaned = [] for line in lines: # Skip lines that are just internal IDs stripped = line.strip() if stripped and re.match(r'^(FK-\d{3}|FF-\d{6}-\d{3})\b', stripped): continue # Remove inline internal refs line = re.sub(r'\(FK-\d{3}\)', '', line) line = re.sub(r'\(FF-\d{6}-\d{3}\)', '', line) line = re.sub(r'\bkb/\S+', '', line) line = re.sub(r'\s{2,}', ' ', line).strip() cleaned.append(line) return '\n'.join(cleaned) def sanitize_list(items: list) -> list: """Sanitize a list of strings.""" return [sanitize_text(str(item)) for item in items if item] # ============================================================ # Styles # ============================================================ def build_styles(): """Build Oracle Redwood paragraph styles.""" styles = getSampleStyleSheet() styles.add(ParagraphStyle( 'CoverTitle', fontName='Helvetica-Bold', fontSize=28, leading=34, textColor=white, alignment=TA_LEFT, spaceAfter=6, )) styles.add(ParagraphStyle( 'CoverSubtitle', fontName='Helvetica', fontSize=18, leading=22, textColor=HexColor("#9E9892"), alignment=TA_LEFT, spaceAfter=4, )) styles.add(ParagraphStyle( 'CoverInfo', fontName='Helvetica', fontSize=11, leading=14, textColor=HexColor("#9E9892"), alignment=TA_LEFT, )) styles.add(ParagraphStyle( 'SectionTitle', fontName='Helvetica-Bold', fontSize=20, leading=26, textColor=Colors.PRIMARY_TEXT, spaceBefore=18, spaceAfter=8, )) styles.add(ParagraphStyle( 'SubsectionTitle', fontName='Helvetica-Bold', fontSize=14, leading=18, textColor=Colors.TEAL, spaceBefore=12, spaceAfter=6, )) styles.add(ParagraphStyle( 'BodyText_Redwood', fontName='Helvetica', fontSize=10, leading=14, textColor=Colors.PRIMARY_TEXT, spaceAfter=6, )) styles.add(ParagraphStyle( 'BulletItem', fontName='Helvetica', fontSize=10, leading=14, textColor=Colors.PRIMARY_TEXT, leftIndent=18, bulletIndent=6, spaceAfter=3, )) styles.add(ParagraphStyle( 'Emphasis', fontName='Helvetica-Bold', fontSize=11, leading=15, textColor=Colors.TEAL, spaceAfter=6, )) styles.add(ParagraphStyle( 'FooterStyle', fontName='Helvetica', fontSize=7, leading=9, textColor=Colors.SECONDARY_TEXT, alignment=TA_CENTER, )) styles.add(ParagraphStyle( 'Disclaimer', fontName='Helvetica-Oblique', fontSize=8, leading=10, textColor=Colors.SECONDARY_TEXT, spaceAfter=4, )) styles.add(ParagraphStyle( 'ImpactStatement', fontName='Helvetica-Bold', fontSize=16, leading=22, textColor=Colors.TEAL, alignment=TA_LEFT, spaceBefore=12, spaceAfter=12, )) styles.add(ParagraphStyle( 'MetricBig', fontName='Helvetica-Bold', fontSize=36, leading=42, textColor=Colors.ORACLE_RED, alignment=TA_CENTER, spaceBefore=18, spaceAfter=6, )) styles.add(ParagraphStyle( 'TableHeader', fontName='Helvetica-Bold', fontSize=9, leading=12, textColor=white, )) styles.add(ParagraphStyle( 'TableCell', fontName='Helvetica', fontSize=9, leading=12, textColor=Colors.PRIMARY_TEXT, )) styles.add(ParagraphStyle( 'TableCellBold', fontName='Helvetica-Bold', fontSize=9, leading=12, textColor=Colors.PRIMARY_TEXT, )) return styles # ============================================================ # PDF Generator # ============================================================ class OCIPDFGenerator: """Generate Oracle Redwood-branded customer-facing PDF documents.""" PAGE_WIDTH, PAGE_HEIGHT = letter # 8.5 x 11 inches MARGIN = 0.75 * inch def __init__(self, customer: str = "", project: str = "", architect: str = "", firm: str = ""): self.customer = customer self.project = project self.architect = architect self.firm = firm self.styles = build_styles() self.story = [] # flowable elements self._page_num = 0 self._total_pages = 0 # ---- Page templates ---- def _header_footer(self, canvas, doc): """Draw header and footer on each page.""" canvas.saveState() page_num = canvas.getPageNumber() if page_num == 1: # Cover page — no header/footer canvas.restoreState() return # Header: thin Oracle Red line canvas.setStrokeColor(Colors.ORACLE_RED) canvas.setLineWidth(2) canvas.line( self.MARGIN, self.PAGE_HEIGHT - 0.5 * inch, self.PAGE_WIDTH - self.MARGIN, self.PAGE_HEIGHT - 0.5 * inch, ) # Header: customer name (left) canvas.setFont("Helvetica", 8) canvas.setFillColor(Colors.SECONDARY_TEXT) canvas.drawString( self.MARGIN, self.PAGE_HEIGHT - 0.45 * inch, self.customer, ) # Header: "Confidential" (right) canvas.drawRightString( self.PAGE_WIDTH - self.MARGIN, self.PAGE_HEIGHT - 0.45 * inch, "Confidential", ) # Footer: thin gray line canvas.setStrokeColor(Colors.LIGHT_GRAY) canvas.setLineWidth(0.5) canvas.line( self.MARGIN, 0.55 * inch, self.PAGE_WIDTH - self.MARGIN, 0.55 * inch, ) # Footer: page number (center) canvas.setFont("Helvetica", 8) canvas.setFillColor(Colors.SECONDARY_TEXT) canvas.drawCentredString( self.PAGE_WIDTH / 2, 0.4 * inch, f"Page {page_num}", ) # Footer: date (right) canvas.drawRightString( self.PAGE_WIDTH - self.MARGIN, 0.4 * inch, datetime.now().strftime("%B %Y"), ) canvas.restoreState() def _thank_you_page(self, canvas, doc): """Draw the Thank You page background (dark, matches cover).""" canvas.saveState() canvas.setFillColor(Colors.DARK_BG) canvas.rect(0, 0, self.PAGE_WIDTH, self.PAGE_HEIGHT, fill=1, stroke=0) # Oracle Red accent bar matching cover canvas.setFillColor(Colors.ORACLE_RED) canvas.rect( 0.6 * inch, 3.2 * inch, 4, 2.2 * inch, fill=1, stroke=0, ) canvas.restoreState() def _cover_page(self, canvas, doc): """Draw the cover page background.""" canvas.saveState() # Full dark background canvas.setFillColor(Colors.DARK_BG) canvas.rect(0, 0, self.PAGE_WIDTH, self.PAGE_HEIGHT, fill=1, stroke=0) # Oracle Red vertical accent bar canvas.setFillColor(Colors.ORACLE_RED) canvas.rect( 0.6 * inch, 3.2 * inch, 4, 2.2 * inch, fill=1, stroke=0, ) # "Prepared with OCI Deal Accelerator" at bottom canvas.setFont("Helvetica-Oblique", 8) canvas.setFillColor(HexColor("#6F6964")) canvas.drawString( 0.85 * inch, 1.2 * inch, "Prepared with OCI Deal Accelerator", ) # Oracle safe harbor footer canvas.setFont("Helvetica", 6) canvas.setFillColor(HexColor("#6F6964")) canvas.drawString( 0.85 * inch, 0.7 * inch, "This document is provided for informational purposes only. " "Actual results may vary.", ) canvas.restoreState() # ---- Section builders ---- def add_cover(self, subtitle: str = ""): """Add cover page elements (text positioned over the dark bg).""" # Spacer to push content down (cover bg is drawn by page template) self.story.append(Spacer(1, 3.0 * inch)) # Customer name title = self.customer or "Architecture Proposal" self.story.append(Paragraph(title, self.styles['CoverTitle'])) # Project if self.project: self.story.append(Paragraph(self.project, self.styles['CoverSubtitle'])) # Subtitle date_str = datetime.now().strftime("%B %Y") sub = subtitle or f"Architecture Proposal — {date_str}" self.story.append(Spacer(1, 6)) self.story.append(Paragraph(sub, self.styles['CoverInfo'])) # Architect / Firm if self.architect or self.firm: info = self.architect if self.firm: info += f" | {self.firm}" if self.architect else self.firm self.story.append(Spacer(1, 1.5 * inch)) self.story.append(Paragraph(info, self.styles['CoverInfo'])) self.story.append(PageBreak()) def _add_section_title(self, title: str): """Add a section title with Oracle Red underline.""" self.story.append(Paragraph(title, self.styles['SectionTitle'])) self.story.append(HRFlowable( width="25%", thickness=2, color=Colors.ORACLE_RED, spaceAfter=10, spaceBefore=0, hAlign='LEFT', )) def _add_subsection(self, title: str): """Add a subsection title.""" self.story.append(Paragraph(title, self.styles['SubsectionTitle'])) def _add_body(self, text: str): """Add body text (sanitized).""" text = sanitize_text(text) if text: self.story.append(Paragraph(text, self.styles['BodyText_Redwood'])) def _add_bullet_list(self, items: list): """Add a bulleted list (sanitized).""" for item in sanitize_list(items): if item.strip(): self.story.append(Paragraph( f"• {item}", self.styles['BulletItem'], )) def _make_table(self, headers: list, rows: list, col_widths: list = None) -> Table: """Build a styled table.""" # Header row header_cells = [ Paragraph(h, self.styles['TableHeader']) for h in headers ] # Data rows data = [header_cells] for row in rows: data.append([ Paragraph(sanitize_text(str(cell)), self.styles['TableCell']) for cell in row ]) table = Table(data, colWidths=col_widths, repeatRows=1) # Style style_cmds = [ ('BACKGROUND', (0, 0), (-1, 0), Colors.TABLE_HEADER_BG), ('TEXTCOLOR', (0, 0), (-1, 0), white), ('FONTNAME', (0, 0), (-1, 0), 'Helvetica-Bold'), ('FONTSIZE', (0, 0), (-1, -1), 9), ('ALIGN', (0, 0), (-1, 0), 'CENTER'), ('VALIGN', (0, 0), (-1, -1), 'MIDDLE'), ('GRID', (0, 0), (-1, -1), 0.5, Colors.LIGHT_GRAY), ('TOPPADDING', (0, 0), (-1, -1), 4), ('BOTTOMPADDING', (0, 0), (-1, -1), 4), ('LEFTPADDING', (0, 0), (-1, -1), 6), ('RIGHTPADDING', (0, 0), (-1, -1), 6), ] # Alternating row colors for i in range(1, len(data)): if i % 2 == 0: style_cmds.append( ('BACKGROUND', (0, i), (-1, i), Colors.TABLE_ALT_ROW) ) table.setStyle(TableStyle(style_cmds)) return table # ---- Content sections ---- def add_executive_summary(self, why: str, current_state: list, target_state: str, timeline: str): """Executive Summary section.""" self._add_section_title("Executive Summary") # Impact statement self.story.append(Paragraph( sanitize_text(why), self.styles['ImpactStatement'], )) self.story.append(Spacer(1, 8)) # Current state self._add_subsection("Current State") self._add_bullet_list(current_state) # Target state self._add_subsection("Target State") self._add_body(target_state) # Timeline self.story.append(Spacer(1, 6)) self.story.append(Paragraph( f"Timeline: {sanitize_text(timeline)}", self.styles['BodyText_Redwood'], )) def add_service_tiering(self, workloads: list): """Service Tiering section.""" self._add_section_title("Service Tiering") self._add_body( "Each tier drives HA/DR topology, backup strategy, " "isolation model, and support level." ) headers = ["Workload", "Tier", "Uptime", "RTO", "RPO"] rows = [ [wl.get("name", ""), wl.get("tier", ""), wl.get("uptime", ""), wl.get("rto", ""), wl.get("rpo", "")] for wl in workloads ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.28, avail * 0.15, avail * 0.17, avail * 0.20, avail * 0.20] self.story.append(self._make_table(headers, rows, widths)) self.story.append(Spacer(1, 12)) def add_architecture_principles(self, principles: dict): """Architecture Principles section.""" self._add_section_title("Architecture Principles") for category in ["design", "deployment", "service"]: items = principles.get(category, []) if not items: continue self._add_subsection(category.title()) for item in items: pid = item.get("id", "") name = item.get("name", "") summary = item.get("summary", "") label = f"{pid} {name}" if pid else f"{name}" if summary: label += f" — {summary}" self.story.append(Paragraph( f"• {label}", self.styles['BulletItem'], )) def add_architecture_overview(self, description: str = "", diagram_path: str = None): """Architecture Overview section.""" self._add_section_title("Architecture Overview") if diagram_path and Path(diagram_path).is_file(): try: img = Image(diagram_path) avail_w = self.PAGE_WIDTH - 2 * self.MARGIN avail_h = 4.5 * inch # Scale to fit ratio = min(avail_w / img.drawWidth, avail_h / img.drawHeight) img.drawWidth *= ratio img.drawHeight *= ratio self.story.append(img) self.story.append(Spacer(1, 8)) except Exception: self._add_body(f"[Diagram: {diagram_path}]") if description: self._add_body(description) def add_decisions(self, decisions: list): """Architecture Decisions section.""" self._add_section_title("Architecture Decisions") headers = ["Decision", "Rationale"] rows = [ [d.get("decision", ""), d.get("rationale", "")] for d in decisions ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.35, avail * 0.65] self.story.append(self._make_table(headers, rows, widths)) self.story.append(Spacer(1, 12)) def add_ha_dr(self, tiers: list, description: str = ""): """HA/DR section.""" self._add_section_title("High Availability & Disaster Recovery") if description: self._add_body(description) self.story.append(Spacer(1, 6)) headers = ["Component", "Technology", "RTO", "RPO"] rows = [ [t.get("tier", ""), t.get("technology", ""), t.get("rto", ""), t.get("rpo", "")] for t in tiers ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.22, avail * 0.38, avail * 0.20, avail * 0.20] self.story.append(self._make_table(headers, rows, widths)) self.story.append(Spacer(1, 12)) def add_security(self, controls: dict, compliance: list = None): """Security & Compliance section.""" self._add_section_title("Security & Compliance") if compliance: badges = " | ".join([f"✓ {fw}" for fw in compliance]) self.story.append(Paragraph( f"{badges}", self.styles['Emphasis'], )) self.story.append(Spacer(1, 6)) for area, items in controls.items(): area_title = area.replace("_", " ").title() self._add_subsection(area_title) self._add_bullet_list(items) def add_environment_catalogue(self, environments: list, cost_notes=None): """Environment Catalogue section.""" self._add_section_title("Environment Catalogue") # Normalize cost_notes to list if isinstance(cost_notes, str): cost_notes = [s.strip() for s in cost_notes.split(".") if s.strip()] # Detect schema: new (name/sizing/cost_pct) vs legacy (environment/tier/databases/ocpus) sample = environments[0] if environments else {} if "sizing" in sample or "cost_pct" in sample: headers = ["Environment", "Sizing", "Isolation", "Cost %"] rows = [ [e.get("name", e.get("environment", "")), e.get("sizing", ""), e.get("isolation", ""), f"{e.get('cost_pct', '')}%" if e.get("cost_pct", "") != "" else ""] for e in environments ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.18, avail * 0.40, avail * 0.27, avail * 0.15] else: headers = ["Environment", "Tier", "Databases", "OCPUs", "Isolation"] rows = [ [e.get("environment", ""), e.get("tier", ""), e.get("databases", ""), e.get("ocpus", ""), e.get("isolation", "")] for e in environments ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.16, avail * 0.10, avail * 0.20, avail * 0.10, avail * 0.44] self.story.append(self._make_table(headers, rows, widths)) if cost_notes: self.story.append(Spacer(1, 8)) self._add_subsection("Cost Optimization") self._add_bullet_list(cost_notes) self.story.append(Spacer(1, 12)) def add_cost_estimate(self, line_items: list, assumptions: list = None, show_byol: bool = True): """Cost Estimate section.""" self._add_section_title("Cost Estimate") if show_byol: headers = ["Component", "Monthly (PAYG)", "Monthly (BYOL)", "Notes"] rows = [ [item.get("component", ""), item.get("monthly_payg", ""), item.get("monthly_byol", ""), item.get("notes", "")] for item in line_items ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.30, avail * 0.18, avail * 0.18, avail * 0.34] else: headers = ["Component", "Monthly", "Notes"] rows = [ [item.get("component", ""), item.get("monthly_payg", ""), item.get("notes", "")] for item in line_items ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.35, avail * 0.20, avail * 0.45] table = self._make_table(headers, rows, widths) # Highlight total rows extra_cmds = [] for i, item in enumerate(line_items): if "total" in item.get("component", "").lower(): row_idx = i + 1 extra_cmds.extend([ ('BACKGROUND', (0, row_idx), (-1, row_idx), Colors.TEAL), ('TEXTCOLOR', (0, row_idx), (-1, row_idx), white), ('FONTNAME', (0, row_idx), (-1, row_idx), 'Helvetica-Bold'), ]) if extra_cmds: table.setStyle(TableStyle(extra_cmds)) self.story.append(table) if assumptions: self.story.append(Spacer(1, 10)) self.story.append(Paragraph( "Assumptions:", self.styles['Disclaimer'], )) for a in sanitize_list(assumptions): self.story.append(Paragraph( f"• {a}", self.styles['Disclaimer'], )) self.story.append(Spacer(1, 12)) def add_cost_comparison(self, rows: list, title: str = "Cost Comparison", col_headers: list = None): """Cost Comparison section.""" self._add_section_title(title) headers = col_headers or ["Component", "Current", "OCI", "Savings"] data_rows = [ [r.get("item", ""), r.get("current", ""), r.get("oci", ""), r.get("savings", "")] for r in rows ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.35, avail * 0.20, avail * 0.20, avail * 0.25] table = self._make_table(headers, data_rows, widths) # Highlight total rows extra_cmds = [] for i, r in enumerate(rows): if "total" in r.get("item", "").lower(): row_idx = i + 1 extra_cmds.extend([ ('BACKGROUND', (0, row_idx), (-1, row_idx), Colors.TEAL), ('TEXTCOLOR', (0, row_idx), (-1, row_idx), white), ('FONTNAME', (0, row_idx), (-1, row_idx), 'Helvetica-Bold'), ]) if extra_cmds: table.setStyle(TableStyle(extra_cmds)) self.story.append(table) self.story.append(Spacer(1, 12)) def add_migration(self, phases: list, tools: list = None, downtime: str = ""): """Migration Approach section.""" self._add_section_title("Migration Approach") headers = ["Phase", "Timeline", "Key Activities"] rows = [ [p.get("name", ""), p.get("weeks", ""), " | ".join(p.get("tasks", [])[:4])] for p in phases ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.25, avail * 0.15, avail * 0.60] self.story.append(self._make_table(headers, rows, widths)) if tools: self.story.append(Spacer(1, 8)) self.story.append(Paragraph( f"Migration Tools: {', '.join(tools)}", self.styles['BodyText_Redwood'], )) if downtime: self.story.append(Paragraph( f"Downtime Approach: {sanitize_text(downtime)}", self.styles['BodyText_Redwood'], )) self.story.append(Spacer(1, 12)) def add_operational_raci(self, raci_items: list, model: str = "co_managed"): """Operational RACI section.""" model_label = model.replace("_", "-").title() self._add_section_title(f"Operational Responsibilities ({model_label})") headers = ["Activity", "Customer", "Oracle / Partner"] rows = [ [item.get("activity", ""), item.get("customer", ""), item.get("oracle", "")] for item in raci_items ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.50, avail * 0.25, avail * 0.25] self.story.append(self._make_table(headers, rows, widths)) self.story.append(Spacer(1, 6)) self.story.append(Paragraph( "R = Responsible | A = Accountable | C = Consulted | I = Informed", self.styles['Disclaimer'], )) self.story.append(Spacer(1, 12)) def add_risks(self, risks: list): """Risk Register section.""" self._add_section_title("Risk Register") headers = ["Risk", "Severity", "Mitigation"] rows = [ [sanitize_text(r.get("risk", "")), r.get("severity", "MEDIUM"), sanitize_text(r.get("mitigation", ""))] for r in risks ] avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.30, avail * 0.12, avail * 0.58] table = self._make_table(headers, rows, widths) # Color-code severity severity_colors = { "HIGH": Colors.ERROR, "MEDIUM": Colors.WARNING, "LOW": Colors.SUCCESS, } extra_cmds = [] for i, risk in enumerate(risks): sev = risk.get("severity", "MEDIUM").upper() color = severity_colors.get(sev, Colors.WARNING) row_idx = i + 1 extra_cmds.extend([ ('TEXTCOLOR', (1, row_idx), (1, row_idx), color), ('FONTNAME', (1, row_idx), (1, row_idx), 'Helvetica-Bold'), ('ALIGN', (1, row_idx), (1, row_idx), 'CENTER'), ]) if extra_cmds: table.setStyle(TableStyle(extra_cmds)) self.story.append(table) self.story.append(Spacer(1, 12)) def add_scorecard(self, pillars: list, recommendations: list = None): """Well-Architected Scorecard section.""" self._add_section_title("Well-Architected Scorecard") status_labels = { "PASS": "✓ Pass", "PASS_WITH_RECOMMENDATIONS": "⚠ Pass with Recommendations", "GAPS_IDENTIFIED": "✗ Gaps Identified", "NOT_APPLICABLE": "— N/A", } headers = ["Pillar", "Score", "Status"] rows = [] for p in pillars: score = (f"{p.get('passed', 0)}/{p.get('total', 0)}" if p.get('total', 0) > 0 else "—") status = status_labels.get(p.get("status", ""), p.get("status", "")) rows.append([p.get("name", ""), score, status]) avail = self.PAGE_WIDTH - 2 * self.MARGIN widths = [avail * 0.40, avail * 0.15, avail * 0.45] table = self._make_table(headers, rows, widths) # Color-code status status_colors = { "PASS": Colors.SUCCESS, "PASS_WITH_RECOMMENDATIONS": Colors.WARNING, "GAPS_IDENTIFIED": Colors.ERROR, "NOT_APPLICABLE": Colors.SECONDARY_TEXT, } extra_cmds = [] for i, p in enumerate(pillars): color = status_colors.get(p.get("status", ""), Colors.SECONDARY_TEXT) row_idx = i + 1 extra_cmds.extend([ ('TEXTCOLOR', (2, row_idx), (2, row_idx), color), ('FONTNAME', (2, row_idx), (2, row_idx), 'Helvetica-Bold'), ]) if extra_cmds: table.setStyle(TableStyle(extra_cmds)) self.story.append(table) if recommendations: self.story.append(Spacer(1, 10)) self._add_subsection("Top Recommendations") for rec in sanitize_list(recommendations[:6]): self.story.append(Paragraph( f"→ {rec}", self.styles['BulletItem'], )) self.story.append(Spacer(1, 6)) self.story.append(Paragraph( "Validated against Oracle Well-Architected Framework — " "docs.oracle.com/en/solutions/oci-best-practices/", self.styles['Disclaimer'], )) self.story.append(Spacer(1, 12)) def add_next_steps(self, steps: list, contact_info: str = ""): """Next Steps section.""" self._add_section_title("Next Steps") for i, step in enumerate(sanitize_list(steps)): self.story.append(Paragraph( f"{i + 1}. {step}", self.styles['BodyText_Redwood'], )) self.story.append(Spacer(1, 4)) if contact_info: self.story.append(Spacer(1, 18)) self.story.append(Paragraph( sanitize_text(contact_info), self.styles['Emphasis'], )) def add_thank_you_page(self): """Add a dark-background closing / Thank You page.""" self.story.append(PageBreak()) # The _thank_you_page canvas callback draws the dark background from reportlab.platypus.doctemplate import NextPageTemplate self.story.append(NextPageTemplate('thank_you')) self.story.append(Spacer(1, 2.2 * inch)) self.story.append(Paragraph( "Thank you", ParagraphStyle( 'ThankYouTitle', fontName='Helvetica-Bold', fontSize=40, leading=50, textColor=white, alignment=TA_LEFT, ), )) self.story.append(Spacer(1, 0.3 * inch)) if self.architect or self.firm: parts = [p for p in [self.architect, self.firm] if p] self.story.append(Paragraph( " | ".join(parts), ParagraphStyle( 'ThankYouContact', fontName='Helvetica', fontSize=13, leading=18, textColor=HexColor("#9E9892"), alignment=TA_LEFT, ), )) def add_safe_harbor(self): """Add Oracle Safe Harbor statement.""" from reportlab.platypus.doctemplate import NextPageTemplate self.story.append(NextPageTemplate('content_pages')) self.story.append(PageBreak()) self.story.append(Spacer(1, 2 * inch)) self.story.append(Paragraph( "Safe Harbor Statement", self.styles['SectionTitle'], )) self.story.append(HRFlowable( width="25%", thickness=2, color=Colors.ORACLE_RED, spaceAfter=14, spaceBefore=0, hAlign='LEFT', )) self.story.append(Paragraph( "The preceding is intended to outline our general product direction. " "It is intended for information purposes only, and may not be " "incorporated into any contract. It is not a commitment to deliver " "any material, code, or functionality, and should not be relied upon " "in making purchasing decisions. The development, release, timing, " "and pricing of any features or functionality described for Oracle's " "products may change and remains at the sole discretion of Oracle " "Corporation.", self.styles['BodyText_Redwood'], )) # ---- Build from spec ---- @classmethod def from_spec(cls, spec: dict, diagram_path: str = None) -> "OCIPDFGenerator": """Build a complete PDF from a YAML specification.""" meta = spec.get("metadata", {}) gen = cls( customer=meta.get("customer", ""), project=meta.get("project", ""), architect=meta.get("architect", ""), firm=meta.get("firm", ""), ) # Cover gen.add_cover(subtitle=meta.get("subtitle", "")) # Executive Summary if "summary" in spec: s = spec["summary"] gen.add_executive_summary( why=s.get("why", ""), current_state=s.get("current_state", []), target_state=s.get("target_state", ""), timeline=s.get("timeline", ""), ) # Service Tiering if "service_tiering" in spec: gen.add_service_tiering(spec["service_tiering"]) # Architecture Principles if "architecture_principles" in spec: gen.add_architecture_principles(spec["architecture_principles"]) # Architecture Overview if "architecture" in spec: a = spec["architecture"] gen.add_architecture_overview( description=a.get("description", ""), diagram_path=diagram_path or a.get("diagram_path"), ) # Decisions if "decisions" in spec: gen.add_decisions(spec["decisions"]) # HA/DR if "ha_dr" in spec: h = spec["ha_dr"] gen.add_ha_dr( tiers=h.get("tiers", []), description=h.get("description", ""), ) # Security if "security" in spec: s = spec["security"] gen.add_security( controls=s.get("controls", {}), compliance=s.get("compliance", []), ) # Environment Catalogue if "environment_catalogue" in spec: ec = spec["environment_catalogue"] gen.add_environment_catalogue( environments=ec.get("environments", []), cost_notes=ec.get("cost_notes"), ) # Cost Estimate if "cost" in spec: c = spec["cost"] gen.add_cost_estimate( line_items=c.get("line_items", []), assumptions=c.get("assumptions", []), show_byol=c.get("show_byol", True), ) # Cost Comparison if "cost_comparison" in spec: cc = spec["cost_comparison"] gen.add_cost_comparison( rows=cc.get("rows", []), title=cc.get("title", "Cost Comparison"), col_headers=cc.get("col_headers"), ) # Migration if "migration" in spec: m = spec["migration"] gen.add_migration( phases=m.get("phases", []), tools=m.get("tools", []), downtime=m.get("downtime", ""), ) # Operational RACI if "operational_raci" in spec: r = spec["operational_raci"] gen.add_operational_raci( raci_items=r.get("raci_items", []), model=r.get("model", "co_managed"), ) # Risks if "risks" in spec: gen.add_risks(spec["risks"]) # Scorecard if "scorecard" in spec: sc = spec["scorecard"] gen.add_scorecard( pillars=sc.get("pillars", []), recommendations=sc.get("recommendations", []), ) # Next Steps if "next_steps" in spec: ns = spec["next_steps"] gen.add_next_steps( steps=ns.get("steps", []), contact_info=ns.get("contact_info", ""), ) # Thank You + Safe Harbor gen.add_thank_you_page() gen.add_safe_harbor() return gen # ---- Save ---- def save(self, filepath: str): """Save the document to a PDF file.""" doc = BaseDocTemplate( filepath, pagesize=letter, leftMargin=self.MARGIN, rightMargin=self.MARGIN, topMargin=0.7 * inch, bottomMargin=0.7 * inch, title=f"{self.customer} — {self.project}", author=self.architect, subject="OCI Architecture Proposal", ) # Frame for content content_frame = Frame( self.MARGIN, 0.7 * inch, self.PAGE_WIDTH - 2 * self.MARGIN, self.PAGE_HEIGHT - 1.4 * inch, id='content', ) # Cover page template (dark bg) cover_template = PageTemplate( id='cover', frames=[content_frame], onPage=self._cover_page, ) # Content page template (header + footer) content_template = PageTemplate( id='content_pages', frames=[content_frame], onPage=self._header_footer, ) # Thank You page template (dark bg, no header/footer) thank_you_template = PageTemplate( id='thank_you', frames=[content_frame], onPage=self._thank_you_page, ) doc.addPageTemplates([cover_template, content_template, thank_you_template]) # After cover page, switch to content template # Insert a NextPageTemplate after the PageBreak in story from reportlab.platypus.doctemplate import NextPageTemplate # Find first PageBreak and insert template switch before it for i, elem in enumerate(self.story): if isinstance(elem, PageBreak): self.story.insert(i, NextPageTemplate('content_pages')) break doc.build(self.story) # ============================================================ # CLI # ============================================================ def main(): parser = argparse.ArgumentParser( description="Generate customer-facing OCI architecture proposal PDF" ) parser.add_argument( "--spec", required=True, help="Path to YAML spec file with proposal data", ) parser.add_argument( "--output", default="architecture-proposal.pdf", help="Output .pdf file path", ) parser.add_argument( "--diagram", help="Path to architecture diagram image (.png/.jpg) to embed", ) args = parser.parse_args() with open(args.spec, 'r') as f: spec = yaml.safe_load(f) gen = OCIPDFGenerator.from_spec(spec, diagram_path=args.diagram) gen.save(args.output) customer = spec.get("metadata", {}).get("customer", "") print(f"Generated: {args.output}") print(f" Style: Oracle Redwood (customer-facing)") print(f" Internal references: stripped") if customer: print(f" Customer: {customer}") if __name__ == "__main__": main()