This commit is contained in:
Julien Neuhart
2019-04-14 17:07:45 +02:00
committed by GitHub
parent c4222e0981
commit 194670c3bf
63 changed files with 2114 additions and 1503 deletions

View File

@@ -1,5 +1,5 @@
/*
Package notify is used across the application
to display nice outputs to the user.
Package notify helps displaying nice outputs
to the user.
*/
package notify

View File

@@ -7,24 +7,29 @@ import (
"github.com/labstack/gommon/color"
)
var (
stdout *color.Color
stderr *color.Color
)
func init() {
stdout = color.New()
// Print prints a message to stdout.
func Print(message string) {
stdout := color.New()
stdout.SetOutput(os.Stdout)
stderr = color.New()
stderr.SetOutput(os.Stderr)
}
// Println prints a message to stdout.
func Println(message string) {
stdout.Printf("⇨ %s\n", message)
}
// ErrPrintln prints an error to stderr.
func ErrPrintln(err error) {
// Printf prints a formatted message to stdout.
func Printf(format string, a ...interface{}) {
message := fmt.Sprintf(format, a...)
Print(message)
}
// WarnPrint prints a warning to stderr.
func WarnPrint(err error) {
stderr := color.New()
stderr.SetOutput(os.Stderr)
stderr.Printf("%s\n", color.Yellow(fmt.Sprintf("⇨ warn: %v", err)))
}
// ErrPrint prints an error to stderr.
func ErrPrint(err error) {
stderr := color.New()
stderr.SetOutput(os.Stderr)
stderr.Printf("%s\n", color.Red(fmt.Sprintf("⇨ error: %v", err)))
}

View File

@@ -2,30 +2,36 @@ package pm2
import (
"context"
"fmt"
"time"
"github.com/mafredri/cdp/devtool"
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
)
// Chrome facilitates starting or shutting down
// Chrome headless with PM2.
type Chrome struct{}
// Launch starts Chrome headless with PM2.
func (c *Chrome) Launch() error {
return launch(c)
type chrome struct {
manager *processManager
}
// Shutdown stops Chrome headless and
// removes it from the list of PM2
// processes.
func (c *Chrome) Shutdown() error {
return shutdown(c)
// NewChrome retruns a Google Chrome
// headless process.
func NewChrome() Process {
return &chrome{
manager: &processManager{},
}
}
func (c *Chrome) getArgs() []string {
func (p *chrome) Fullname() string {
return "Google Chrome headless"
}
func (p *chrome) Start() error {
return p.manager.start(p)
}
func (p *chrome) Shutdown() error {
return p.manager.shutdown(p)
}
func (p *chrome) args() []string {
return []string{
"--no-sandbox",
"--headless",
@@ -44,28 +50,23 @@ func (c *Chrome) getArgs() []string {
}
}
func (c *Chrome) getName() string {
func (p *chrome) name() string {
return "google-chrome-stable"
}
func (c *Chrome) getFullname() string {
return "Chrome headless"
}
func (c *Chrome) isViable() bool {
// check if Chrome is correctly running.
func (p *chrome) viable() bool {
// check if Google Chrome is correctly running.
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
_, err := devtool.New("http://localhost:9222").Version(ctx)
return err == nil
}
func (c *Chrome) warmup() {
notify.Println(fmt.Sprintf("warming-up %s", c.getFullname()))
func (p *chrome) warmup() {
time.Sleep(5 * time.Second)
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Process(new(Chrome))
_ = Process(new(chrome))
)

View File

@@ -6,14 +6,14 @@ import (
"github.com/stretchr/testify/require"
)
func TestChromeLaunch(t *testing.T) {
p := &Chrome{}
err := p.Launch()
func TestChromeStart(t *testing.T) {
p := NewChrome()
err := p.Start()
require.Nil(t, err)
}
func TestChromeShutdown(t *testing.T) {
p := &Chrome{}
p := NewChrome()
err := p.Shutdown()
require.Nil(t, err)
}

View File

@@ -2,7 +2,7 @@
Package pm2 facilitates starting external
processes on which our API depends.
For instance, it starts Chrome headless and
For instance, it may start Google Chrome headless and
unoconv listener with PM2.
The PM2 process manager launch those processes and keep

View File

@@ -3,69 +3,81 @@ package pm2
import (
"fmt"
"os/exec"
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
)
// Process is a type that can launch or
const (
stoppedState = iota
runningState
errorState
)
// Process is a type that can start or
// shutdown a process with PM2.
type Process interface {
Launch() error
Fullname() string
Start() error
Shutdown() error
getArgs() []string
getName() string
getFullname() string
isViable() bool
args() []string
name() string
viable() bool
warmup()
}
const maxRestartAttempts int = 5
var humanNames = map[string]string{
"start": "started",
"restart": "restarted",
"stop": "stopped",
type processManager struct {
heuristicState int32
}
func launch(p Process) error {
if err := run(p, "start"); err != nil {
func (m *processManager) start(p Process) error {
if err := m.pm2(p, "start"); err != nil {
return err
}
p.warmup()
if !p.isViable() {
if !p.viable() {
attempts := 0
for attempts < maxRestartAttempts && !p.isViable() {
run(p, "restart")
for attempts < 5 && !p.viable() {
if err := m.pm2(p, "restart"); err != nil {
m.heuristicState = errorState
return err
}
p.warmup()
attempts++
}
if !p.isViable() {
return fmt.Errorf("failed to launch %s", p.getFullname())
if !p.viable() {
m.heuristicState = errorState
return fmt.Errorf("failed to launch %s", p.Fullname())
}
}
m.heuristicState = runningState
return nil
}
func shutdown(p Process) error {
return run(p, "stop")
func (m *processManager) shutdown(p Process) error {
if m.heuristicState != runningState {
return nil
}
if err := m.pm2(p, "stop"); err != nil {
m.heuristicState = errorState
return err
}
m.heuristicState = stoppedState
return nil
}
func run(p Process, cmdName string) error {
func (m *processManager) pm2(p Process, cmdName string) error {
cmdArgs := []string{
cmdName,
p.getName(),
p.name(),
}
if cmdName == "start" {
cmdArgs = append(cmdArgs, "--interpreter none", "--")
cmdArgs = append(cmdArgs, p.getArgs()...)
cmdArgs = append(cmdArgs, p.args()...)
}
cmd := exec.Command(
"pm2",
cmdArgs...,
)
if err := cmd.Start(); err != nil {
return fmt.Errorf("%s %s with PM2: %v", cmdName, p.getFullname(), err)
return fmt.Errorf("%s %s with PM2: %v", cmdName, p.Fullname(), err)
}
notify.Println(fmt.Sprintf("%s %s with PM2", p.getFullname(), humanNames[cmdName]))
return nil
}

View File

@@ -1,47 +1,52 @@
package pm2
// Unoconv facilitates starting or shutting down
// unoconv listener with PM2.
type Unoconv struct{}
// Launch starts unoconv listener with PM2.
func (u *Unoconv) Launch() error {
return launch(u)
type unoconv struct {
manager *processManager
}
// Shutdown stops unoconv listener and
// removes it from the list of PM2
// processes.
func (u *Unoconv) Shutdown() error {
return shutdown(u)
// NewUnoconv retruns a unoconv listener
// process.
func NewUnoconv() Process {
return &unoconv{
manager: &processManager{},
}
}
func (u *Unoconv) getArgs() []string {
func (p *unoconv) Fullname() string {
return "unoconv listener"
}
func (p *unoconv) Start() error {
return p.manager.start(p)
}
func (p *unoconv) Shutdown() error {
return p.manager.shutdown(p)
}
func (p *unoconv) args() []string {
return []string{
"--listener",
"--verbose",
}
}
func (u *Unoconv) getName() string {
func (p *unoconv) name() string {
return "unoconv"
}
func (u *Unoconv) getFullname() string {
return "unoconv listener"
}
func (u *Unoconv) isViable() bool {
func (p *unoconv) viable() bool {
// TODO find a way to check if
// unoconv is correctly started?
// the unoconv listener
// is correctly started?
return true
}
func (u *Unoconv) warmup() {
func (p *unoconv) warmup() {
// let's do nothing.
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Process(new(Unoconv))
_ = Process(new(unoconv))
)

View File

@@ -6,14 +6,14 @@ import (
"github.com/stretchr/testify/require"
)
func TestUnoconvLaunch(t *testing.T) {
p := &Unoconv{}
err := p.Launch()
func TestUnoconvStart(t *testing.T) {
p := NewUnoconv()
err := p.Start()
require.Nil(t, err)
}
func TestUnoconvShutdown(t *testing.T) {
p := &Unoconv{}
p := NewUnoconv()
err := p.Shutdown()
require.Nil(t, err)
}

View File

@@ -0,0 +1,169 @@
package printer
import (
"context"
"fmt"
"io/ioutil"
"time"
"github.com/mafredri/cdp"
"github.com/mafredri/cdp/devtool"
"github.com/mafredri/cdp/protocol/network"
"github.com/mafredri/cdp/protocol/page"
"github.com/mafredri/cdp/protocol/target"
"github.com/mafredri/cdp/rpcc"
"golang.org/x/sync/errgroup"
)
type chrome struct {
url string
opts *ChromeOptions
}
// ChromeOptions helps customizing the
// Google Chrome printer behaviour.
type ChromeOptions struct {
WaitTimeout float64
WaitDelay float64
HeaderHTML string
FooterHTML string
PaperWidth float64
PaperHeight float64
MarginTop float64
MarginBottom float64
MarginLeft float64
MarginRight float64
Landscape bool
}
func (p *chrome) Print(destination string) error {
duration := time.Duration(p.opts.WaitTimeout+p.opts.WaitDelay) * time.Second
ctx, cancel := context.WithTimeout(context.Background(), duration)
defer cancel()
devt, err := devtool.New("http://localhost:9222").Version(ctx)
if err != nil {
return err
}
// connect to WebSocket URL (page) that speaks the Chrome DevTools Protocol.
devtConn, err := rpcc.DialContext(ctx, devt.WebSocketDebuggerURL)
if err != nil {
return err
}
defer devtConn.Close() // nolint: errcheck
// create a new CDP Client that uses conn.
devtClient := cdp.NewClient(devtConn)
newContextTarget, err := devtClient.Target.CreateBrowserContext(ctx)
if err != nil {
return fmt.Errorf("creating new browser context: %v", err)
}
// create a new blank target with the new browser context.
createTargetArgs := target.
NewCreateTargetArgs("about:blank").
SetBrowserContextID(newContextTarget.BrowserContextID)
newTarget, err := devtClient.Target.CreateTarget(ctx, createTargetArgs)
if err != nil {
return fmt.Errorf("creating new blank target: %v", err)
}
// connect the client to the new target.
newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID)
newContextConn, err := rpcc.DialContext(ctx, newTargetWsURL)
if err != nil {
return fmt.Errorf("connecting client to blank target: %v", err)
}
defer newContextConn.Close() // nolint: errcheck
// create a new CDP Client that uses newContextConn.
targetClient := cdp.NewClient(newContextConn)
closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID)
// close the target when done.
defer targetClient.Target.CloseTarget(ctx, closeTargetArgs) // nolint: errcheck
if err := runBatch(
// enable all the domain events that we're interested in.
func() error { return targetClient.DOM.Enable(ctx) },
func() error { return targetClient.Network.Enable(ctx, network.NewEnableArgs()) },
func() error { return targetClient.Page.Enable(ctx) },
func() error { return targetClient.Runtime.Enable(ctx) },
); err != nil {
return err
}
if err := p.navigate(ctx, targetClient); err != nil {
return err
}
print, err := targetClient.Page.PrintToPDF(
ctx,
page.NewPrintToPDFArgs().
SetPaperWidth(p.opts.PaperWidth).
SetPaperHeight(p.opts.PaperHeight).
SetMarginTop(p.opts.MarginTop).
SetMarginBottom(p.opts.MarginBottom).
SetMarginLeft(p.opts.MarginLeft).
SetMarginRight(p.opts.MarginRight).
SetLandscape(p.opts.Landscape).
SetDisplayHeaderFooter(true).
SetHeaderTemplate(p.opts.HeaderHTML).
SetFooterTemplate(p.opts.FooterHTML).
SetPrintBackground(true),
)
if err != nil {
return fmt.Errorf("printing page to PDF: %v", err)
}
if err := ioutil.WriteFile(destination, print.Data, 0644); err != nil {
return fmt.Errorf("%s: writing file: %v", destination, err)
}
return nil
}
func (p *chrome) navigate(ctx context.Context, client *cdp.Client) error {
// make sure Page events are enabled.
if err := client.Page.Enable(ctx); err != nil {
return err
}
// make sure Network events are enabled.
if err := client.Network.Enable(ctx, nil); err != nil {
return err
}
// create all clients for events.
domContentEventFired, err := client.Page.DOMContentEventFired(ctx)
if err != nil {
return err
}
defer domContentEventFired.Close() // nolint: errcheck
loadEventFired, err := client.Page.LoadEventFired(ctx)
if err != nil {
return err
}
defer loadEventFired.Close() // nolint: errcheck
loadingFinished, err := client.Network.LoadingFinished(ctx)
if err != nil {
return err
}
defer loadingFinished.Close() // nolint: errcheck
if _, err := client.Page.Navigate(ctx, page.NewNavigateArgs(p.url)); err != nil {
return err
}
if err := runBatch(
// wait for all events.
func() error { _, err := domContentEventFired.Recv(); return err },
func() error { _, err := loadEventFired.Recv(); return err },
func() error { _, err := loadingFinished.Recv(); return err },
); err != nil {
return err
}
// wait for a given amount of time (useful for javascript delay).
time.Sleep(time.Duration(p.opts.WaitDelay) * time.Second)
return nil
}
func runBatch(fn ...func() error) error {
// run all functions simultaneously and wait until
// execution has completed or an error is encountered.
eg := errgroup.Group{}
for _, f := range fn {
eg.Go(f)
}
return eg.Wait()
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(chrome))
)

View File

@@ -1,7 +1,5 @@
/*
Package printer contains structs which convert
a specific file type to PDF.
It is also able to merge a list of PDF files.
*/
package printer

View File

@@ -1,193 +1,14 @@
package printer
import (
"context"
"errors"
"fmt"
"io/ioutil"
"strings"
"github.com/mafredri/cdp"
"github.com/mafredri/cdp/devtool"
"github.com/mafredri/cdp/protocol/network"
"github.com/mafredri/cdp/protocol/page"
"github.com/mafredri/cdp/protocol/runtime"
"github.com/mafredri/cdp/protocol/target"
"github.com/mafredri/cdp/rpcc"
)
// HTML facilitates HTML to PDF conversion.
type HTML struct {
Context context.Context
URL string
HeaderHTML string
FooterHTML string
PaperWidth float64
PaperHeight float64
MarginTop float64
MarginBottom float64
MarginLeft float64
MarginRight float64
Landscape bool
WebFontsTimeout int64
// NewHTML returns an HTML printer.
func NewHTML(fpath string, opts *ChromeOptions) Printer {
URL := fmt.Sprintf("file://%s", fpath)
return &chrome{
url: URL,
opts: opts,
}
}
const defaultHeaderFooterHTML string = "<html><head></head><body></body></html>"
// Print converts HTML to PDF.
// Credits: https://medium.com/compass-true-north/go-service-to-convert-web-pages-to-pdf-using-headless-chrome-5fd9ffbae1af
func (html *HTML) Print(destination string) error {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
// use the DevTools HTTP/JSON API to manage targets (e.g. pages, webworkers).
devt, err := devtool.New("http://localhost:9222").Version(ctx)
if err != nil {
return fmt.Errorf("creating DevTools target: %v", err)
}
// open a new RPC connection to the Chrome Debugging Protocol target.
conn, err := rpcc.DialContext(html.Context, devt.WebSocketDebuggerURL)
if err != nil {
return fmt.Errorf("creating RPC connection: %v", err)
}
defer conn.Close()
// create new browser context.
baseBrowser := cdp.NewClient(conn)
newContextTarget, err := baseBrowser.Target.CreateBrowserContext(html.Context)
if err != nil {
return fmt.Errorf("creating new browser context: %v", err)
}
// create a new blank target with the new browser context.
newTargetArgs := target.NewCreateTargetArgs("about:blank").
SetBrowserContextID(newContextTarget.BrowserContextID)
newTarget, err := baseBrowser.Target.CreateTarget(html.Context, newTargetArgs)
if err != nil {
return fmt.Errorf("creating new blank target: %v", err)
}
// connect the client to the new target.
newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID)
newContextConn, err := rpcc.DialContext(html.Context, newTargetWsURL)
if err != nil {
return fmt.Errorf("connecting client to blank target: %v", err)
}
defer newContextConn.Close()
// close the target when done.
closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID)
defer baseBrowser.Target.CloseTarget(html.Context, closeTargetArgs)
c := cdp.NewClient(newContextConn)
// enable the runtime.
if err := c.Runtime.Enable(html.Context); err != nil {
return fmt.Errorf("enabling runtime: %v", err)
}
// enable the network.
if err := c.Network.Enable(html.Context, network.NewEnableArgs()); err != nil {
return fmt.Errorf("enabling network: %v", err)
}
// enable events on the page domain.
if err := c.Page.Enable(html.Context); err != nil {
return fmt.Errorf("enabling events on page domain: %v", err)
}
// create a client to listen for the load event to be fired.
loadEventFiredClient, err := c.Page.LoadEventFired(html.Context)
if err != nil {
return fmt.Errorf("creating client listening for load event: %v", err)
}
defer loadEventFiredClient.Close()
// tell the page to navigate to the URL.
navArgs := page.NewNavigateArgs(html.URL)
_, err = c.Page.Navigate(html.Context, navArgs)
if err != nil {
return fmt.Errorf("%s: navigating to URL: %v", html.URL, err)
}
// wait for the page to finish loading.
_, err = loadEventFiredClient.Recv()
if err != nil {
return fmt.Errorf("waiting for page loading: %v", err)
}
// inject a script to make sure web fonts are loaded.
script := fmt.Sprintf(`new Promise((resolve, reject) => {
document.fonts.ready.then(function () {
resolve('fonts loaded');
});
setTimeout(resolve.bind(resolve, 'timeout'), %d);
});`, html.WebFontsTimeout)
scriptArg := runtime.NewEvaluateArgs(script).SetAwaitPromise(true)
returnObj, _ := c.Runtime.Evaluate(html.Context, scriptArg)
if returnObj.ExceptionDetails != nil {
return fmt.Errorf("script evaluated with exception: %+v", returnObj.ExceptionDetails)
}
loadFontsResult := string(returnObj.Result.Value)
if strings.Contains(loadFontsResult, "timeout") {
return errors.New("timed out loading fonts")
}
// if no header or footer, use the default template
// for avoiding displaying default Chrome templates.
if html.HeaderHTML == "" {
html.HeaderHTML = defaultHeaderFooterHTML
}
if html.FooterHTML == "" {
html.FooterHTML = defaultHeaderFooterHTML
}
print, err := c.Page.PrintToPDF(
html.Context,
page.NewPrintToPDFArgs().
SetPaperWidth(html.PaperWidth).
SetPaperHeight(html.PaperHeight).
SetMarginTop(html.MarginTop).
SetMarginBottom(html.MarginBottom).
SetMarginLeft(html.MarginLeft).
SetMarginRight(html.MarginRight).
SetLandscape(html.Landscape).
SetDisplayHeaderFooter(true).
SetHeaderTemplate(html.HeaderHTML).
SetFooterTemplate(html.FooterHTML).
SetPrintBackground(true),
)
if err != nil {
return fmt.Errorf("printing page to PDF: %v", err)
}
return writeBytesToFile(destination, print.Data)
}
// WithLocalURL sets a local URL from a file path.
func (html *HTML) WithLocalURL(fpath string) {
html.URL = fmt.Sprintf("file://%s", fpath)
}
// WithHeaderFile sets header content from a file.
func (html *HTML) WithHeaderFile(fpath string) error {
if fpath == "" {
return nil
}
contentHTML, err := fileContentToString(fpath)
if err != nil {
return err
}
html.HeaderHTML = contentHTML
return nil
}
// WithFooterFile sets footer content from a file.
func (html *HTML) WithFooterFile(fpath string) error {
if fpath == "" {
return nil
}
contentHTML, err := fileContentToString(fpath)
if err != nil {
return err
}
html.FooterHTML = contentHTML
return nil
}
func fileContentToString(fpath string) (string, error) {
b, err := ioutil.ReadFile(fpath)
if err != nil {
return "", fmt.Errorf("%s: reading file: %v", fpath, err)
}
return string(b), nil
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(HTML))
)

View File

@@ -1,39 +0,0 @@
package printer
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/test"
)
func TestHTML(t *testing.T) {
dirPath := test.HTMLTestDirPath(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
html := &HTML{
Context: ctx,
PaperWidth: 8.27,
PaperHeight: 11.7,
MarginTop: 1,
MarginBottom: 1,
MarginLeft: 1,
MarginRight: 1,
}
html.WithLocalURL(fmt.Sprintf("%s/%s", dirPath, "index.html"))
err := html.WithHeaderFile(fmt.Sprintf("%s/%s", dirPath, "header.html"))
require.Nil(t, err)
err = html.WithFooterFile(fmt.Sprintf("%s/%s", dirPath, "footer.html"))
require.Nil(t, err)
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
err = html.Print(dst)
require.Nil(t, err)
require.FileExists(t, dst)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

@@ -2,7 +2,6 @@ package printer
import (
"bytes"
"context"
"fmt"
"html/template"
"io/ioutil"
@@ -13,101 +12,48 @@ import (
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
)
// Markdown facilitates Markdown to PDF conversion.
type Markdown struct {
Context context.Context
TemplatePath string
HeaderHTML string
FooterHTML string
PaperWidth float64
PaperHeight float64
MarginTop float64
MarginBottom float64
MarginLeft float64
MarginRight float64
Landscape bool
WebFontsTimeout int64
html *HTML
// NewMarkdown returns a Markdown printer.
func NewMarkdown(fpath string, opts *ChromeOptions) (Printer, error) {
tmpl, err := template.
New(filepath.Base(fpath)).
Funcs(template.FuncMap{"toHTML": markdownToHTML}).
ParseFiles(fpath)
if err != nil {
return nil, fmt.Errorf("%s: parsing template: %v", fpath, err)
}
dirPath := filepath.Dir(fpath)
data := &templateData{DirPath: dirPath}
var buffer bytes.Buffer
if err := tmpl.Execute(&buffer, data); err != nil {
return nil, fmt.Errorf("%s: executing template: %v", fpath, err)
}
baseFilename, err := rand.Get()
if err != nil {
return nil, err
}
dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename)
if err := ioutil.WriteFile(dst, buffer.Bytes(), 0644); err != nil {
return nil, fmt.Errorf("%s: writing file: %v", dst, err)
}
URL := fmt.Sprintf("file://%s", dst)
return &chrome{
url: URL,
opts: opts,
}, nil
}
type templateData struct {
DirPath string
}
// Print converts markdown to PDF.
func (md *Markdown) Print(destination string) error {
if md.html == nil {
md.html = &HTML{Context: md.Context}
}
if md.HeaderHTML != "" {
md.html.HeaderHTML = md.HeaderHTML
}
if md.FooterHTML != "" {
md.html.FooterHTML = md.FooterHTML
}
md.html.PaperWidth = md.PaperWidth
md.html.PaperHeight = md.PaperHeight
md.html.MarginTop = md.MarginTop
md.html.MarginBottom = md.MarginBottom
md.html.MarginLeft = md.MarginLeft
md.html.MarginRight = md.MarginRight
md.html.Landscape = md.Landscape
md.html.WebFontsTimeout = md.WebFontsTimeout
tmpl, err := template.
New(filepath.Base(md.TemplatePath)).
Funcs(template.FuncMap{"toHTML": toHTML}).
ParseFiles(md.TemplatePath)
if err != nil {
return fmt.Errorf("%s: parsing template: %v", md.TemplatePath, err)
}
dirPath := filepath.Dir(md.TemplatePath)
data := &templateData{DirPath: dirPath}
var buffer bytes.Buffer
if err := tmpl.Execute(&buffer, data); err != nil {
return fmt.Errorf("%s: executing template: %v", md.TemplatePath, err)
}
baseFilename, err := rand.Get()
if err != nil {
return err
}
dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename)
if err := writeBytesToFile(dst, buffer.Bytes()); err != nil {
return err
}
md.html.WithLocalURL(dst)
return md.html.Print(destination)
}
func toHTML(dirPath, filename string) (template.HTML, error) {
func markdownToHTML(dirPath, filename string) (template.HTML, error) {
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
b, err := ioutil.ReadFile(fpath)
if err != nil {
return "", fmt.Errorf("%s: reading file: %v", fpath, err)
}
unsafe := blackfriday.Run(b)
contentHTML := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
return template.HTML(contentHTML), nil
content := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
/* #nosec */
return template.HTML(content), nil
}
// WithHeaderFile sets header content from a file.
func (md *Markdown) WithHeaderFile(fpath string) error {
if md.html == nil {
md.html = &HTML{Context: md.Context}
}
return md.html.WithHeaderFile(fpath)
}
// WithFooterFile sets footer content from a file.
func (md *Markdown) WithFooterFile(fpath string) error {
if md.html == nil {
md.html = &HTML{Context: md.Context}
}
return md.html.WithFooterFile(fpath)
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(Markdown))
)

View File

@@ -1,39 +0,0 @@
package printer
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMarkdown(t *testing.T) {
dirPath := test.MarkdownTestDirPath(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
markdown := &Markdown{
Context: ctx,
TemplatePath: fmt.Sprintf("%s/%s", dirPath, "index.html"),
PaperWidth: 8.27,
PaperHeight: 11.7,
MarginTop: 1,
MarginBottom: 1,
MarginLeft: 1,
MarginRight: 1,
}
err := markdown.WithHeaderFile(fmt.Sprintf("%s/%s", dirPath, "header.html"))
require.Nil(t, err)
err = markdown.WithFooterFile(fmt.Sprintf("%s/%s", dirPath, "footer.html"))
require.Nil(t, err)
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
err = markdown.Print(dst)
require.Nil(t, err)
require.FileExists(t, dst)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

@@ -0,0 +1,50 @@
package printer
import (
"context"
"fmt"
"os/exec"
"time"
)
type merge struct {
ctx context.Context
fpaths []string
opts *MergeOptions
}
// MergeOptions helps customizing the
// merge printer behaviour.
type MergeOptions struct {
WaitTimeout float64
}
// NewMerge returns a merge printer.
func NewMerge(fpaths []string, opts *MergeOptions) Printer {
return &merge{
fpaths: fpaths,
opts: opts,
}
}
func (p *merge) Print(destination string) error {
if p.ctx == nil {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second)
defer cancel()
p.ctx = ctx
}
var cmdArgs []string
cmdArgs = append(cmdArgs, p.fpaths...)
cmdArgs = append(cmdArgs, "cat", "output", destination)
cmd := exec.CommandContext(p.ctx, "pdftk", cmdArgs...)
_, err := cmd.Output()
if err != nil {
return fmt.Errorf("pdtk: %v", err)
}
return nil
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(merge))
)

View File

@@ -2,66 +2,89 @@ package printer
import (
"context"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sync"
"time"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
)
var mu sync.Mutex
// Office facilitates Office documents to PDF conversion.
type Office struct {
Context context.Context
FilePaths []string
Landscape bool
type office struct {
fpaths []string
opts *OfficeOptions
}
// Print converts Office documents to PDF.
func (o *Office) Print(destination string) error {
mu.Lock()
defer mu.Unlock()
fpaths := make([]string, len(o.FilePaths))
// OfficeOptions helps customizing the
// Office printer behaviour.
type OfficeOptions struct {
WaitTimeout float64
Landscape bool
}
// NewOffice returns an Office printer.
func NewOffice(fpaths []string, opts *OfficeOptions) Printer {
return &office{
fpaths: fpaths,
opts: opts,
}
}
func (p *office) Print(destination string) error {
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(p.opts.WaitTimeout)*time.Second)
defer cancel()
fpaths := make([]string, len(p.fpaths))
dirPath := filepath.Dir(destination)
for i, fpath := range o.FilePaths {
for i, fpath := range p.fpaths {
baseFilename, err := rand.Get()
if err != nil {
return err
}
tmpDest := fmt.Sprintf("%s/%s.pdf", dirPath, baseFilename)
cmdArgs := []string{
"--format",
"pdf",
}
if o.Landscape {
cmdArgs = append(cmdArgs, "--printer", "PaperOrientation=landscape")
}
cmdArgs = append(cmdArgs, "--output", tmpDest, fpath)
cmd := exec.CommandContext(
o.Context,
"unoconv",
cmdArgs...,
)
_, err = cmd.Output()
if o.Context.Err() == context.DeadlineExceeded {
return errors.New("unoconv: command timed out")
}
if err != nil {
return fmt.Errorf("unoconv: non-zero exit code: %v", err)
tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename)
if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil {
return err
}
fpaths[i] = tmpDest
}
if len(fpaths) == 1 {
return os.Rename(fpaths[0], destination)
}
return Merge(fpaths, destination)
m := &merge{
ctx: ctx,
fpaths: fpaths,
}
return m.Print(destination)
}
// nolint: gochecknoglobals
var mu sync.Mutex
func unoconv(ctx context.Context, fpath, destination string, opts *OfficeOptions) error {
mu.Lock()
defer mu.Unlock()
cmdArgs := []string{
"--format",
"pdf",
}
if opts.Landscape {
cmdArgs = append(cmdArgs, "--printer", "PaperOrientation=landscape")
}
cmdArgs = append(cmdArgs, "--output", destination, fpath)
cmd := exec.CommandContext(
ctx,
"unoconv",
cmdArgs...,
)
_, err := cmd.Output()
if err != nil {
return fmt.Errorf("unoconv: %v", err)
}
return nil
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Printer(new(Office))
_ = Printer(new(office))
)

View File

@@ -1,33 +0,0 @@
package printer
import (
"context"
"fmt"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/test"
)
func TestOffice(t *testing.T) {
dirPath := test.OfficeTestDirPath(t)
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
office := &Office{
Context: ctx,
FilePaths: []string{
fmt.Sprintf("%s/%s", dirPath, "document.docx"),
fmt.Sprintf("%s/%s", dirPath, "document.txt"),
fmt.Sprintf("%s/%s", dirPath, "document.rtf"),
},
}
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
err := office.Print(dst)
require.Nil(t, err)
require.FileExists(t, dst)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

@@ -1,46 +1,7 @@
package printer
import (
"fmt"
"io/ioutil"
"os/exec"
pdfcpuAPI "github.com/hhrutter/pdfcpu/pkg/api"
pdfcpuLog "github.com/hhrutter/pdfcpu/pkg/log"
pdfcpuConfig "github.com/hhrutter/pdfcpu/pkg/pdfcpu"
)
func init() {
// disable loggers when merging
// PDFs.
pdfcpuLog.DisableLoggers()
}
// Printer is a type that can create a PDF file from a source.
// The source is defined in the underlying implementation.
type Printer interface {
Print(destination string) error
}
// Merge merges PDF files.
func Merge(fpaths []string, destination string) error {
cmdcpu := pdfcpuAPI.MergeCommand(fpaths, destination, pdfcpuConfig.NewDefaultConfiguration())
_, err := pdfcpuAPI.Merge(cmdcpu)
if err == nil {
return nil
}
// if pdfcpu failed to merge PDF files...
// https://github.com/thecodingmachine/gotenberg/issues/29
var cmdArgs []string
cmdArgs = append(cmdArgs, fpaths...)
cmdArgs = append(cmdArgs, "cat", "output", destination)
cmd := exec.Command("pdftk", cmdArgs...)
return cmd.Run()
}
func writeBytesToFile(dst string, b []byte) error {
if err := ioutil.WriteFile(dst, b, 0644); err != nil {
return fmt.Errorf("%s: writing file: %v", dst, err)
}
return nil
}

View File

@@ -1,27 +0,0 @@
package printer
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMerge(t *testing.T) {
dirPath := test.PDFTestDirPath(t)
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
err := Merge(
[]string{
fmt.Sprintf("%s/%s", dirPath, "gotenberg.pdf"),
fmt.Sprintf("%s/%s", dirPath, "gotenberg_bis.pdf"),
},
dst,
)
require.Nil(t, err)
require.FileExists(t, dst)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

@@ -0,0 +1,9 @@
package printer
// NewURL returns a URL printer.
func NewURL(url string, opts *ChromeOptions) Printer {
return &chrome{
url: url,
opts: opts,
}
}