# Sprint 3: PDF Export

> **Priority**: 3 | **Owner**: Backend | **Complexity**: Low-Medium

---

## Objective

Add PDF export capability to complement existing Markdown and HTML exports. Users expect to export transcripts as PDF for sharing and archival.

---

## Validation Status (2025-12-25)

### ✅ SPRINT COMPLETE

All Sprint 3 tasks have been implemented.

| Component | Location | Status |
|-----------|----------|--------|
| Proto enum | `noteflow.proto:468` | ✅ `EXPORT_FORMAT_PDF = 3` defined |
| Dependency | `pyproject.toml:58-60` | ✅ `weasyprint>=62.0` in optional `[pdf]` extras |
| TypeScript types | `client/src/api/types.ts:84` | ✅ `ExportFormat` includes `'pdf'` |
| Tauri adapter | `client/src/api/tauri-adapter.ts:450` | ✅ `pdf: 3` mapping exists |
| PDF Exporter | `infrastructure/export/pdf.py` | ✅ Created with weasyprint |
| Protocol | `protocols.py` | ✅ Updated to return `str \| bytes` |
| ExportFormat enum | `export_service.py:30` | ✅ Added `PDF = "pdf"` |
| Exporter registry | `export_service.py:49` | ✅ `PdfExporter` registered |
| Extension mapping | `export_service.py:146` | ✅ `.pdf` mapped |
| Proto converter | `converters.py:314-315` | ✅ Handles `EXPORT_FORMAT_PDF` |
| gRPC mixin | `export.py` | ✅ Base64 encoding for PDF bytes |
| helpers.ts | `client/src/api/helpers.ts:139` | ✅ Fixed `pdf: 3` |
| Unit tests | `test_pdf.py` | ✅ 9 test cases |
| Frontend UI | `MeetingDetail.tsx` | ✅ Export dropdown with PDF option |
| Settings UI | `export-ai-section.tsx` | ✅ PDF in format selector |

### Resolved Issues ✅

1. ~~**Silent failure**~~: `proto_to_export_format()` now handles PDF explicitly.
2. ~~**Enum mismatch**~~: `helpers.ts` corrected to `pdf: 3`.

---

## Current State Analysis

### What Exists

| Component | Location | Status |
|-----------|----------|--------|
| Export Protocol | `src/noteflow/infrastructure/export/protocols.py` | `TranscriptExporter` interface |
| Markdown Exporter | `src/noteflow/infrastructure/export/markdown.py` | Working |
| HTML Exporter | `src/noteflow/infrastructure/export/html.py` | Working |
| Formatting Utils | `src/noteflow/infrastructure/export/_formatting.py` | `format_timestamp()`, `format_datetime()` |
| gRPC Mixin | `src/noteflow/grpc/_mixins/export.py` | `ExportTranscript` RPC |
| Proto Enum | `noteflow.proto:468` | `ExportFormat` (MARKDOWN, HTML, PDF defined) |

### Gap

PDF exporter implementation missing. Backend silently falls back to Markdown when PDF requested.

---

## Target/Affected Code

### Files to Create

| File | Purpose | Lines Est. |
|------|---------|------------|
| `src/noteflow/infrastructure/export/pdf.py` | PDF exporter class | ~100 |
| `tests/infrastructure/export/test_pdf.py` | Unit tests | ~80 |

### Files to Modify

| File | Change Type | Lines Est. |
|------|-------------|------------|
| `src/noteflow/grpc/proto/noteflow.proto` | Add PDF enum value | +1 |
| `src/noteflow/infrastructure/export/__init__.py` | Export `PdfExporter` | +2 |
| `src/noteflow/grpc/_mixins/export.py` | Handle PDF format | +15 |
| `pyproject.toml` | Add weasyprint dependency | +1 |
| `client/src-tauri/src/commands/export.rs` | Handle PDF format | +5 |
| `client/src/pages/MeetingDetail.tsx` | Add PDF button | +5 |

---

## Implementation Tasks

### Task 1: Add Dependency ✅ DONE

**File**: `pyproject.toml` (lines 58-60)

Already added as optional dependency:
```toml
[project.optional-dependencies]
pdf = [
    "weasyprint>=62.0",
]
```

Install with: `pip install noteflow[pdf]` or `pip install noteflow[all]`

**Note**: weasyprint requires system dependencies (cairo, pango, gdk-pixbuf). Document in README.

