32814c4796
- 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.
290 lines
9.3 KiB
Go
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
|
|
}
|