package converter import ( "context" "errors" "fmt" "os" "path/filepath" "runtime" "strings" "time" "gitea.tecamino.com/paadi/html2pdf/models" "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" ) // html to pdf converter structure for type Converter struct { chromePath string allocCtx context.Context allocCancel context.CancelFunc // Cancels the whole Chrome process manager browserCtx context.Context // The specific browser instance browserCancel context.CancelFunc // Closes the browser progress func(progress int) } // NewConverter starts a new converter instance with a chrome headless shell executable func NewConverter(chromePath string) (*Converter, error) { var err error c := &Converter{chromePath: chromePath} chromePath, err = c.getChromePath() if err != nil { return nil, err } opts := append(chromedp.DefaultExecAllocatorOptions[:], chromedp.ExecPath(chromePath), chromedp.NoSandbox, chromedp.Headless, chromedp.DisableGPU, ) opts = append(opts, platformOptions()) c.allocCtx, c.allocCancel = chromedp.NewExecAllocator(context.Background(), opts...) c.browserCtx, c.browserCancel = chromedp.NewContext(c.allocCtx) // 5. "Warm up" the browser to ensure the executable actually runs // This catches "file not found" or permission errors immediately err = chromedp.Run(c.browserCtx) if err != nil { c.Close() // Cleanup if start fails return nil, fmt.Errorf("failed to start chrome: %w", err) } return c, nil } func (c *Converter) SetProgressCallback(cb func(progress int)) { c.progress = cb } // Convert converts all given input files func (c *Converter) Convert(files ...models.File) error { for i, f := range files { if c.progress != nil { c.progress(i + 1) } if f.Input == "" || filepath.Ext(f.Input) != ".html" { return fmt.Errorf("no .html input file path provided: %s", f.Input) } else if f.Output == "" || filepath.Ext(f.Output) != ".pdf" { return fmt.Errorf("no .pdf output file path provided: %s", f.Output) } var htmlURL strings.Builder htmlURL.WriteString("file://") switch runtime.GOOS { case "windows": htmlURL.WriteString("/") } // Convert to absolute path absPath, err := filepath.Abs(f.Input) if err != nil { return err } htmlURL.WriteString(filepath.ToSlash(absPath)) taskCtx, taskCancel := chromedp.NewContext(c.browserCtx) timeoutCtx, timeoutCancel := context.WithTimeout(taskCtx, 60*time.Second) var pdfData []byte err = chromedp.Run(timeoutCtx, chromedp.Navigate(htmlURL.String()), chromedp.WaitReady("body", chromedp.ByQuery), chromedp.ActionFunc(func(ctx context.Context) error { buf, _, err := page.PrintToPDF(). WithPrintBackground(true). WithPaperWidth(8.27). WithPaperHeight(11.69). Do(ctx) if err != nil { return err } pdfData = buf return nil }), ) timeoutCancel() taskCancel() if err != nil { return err } // Save PDF to file if err := os.WriteFile(f.Output, pdfData, 0644); err != nil { return err } } return nil } // Convert converts all given input files func (c *Converter) ConvertHtml(html []byte, outputPath string) error { if len(html) == 0 { return fmt.Errorf("no .html input provided") } else if outputPath == "" || filepath.Ext(outputPath) != ".pdf" { return fmt.Errorf("no .pdf output file path provided: %s", outputPath) } taskCtx, taskCancel := chromedp.NewContext(c.browserCtx) timeoutCtx, timeoutCancel := context.WithTimeout(taskCtx, 60*time.Second) var pdfData []byte err := chromedp.Run(timeoutCtx, // Start with a blank page chromedp.Navigate("about:blank"), // Inject HTML directly chromedp.ActionFunc(func(ctx context.Context) error { frameTree, err := page.GetFrameTree().Do(ctx) if err != nil { return err } return page.SetDocumentContent(frameTree.Frame.ID, string(html)).Do(ctx) }), chromedp.WaitReady("body", chromedp.ByQuery), chromedp.ActionFunc(func(ctx context.Context) error { buf, _, err := page.PrintToPDF(). WithPrintBackground(true). WithPaperWidth(8.27). WithPaperHeight(11.69). Do(ctx) if err != nil { return err } pdfData = buf return nil }), ) timeoutCancel() taskCancel() if err != nil { return err } // Save PDF to file if err := os.WriteFile(outputPath, pdfData, 0644); err != nil { return err } return nil } func (c *Converter) Close() { // Close browser first, then allocator if c.browserCancel != nil { c.browserCancel() } if c.allocCancel != nil { c.allocCancel() } } // getChromePath checks for system Chrome, else falls back to bundled headless shell func (c *Converter) getChromePath() (string, error) { chromeExec := "chrome-headless-shell" if runtime.GOOS == "windows" { chromeExec += ".exe" } path := filepath.Join(c.chromePath, chromeExec) if _, err := os.Stat(path); err == nil { return path, nil } // Candidate paths for system Chrome candidates := []string{} // Fallback: switch runtime.GOOS { case "windows": candidates = []string{ `C:\Program Files\Google\Chrome\Application\chrome.exe`, `C:\Program Files (x86)\Google\Chrome\Application\chrome.exe`, `C:\Program Files\Chromium\Application\chrome.exe`, } case "darwin": candidates = []string{ "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome", "/Applications/Chromium.app/Contents/MacOS/Chromium", } default: // Linux candidates = []string{ "/usr/bin/google-chrome", "/usr/bin/chromium-browser", "/usr/bin/chromium", } } // Check system paths first for _, path := range candidates { if _, err := os.Stat(path); err == nil { return path, nil } } return "", errors.New("chrome path not found") }