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 }