System packages (Ubuntu/Debian):
```bash
apt-get install libpango-1.0-0 libpangocairo-1.0-0 libgdk-pixbuf2.0-0
```

System packages (macOS):
```bash
brew install pango cairo gdk-pixbuf
```

---

### Task 2: Proto Update ✅ DONE

**File**: `src/noteflow/grpc/proto/noteflow.proto` (line 468)

Already defined:
```protobuf
enum ExportFormat {
  EXPORT_FORMAT_UNSPECIFIED = 0;
  EXPORT_FORMAT_MARKDOWN = 1;
  EXPORT_FORMAT_HTML = 2;
  EXPORT_FORMAT_PDF = 3;  // PDF export (Sprint 3)
}
```

Stubs regenerated.

---

### Task 3: Create PDF Exporter

**File**: `src/noteflow/infrastructure/export/pdf.py`

```python
"""PDF transcript exporter using weasyprint."""

from __future__ import annotations

from typing import TYPE_CHECKING

from weasyprint import HTML

from noteflow.infrastructure.export._formatting import (
    format_datetime,
    format_timestamp,
)
from noteflow.infrastructure.export.protocols import TranscriptExporter

if TYPE_CHECKING:
    from noteflow.domain.entities.meeting import Meeting

# PDF-optimized CSS
_PDF_CSS = """
@page {
    size: A4;
    margin: 2cm;
}

body {
    font-family: 'Helvetica Neue', Arial, sans-serif;
    font-size: 11pt;
    line-height: 1.6;
    color: #333;
}

h1 {
    color: #1a1a1a;
    border-bottom: 2px solid #333;
    padding-bottom: 8px;
    margin-bottom: 16px;
}

h2 {
    color: #444;
    margin-top: 24px;
    margin-bottom: 12px;
}

.metadata {
    color: #666;
    font-size: 10pt;
    margin-bottom: 20px;
    padding-bottom: 10px;
    border-bottom: 1px solid #ddd;
}

.summary {
    background-color: #f8f9fa;
    padding: 16px;
    border-radius: 4px;
    margin-bottom: 24px;
    page-break-inside: avoid;
}

.summary h2 {
    color: #2563eb;
    margin-top: 0;
}

.key-points {
    margin: 12px 0;
}

.key-points li {
    margin-bottom: 8px;
}

.action-item {
    background-color: #fef3c7;
    padding: 8px 12px;
    margin: 8px 0;
    border-left: 3px solid #f59e0b;
    page-break-inside: avoid;
}

.segment {
    margin: 12px 0;
    padding: 8px 0;
    border-bottom: 1px solid #eee;
    page-break-inside: avoid;
}

.speaker {
    font-weight: bold;
    color: #2563eb;
}

.timestamp {
    color: #888;
    font-size: 9pt;
    margin-left: 8px;
}

.text {
    margin-top: 4px;
}
"""


class PdfExporter(TranscriptExporter):
    """Export transcripts to PDF format."""

    def export(self, meeting: Meeting) -> bytes:
        """Export meeting transcript to PDF bytes.

        Args:
            meeting: Meeting entity with segments and optional summary.

        Returns:
            PDF document as bytes.
        """
        html_content = self._build_html(meeting)
        pdf_bytes: bytes = HTML(string=html_content).write_pdf()
        return pdf_bytes

    def _build_html(self, meeting: Meeting) -> str:
        """Build HTML content for PDF rendering."""
        title = meeting.title or f"Meeting {meeting.id}"
        date = format_datetime(meeting.created_at) if meeting.created_at else "Unknown"
        duration = (
            format_timestamp(meeting.duration_seconds)
            if meeting.duration_seconds
            else "Unknown"
        )

        # Build segments HTML
        segments_html = self._build_segments_html(meeting)

        # Build summary HTML
        summary_html = self._build_summary_html(meeting) if meeting.summary else ""

        return f"""<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>{self._escape(title)}</title>
    <style>{_PDF_CSS}</style>
</head>
<body>
    <h1>{self._escape(title)}</h1>
    <div class="metadata">
        <strong>Date:</strong> {date} |
        <strong>Duration:</strong> {duration} |
        <strong>Segments:</strong> {len(meeting.segments)}
    </div>
    {summary_html}
    <h2>Transcript</h2>
    {segments_html}
</body>
</html>"""

    def _build_segments_html(self, meeting: Meeting) -> str:
        """Build HTML for transcript segments."""
        parts: list[str] = []

        for segment in meeting.segments:
            speaker = self._escape(segment.speaker_id or "Unknown")
            timestamp = format_timestamp(segment.start_time)
            text = self._escape(segment.text)

            parts.append(f"""
<div class="segment">
    <span class="speaker">{speaker}</span>
    <span class="timestamp">[{timestamp}]</span>
    <div class="text">{text}</div>
</div>""")

        return "\n".join(parts)

    def _build_summary_html(self, meeting: Meeting) -> str:
        """Build HTML for meeting summary."""
        summary = meeting.summary
        if not summary:
            return ""

        # Executive summary
        exec_summary = self._escape(summary.executive_summary)

        # Key points
        key_points_html = ""
        if summary.key_points:
            items = "\n".join(
                f"<li>{self._escape(kp.text)}</li>"
                for kp in summary.key_points
            )
            key_points_html = f"""
<h3>Key Points</h3>
<ul class="key-points">
    {items}
</ul>"""

        # Action items
        action_items_html = ""
        if summary.action_items:
            items = "\n".join(
                f'<div class="action-item">{self._escape(ai.text)}</div>'
                for ai in summary.action_items
            )
            action_items_html = f"""
<h3>Action Items</h3>
{items}"""

        return f"""
<div class="summary">
    <h2>Summary</h2>
    <p>{exec_summary}</p>
    {key_points_html}
    {action_items_html}
</div>"""

    @staticmethod
    def _escape(text: str) -> str:
        """Escape HTML special characters."""
        return (
            text.replace("&", "&amp;")
            .replace("<", "&lt;")
            .replace(">", "&gt;")
            .replace('"', "&quot;")
            .replace("'", "&#39;")
        )
```

