c89b67a06d
- Merge lang package into constants (add IsValidLang, ValidateLang, AllLangs) - Rename internal/services to internal/email for consistency with pdf package - Rename types to avoid redundancy: EmailService→Service, EmailConfig→Config - Update all imports and references across codebase - Delete internal/lang directory (functions moved to constants)
292 lines
9.4 KiB
Go
292 lines
9.4 KiB
Go
package pdf
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"io"
|
|
"time"
|
|
|
|
"github.com/chromedp/cdproto/network"
|
|
"github.com/chromedp/cdproto/page"
|
|
"github.com/chromedp/chromedp"
|
|
|
|
c "github.com/juanatsap/cv-site/internal/constants"
|
|
)
|
|
|
|
// 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 = c.TimeoutPDFGeneration
|
|
}
|
|
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[c.CookieCVLength] == c.CVLengthShort
|
|
|
|
// 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
|
|
}
|