Files
cv-site/internal/pdf/generator.go
T
juanatsap 32814c4796 fix: Use proper chromedp options for no-sandbox mode
- Replace chromedp.Flag() with chromedp.NoSandbox
- Replace chromedp.Flag() with chromedp.DisableGPU
- Apply fix to both GenerateFromURL and GenerateFromURLWithOptions
- Fixes Ubuntu 23.10+ AppArmor sandbox restrictions

The chromedp.Flag() method wasn't properly disabling the sandbox, causing
Chrome to crash with 'No usable sandbox!' fatal error in CI.
2025-11-20 13:56:29 +00:00

290 lines
9.3 KiB
Go

package pdf
import (
"context"
"fmt"
"io"
"time"
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
)
// Generator handles PDF generation using headless Chrome
type Generator struct {
timeout time.Duration
}
// NewGenerator creates a new PDF generator with the specified timeout
func NewGenerator(timeout time.Duration) *Generator {
if timeout == 0 {
timeout = 30 * time.Second
}
return &Generator{
timeout: timeout,
}
}
// GenerateFromURL generates a PDF from a given URL
func (g *Generator) GenerateFromURL(ctx context.Context, url string) ([]byte, error) {
// Create context with timeout
ctx, cancel := context.WithTimeout(ctx, g.timeout)
defer cancel()
// Create chromedp options for headless mode (especially for CI environments)
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.NoSandbox,
chromedp.DisableGPU,
)
// Create exec allocator with custom options
allocCtx, allocCancel := chromedp.NewExecAllocator(ctx, opts...)
defer allocCancel()
// Create chromedp context
browserCtx, browserCancel := chromedp.NewContext(allocCtx)
defer browserCancel()
// Use browserCtx instead of allocCtx for chromedp operations
allocCtx = browserCtx
// Buffer to store PDF
var pdfBuffer []byte
// Run chromedp tasks
err := chromedp.Run(allocCtx,
// Navigate to URL
chromedp.Navigate(url),
// Wait for page to be ready
chromedp.WaitReady("body"),
// Small delay to ensure all content is loaded
chromedp.Sleep(500*time.Millisecond),
// Generate PDF with print-optimized settings
chromedp.ActionFunc(func(ctx context.Context) error {
var err error
pdfBuffer, _, err = page.PrintToPDF().
WithPrintBackground(true).
WithPreferCSSPageSize(true).
WithMarginTop(0).
WithMarginBottom(0).
WithMarginLeft(0).
WithMarginRight(0).
WithPaperWidth(8.27). // A4 width in inches
WithPaperHeight(11.69). // A4 height in inches
Do(ctx)
return err
}),
)
if err != nil {
return nil, fmt.Errorf("chromedp execution failed: %w", err)
}
if len(pdfBuffer) == 0 {
return nil, fmt.Errorf("generated PDF is empty")
}
return pdfBuffer, nil
}
// RenderMode determines how the PDF is rendered
type RenderMode string
const (
// RenderModePrint uses @media print CSS (clean, print-friendly)
RenderModePrint RenderMode = "print"
// RenderModeScreen uses @media screen CSS (long, full page with sidebars)
RenderModeScreen RenderMode = "screen"
)
// GenerateFromURLWithCookies generates a PDF from a given URL with custom cookies
func (g *Generator) GenerateFromURLWithCookies(ctx context.Context, url string, cookies map[string]string) ([]byte, error) {
return g.GenerateFromURLWithOptions(ctx, url, cookies, RenderModePrint)
}
// GenerateFromURLWithOptions generates a PDF with custom cookies and render mode
func (g *Generator) GenerateFromURLWithOptions(ctx context.Context, url string, cookies map[string]string, mode RenderMode) ([]byte, error) {
// Create context with timeout
ctx, cancel := context.WithTimeout(ctx, g.timeout)
defer cancel()
// Create chromedp options for headless mode (especially for CI environments)
opts := append(chromedp.DefaultExecAllocatorOptions[:],
chromedp.NoSandbox,
chromedp.DisableGPU,
)
// Create exec allocator with custom options
execCtx, execCancel := chromedp.NewExecAllocator(ctx, opts...)
defer execCancel()
// Create chromedp context
allocCtx, allocCancel := chromedp.NewContext(execCtx)
defer allocCancel()
// Buffer to store PDF
var pdfBuffer []byte
// Build tasks - set cookies BEFORE navigation
var tasks chromedp.Tasks
// Add cookies if provided - must be done before navigation
if len(cookies) > 0 {
tasks = append(tasks, chromedp.ActionFunc(func(ctx context.Context) error {
// Set cookies with domain (extract from URL)
// For localhost:1999, domain should be "localhost"
domain := "localhost"
for name, value := range cookies {
expr := network.SetCookie(name, value).
WithDomain(domain).
WithPath("/").
WithHTTPOnly(true).
WithSecure(false). // Set to true in production with HTTPS
WithSameSite(network.CookieSameSiteStrict)
if err := expr.Do(ctx); err != nil {
return fmt.Errorf("failed to set cookie %s: %w", name, err)
}
}
return nil
}))
}
// Navigate to URL
tasks = append(tasks, chromedp.Navigate(url))
// Wait for page to be ready
tasks = append(tasks,
chromedp.WaitReady("body"),
// Small delay to ensure all content is loaded
chromedp.Sleep(500*time.Millisecond),
)
// Apply mode-specific customizations
if mode == RenderModeScreen {
// For long version: Use print media for compact layout, but show sidebars
// Print CSS hides sidebars - we override that to show them
// UI elements remain hidden (already handled by print CSS)
// Wait for page to fully render
tasks = append(tasks, chromedp.Sleep(500*time.Millisecond))
// Check if this is a short version (to apply compact sidebar fonts)
// The length parameter is passed as a cookie, not in the URL
isShortVersion := cookies["cv-length"] == "short"
// Inject CSS to show sidebars AND restore their positioning
tasks = append(tasks, chromedp.ActionFunc(func(ctx context.Context) error {
// Base CSS for all versions with sidebars
baseSidebarCSS := `
(function() {
const style = document.createElement('style');
style.textContent = '@media print { ' +
// Override all width constraints for full page width
'.cv-page, .cv-paper, .cv-container { max-width: 100% !important; width: 100% !important; margin: 0 !important; padding: 0 !important; transform: none !important; } ' +
// Override print.css blocking display
'.cv-sidebar, .cv-sidebar-left, .cv-sidebar-right { display: block !important; } ' +
// Hide accordion header (web mobile UI element)
'.sidebar-accordion-header { display: none !important; } ' +
// Force page break before page 2 (start right sidebar on new page)
'.page-2 { page-break-before: always !important; break-before: page !important; } ' +
// Grid Layout - Match web's 2-column approach (no wasted space!)
'.page-content { ' +
'display: grid !important; ' +
'gap: 0 !important; ' +
'width: 100% !important; ' +
'max-width: 100% !important; ' +
'} ' +
// Page 1: Left sidebar (25%) + Main (75%) - NO right space
'.page-1 .page-content { ' +
'grid-template-columns: 25% 75% !important; ' +
'} ' +
// Page 2: Main (75%) + Right sidebar (25%) - NO left space
'.page-2 .page-content { ' +
'grid-template-columns: 75% 25% !important; ' +
'} ' +
// Sidebar positioning and padding
'.cv-sidebar-left { grid-column: 1 !important; padding: 12mm 8mm !important; } ' +
'.cv-sidebar-right { grid-column: 2 !important; padding: 12mm 8mm !important; } ' +
// Main content positioning (different column for each page)
'.page-1 .cv-main { grid-column: 2 !important; max-width: none !important; width: 100% !important; padding: 12mm 10mm !important; } ' +
'.page-2 .cv-main { grid-column: 1 !important; max-width: none !important; width: 100% !important; padding: 12mm 10mm !important; } '`
// Add compact font styles ONLY for short version
compactFontCSS := ""
if isShortVersion {
compactFontCSS = ` +
// Compact sidebar fonts (SHORT VERSION ONLY) - very subtle reduction to let content flow naturally
'.cv-sidebar * { font-size: 0.96em !important; line-height: 1.4 !important; } ' +
'.cv-sidebar h3 { font-size: 0.98em !important; margin: 0.4em 0 !important; padding: 0 !important; } ' +
'.cv-sidebar h4 { font-size: 0.96em !important; margin: 0.35em 0 !important; padding: 0 !important; } ' +
'.cv-sidebar p, .cv-sidebar li { font-size: 0.94em !important; line-height: 1.4 !important; margin: 0.3em 0 !important; padding: 0 !important; } ' +
'.cv-sidebar ul, .cv-sidebar ol { margin: 0.4em 0 0.4em 1.2em !important; padding: 0 !important; } ' +
'.cv-sidebar li { margin-bottom: 0.25em !important; } ' +
'.cv-sidebar section { margin-bottom: 0.8em !important; } '`
}
showSidebarsScript := baseSidebarCSS + compactFontCSS + ` +
'}';
document.head.appendChild(style);
})();
`
return chromedp.Evaluate(showSidebarsScript, nil).Do(ctx)
}))
}
// For RenderModePrint (clean version): use default print media (@media print CSS)
// which hides both UI elements and sidebars
// Generate PDF
tasks = append(tasks, chromedp.ActionFunc(func(ctx context.Context) error {
var err error
pdfBuffer, _, err = page.PrintToPDF().
WithPrintBackground(true).
WithPreferCSSPageSize(true).
WithMarginTop(0).
WithMarginBottom(0).
WithMarginLeft(0).
WithMarginRight(0).
WithPaperWidth(8.27). // A4 width in inches
WithPaperHeight(11.69). // A4 height in inches
Do(ctx)
return err
}))
// Run chromedp tasks
err := chromedp.Run(allocCtx, tasks)
if err != nil {
return nil, fmt.Errorf("chromedp execution failed: %w", err)
}
if len(pdfBuffer) == 0 {
return nil, fmt.Errorf("generated PDF is empty")
}
return pdfBuffer, nil
}
// StreamFromURL generates a PDF and writes it to the provided writer
func (g *Generator) StreamFromURL(ctx context.Context, url string, w io.Writer) error {
pdfData, err := g.GenerateFromURL(ctx, url)
if err != nil {
return err
}
_, err = w.Write(pdfData)
return err
}