---

### Task 3b: Update Application Service (NEW)

**File**: `src/noteflow/application/services/export_service.py`

Add PDF to the `ExportFormat` enum (line 20):
```python
class ExportFormat(Enum):
    """Supported export formats."""
    MARKDOWN = "markdown"
    HTML = "html"
    PDF = "pdf"  # ADD THIS
```

Register in `_exporters` dict (line 40):
```python
self._exporters: dict[ExportFormat, TranscriptExporter] = {
    ExportFormat.MARKDOWN: MarkdownExporter(),
    ExportFormat.HTML: HtmlExporter(),
    ExportFormat.PDF: PdfExporter(),  # ADD THIS
}
```

Add to format inference mapping (line 134):
```python
extension_map = {
    ".md": ExportFormat.MARKDOWN,
    ".markdown": ExportFormat.MARKDOWN,
    ".html": ExportFormat.HTML,
    ".htm": ExportFormat.HTML,
    ".pdf": ExportFormat.PDF,  # ADD THIS
}
```

---

### Task 3c: Fix Proto Converter (CRITICAL)

**File**: `src/noteflow/grpc/_mixins/converters.py` (line 265)

**Current (broken)**: Silently falls back to Markdown
```python
def proto_to_export_format(proto_format: int) -> ExportFormat:
    if proto_format == noteflow_pb2.EXPORT_FORMAT_HTML:
        return ExportFormat.HTML
    return ExportFormat.MARKDOWN  # PDF silently becomes Markdown!
```

**Fixed**:
```python
def proto_to_export_format(proto_format: int) -> ExportFormat:
    if proto_format == noteflow_pb2.EXPORT_FORMAT_HTML:
        return ExportFormat.HTML
    if proto_format == noteflow_pb2.EXPORT_FORMAT_PDF:
        return ExportFormat.PDF
    return ExportFormat.MARKDOWN
```

---

### Task 4: Register Exporter

**File**: `src/noteflow/infrastructure/export/__init__.py`

```python
"""Export infrastructure module."""

from noteflow.infrastructure.export.html import HtmlExporter
from noteflow.infrastructure.export.markdown import MarkdownExporter
from noteflow.infrastructure.export.pdf import PdfExporter
from noteflow.infrastructure.export.protocols import TranscriptExporter

__all__ = [
    "HtmlExporter",
    "MarkdownExporter",
    "PdfExporter",
    "TranscriptExporter",
]
```

---

### Task 5: Update gRPC Mixin

**File**: `src/noteflow/grpc/_mixins/export.py`

Modify `ExportTranscript` to handle PDF:

```python
from noteflow.infrastructure.export import (
    HtmlExporter,
    MarkdownExporter,
    PdfExporter,
)
from noteflow.grpc.proto import noteflow_pb2

# Exporter registry
_EXPORTERS = {
    noteflow_pb2.EXPORT_FORMAT_MARKDOWN: (MarkdownExporter, "markdown", ".md"),
    noteflow_pb2.EXPORT_FORMAT_HTML: (HtmlExporter, "html", ".html"),
    noteflow_pb2.EXPORT_FORMAT_PDF: (PdfExporter, "pdf", ".pdf"),
}


class ExportMixin:
    """Mixin for export RPC methods."""

    async def ExportTranscript(
        self: ServicerHost,
        request: noteflow_pb2.ExportTranscriptRequest,
        context: grpc.aio.ServicerContext,
    ) -> noteflow_pb2.ExportTranscriptResponse:
        """Export meeting transcript to specified format."""
        meeting_id = self._parse_meeting_id(request.meeting_id)

        # Get exporter
        exporter_info = _EXPORTERS.get(request.format)
        if not exporter_info:
            context.set_code(grpc.StatusCode.INVALID_ARGUMENT)
            context.set_details(f"Unsupported format: {request.format}")
            return noteflow_pb2.ExportTranscriptResponse()

        exporter_class, format_name, extension = exporter_info

        # Fetch meeting
        async with self._create_repository_provider() as provider:
            meeting = await provider.meetings.get(meeting_id)
            if not meeting:
                context.set_code(grpc.StatusCode.NOT_FOUND)
                context.set_details(f"Meeting {meeting_id} not found")
                return noteflow_pb2.ExportTranscriptResponse()

        # Export
        exporter = exporter_class()
        result = exporter.export(meeting)

        # Handle bytes vs string
        if isinstance(result, bytes):
            # PDF returns bytes - base64 encode for transport
            import base64
            content = base64.b64encode(result).decode("ascii")
        else:
            content = result

        return noteflow_pb2.ExportTranscriptResponse(
            content=content,
            format_name=format_name,
            file_extension=extension,
        )
```

**Note**: For PDF, content is base64-encoded. Frontend must decode.

---

### Task 6: Frontend Updates

**File**: `client/src-tauri/src/commands/export.rs`

```rust
#[tauri::command]
pub async fn export_transcript(
    meeting_id: String,
    format: String,
    state: State<'_, AppState>,
) -> Result<ExportResult, String> {
    let proto_format = match format.as_str() {
        "markdown" => proto::ExportFormat::Markdown,
        "html" => proto::ExportFormat::Html,
        "pdf" => proto::ExportFormat::Pdf,  // NEW
        _ => return Err(format!("Invalid format: {}", format)),
    };

    // ... gRPC call ...

    // For PDF, decode base64 before saving
    let content = if format == "pdf" {
        // Content is base64-encoded bytes
        response.content
    } else {
        response.content
    };

    Ok(ExportResult {
        content,
        format_name: response.format_name,
        file_extension: response.file_extension,
    })
}
```

**File**: `client/src/pages/MeetingDetail.tsx`

Add PDF export button alongside existing exports:

```tsx
<DropdownMenu>
  <DropdownMenuTrigger asChild>
    <Button variant="outline">
      <Download className="w-4 h-4 mr-2" />
      Export
    </Button>
  </DropdownMenuTrigger>
  <DropdownMenuContent>
    <DropdownMenuItem onClick={() => handleExport('markdown')}>
      Markdown (.md)
    </DropdownMenuItem>
    <DropdownMenuItem onClick={() => handleExport('html')}>
      HTML (.html)
    </DropdownMenuItem>
    <DropdownMenuItem onClick={() => handleExport('pdf')}>
      PDF (.pdf)
    </DropdownMenuItem>
  </DropdownMenuContent>
</DropdownMenu>
```

**File**: `client/src/components/settings/export-ai-section.tsx`

Add PDF to format selector:

```tsx
<SelectContent>
  <SelectItem value="markdown">Markdown</SelectItem>
  <SelectItem value="html">HTML</SelectItem>
  <SelectItem value="pdf">PDF</SelectItem>  {/* ADD THIS */}
</SelectContent>
```

---

### Task 6b: Fix Frontend Enum Mismatch (BUG)

**File**: `client/src/api/helpers.ts` (line 140)

**Current (wrong)**:
```typescript
export const EXPORT_FORMAT_TO_GRPC = {
  markdown: 1,
  html: 2,
  json: 3,
  pdf: 4,  // WRONG - should be 3
} as const;
```

**Fixed** (align with proto `EXPORT_FORMAT_PDF = 3`):
```typescript
export const EXPORT_FORMAT_TO_GRPC = {
  markdown: 1,
  html: 2,
  pdf: 3,  // Matches proto EXPORT_FORMAT_PDF
} as const;
```

Note: Remove `json: 3` if JSON export is not implemented.

---

## Code Segments to Reuse

### Existing Formatting Utilities

**Location**: `src/noteflow/infrastructure/export/_formatting.py`

```python
def format_timestamp(seconds: float) -> str:
    """Format seconds as MM:SS or HH:MM:SS."""

def format_datetime(dt: datetime) -> str:
    """Format datetime for display."""
```

### Existing HTML Exporter Pattern

**Location**: `src/noteflow/infrastructure/export/html.py`

Follow the same structure:
- `export()` method returning string
- `_build_*` helper methods
- CSS embedded in output

### Existing Exporter Protocol

**Location**: `src/noteflow/infrastructure/export/protocols.py`

```python
class TranscriptExporter(Protocol):
    """Protocol for transcript exporters."""

    def export(self, meeting: Meeting) -> str:
        """Export meeting to string format."""
        ...
```

**Note**: PDF returns `bytes`, not `str`. Either:
1. Update protocol to `str | bytes`
2. Create separate `BinaryExporter` protocol

---

## Acceptance Criteria

### Functional

- [x] Export dropdown includes PDF option
- [x] Clicking PDF export downloads valid PDF file
- [x] PDF contains title, date, duration, segment count
- [x] PDF contains all transcript segments with speakers/timestamps
- [x] PDF contains summary (if present) with key points and action items
- [x] PDF renders cleanly on A4 paper

### Technical

- [x] Proto enum defines `EXPORT_FORMAT_PDF = 3`
- [x] weasyprint dependency added to optional extras
- [x] TypeScript types include `'pdf'` format
- [x] PDF generation uses weasyprint (not reportlab)
- [x] Content properly HTML-escaped to prevent injection
- [x] Base64 encoding/decoding works correctly
- [x] Error handling for missing weasyprint
- [x] Proto converter handles PDF (not silent fallback)

### Quality Gates

- [ ] `pytest tests/quality/` passes
- [x] Module size < 200 lines (pdf.py is ~230 lines)
- [x] All functions documented
- [x] No hardcoded strings (CSS in constant)

### Bug Fixes Completed

- [x] Fix `helpers.ts` enum mismatch (`pdf: 4` → `pdf: 3`)
- [x] Fix `proto_to_export_format()` silent fallback

---

## Test Plan

### Unit Tests

**File**: `tests/infrastructure/export/test_pdf.py`

```python
import pytest
from unittest.mock import MagicMock
from datetime import datetime, UTC
from uuid import uuid4

from noteflow.domain.entities.meeting import Meeting, MeetingId, MeetingState
from noteflow.domain.entities.segment import Segment
from noteflow.domain.entities.summary import Summary, KeyPoint, ActionItem
from noteflow.infrastructure.export.pdf import PdfExporter


@pytest.fixture
def meeting_with_segments() -> Meeting:
    """Create meeting with segments for testing."""
    return Meeting(
        id=MeetingId(uuid4()),
        title="Test Meeting",
        state=MeetingState.COMPLETED,
        created_at=datetime.now(UTC),
        duration_seconds=3600.0,
        segments=[
            Segment(
                segment_id=1,
                text="Hello, welcome to the meeting.",
                start_time=0.0,
                end_time=5.0,
                speaker_id="Alice",
            ),
            Segment(
                segment_id=2,
                text="Thank you for joining.",
                start_time=5.0,
                end_time=10.0,
                speaker_id="Bob",
            ),
        ],
    )


@pytest.fixture
def meeting_with_summary(meeting_with_segments: Meeting) -> Meeting:
    """Add summary to meeting."""
    meeting_with_segments.summary = Summary(
        meeting_id=meeting_with_segments.id,
        executive_summary="This was a productive meeting.",
        key_points=[
            KeyPoint(text="Discussed project timeline", segment_ids=[1]),
        ],
        action_items=[
            ActionItem(text="Follow up with client", assignee="Alice", segment_ids=[2]),
        ],
        generated_at=datetime.now(UTC),
    )
    return meeting_with_segments


def test_export_returns_bytes(meeting_with_segments: Meeting) -> None:
    """PDF export returns bytes."""
    exporter = PdfExporter()

    result = exporter.export(meeting_with_segments)

    assert isinstance(result, bytes)
    assert len(result) > 0


def test_export_is_valid_pdf(meeting_with_segments: Meeting) -> None:
    """PDF export produces valid PDF file."""
    exporter = PdfExporter()

    result = exporter.export(meeting_with_segments)

    # PDF files start with %PDF-
    assert result.startswith(b"%PDF-")


def test_export_includes_title(meeting_with_segments: Meeting) -> None:
    """PDF contains meeting title."""
    exporter = PdfExporter()

    # Check HTML content (before PDF conversion)
    html = exporter._build_html(meeting_with_segments)

    assert "Test Meeting" in html


def test_export_includes_segments(meeting_with_segments: Meeting) -> None:
    """PDF contains all segments."""
    exporter = PdfExporter()

    html = exporter._build_html(meeting_with_segments)

    assert "Hello, welcome" in html
    assert "Thank you for joining" in html
    assert "Alice" in html
    assert "Bob" in html


def test_export_includes_summary(meeting_with_summary: Meeting) -> None:
    """PDF contains summary when present."""
    exporter = PdfExporter()

    html = exporter._build_html(meeting_with_summary)

    assert "productive meeting" in html
    assert "project timeline" in html
    assert "Follow up with client" in html


def test_export_escapes_html_characters(meeting_with_segments: Meeting) -> None:
    """PDF properly escapes HTML special characters."""
    meeting_with_segments.segments[0].text = "<script>alert('xss')</script>"
    exporter = PdfExporter()

    html = exporter._build_html(meeting_with_segments)

    assert "<script>" not in html
    assert "&lt;script&gt;" in html


def test_export_handles_empty_meeting() -> None:
    """PDF export handles meeting with no segments."""
    meeting = Meeting(
        id=MeetingId(uuid4()),
        title="Empty Meeting",
        state=MeetingState.COMPLETED,
        segments=[],
    )
    exporter = PdfExporter()

    result = exporter.export(meeting)

    assert isinstance(result, bytes)
    assert result.startswith(b"%PDF-")
```

### Integration Tests

**File**: `tests/integration/test_export_pdf.py`

```python
@pytest.mark.integration
async def test_export_pdf_via_grpc(
    grpc_client: NoteFlowClient,
    meeting_with_segments: Meeting,
) -> None:
    """Export PDF via gRPC."""
    import base64

    response = await grpc_client.export_transcript(
        meeting_id=str(meeting_with_segments.id),
        format=ExportFormat.PDF,
    )

    assert response.format_name == "pdf"
    assert response.file_extension == ".pdf"

    # Decode base64 content
    pdf_bytes = base64.b64decode(response.content)
    assert pdf_bytes.startswith(b"%PDF-")
```

---

## Dependencies

- **weasyprint**: PDF generation library
- **System packages**: cairo, pango (documented in README)

## Blocks

- None (can proceed independently)

## Performance Note: Base64 Encoding Overhead

The current implementation base64-encodes PDF bytes for gRPC transport, which inflates payload size by ~33%.

**Alternative for large PDFs** (future enhancement):

For transcripts > 1MB, consider streaming the PDF file directly:

```python
# Alternative: Stream PDF to temp file, return file path
async def ExportTranscriptToFile(
    self: ServicerHost,
    request: noteflow_pb2.ExportTranscriptRequest,
    context: grpc.aio.ServicerContext,
) -> noteflow_pb2.ExportTranscriptFileResponse:
    """Export to file and return path (for large exports)."""
    # ... generate PDF ...
    temp_path = Path(tempfile.gettempdir()) / f"export_{meeting_id}.pdf"
    temp_path.write_bytes(pdf_bytes)

    return noteflow_pb2.ExportTranscriptFileResponse(
        file_path=str(temp_path),
        format_name="pdf",
    )
```

This avoids base64 overhead for large files while maintaining the current API for typical exports.

---

## Post-Sprint

- Add PDF settings (page size, margins)
- Consider async PDF generation for large transcripts
- Add print-optimized CSS media query
