mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-14 03:12:14 +01:00
v3.0.0 (#18)
This commit is contained in:
125
internal/app/api/api.go
Normal file
125
internal/app/api/api.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/labstack/echo/middleware"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/pm2"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
|
||||
)
|
||||
|
||||
// Start starts the API server on port 3000.
|
||||
func Start() error {
|
||||
e := setup()
|
||||
// start Chrome headless and
|
||||
// unoconv listener with PM2.
|
||||
chrome := &pm2.Chrome{}
|
||||
unoconv := &pm2.Unoconv{}
|
||||
if err := chrome.Launch(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unoconv.Launch(); err != nil {
|
||||
return err
|
||||
}
|
||||
// run our API in a goroutine so that it doesn't block.
|
||||
go func() {
|
||||
notify.Println("http server started on port 3000")
|
||||
if err := e.Start(":3000"); err != nil {
|
||||
e.Logger.Fatalf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
quit := make(chan os.Signal, 1)
|
||||
// we'll accept graceful shutdowns when quit via SIGINT (Ctrl+C)
|
||||
// SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught.
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
// block until we receive our signal.
|
||||
<-quit
|
||||
// create a deadline to wait for.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
// doesn't block if no connections, but will otherwise wait
|
||||
// until the timeout deadline.
|
||||
notify.Println("shutting down http server... (Ctrl+C to force)")
|
||||
return e.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func setup() *echo.Echo {
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if err := next(c); err != nil {
|
||||
// TODO should return a better HTTP status code
|
||||
// than 500 for some cases.
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
})
|
||||
e.POST("/merge", merge)
|
||||
g := e.Group("/convert")
|
||||
g.POST("/html", convertHTML)
|
||||
g.POST("/markdown", convertMarkdown)
|
||||
g.POST("/office", convertOffice)
|
||||
return e
|
||||
}
|
||||
|
||||
func newContext(r *resource) (context.Context, context.CancelFunc) {
|
||||
webhookURL := r.webhookURL()
|
||||
if webhookURL == "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
return ctx, cancel
|
||||
}
|
||||
return context.Background(), nil
|
||||
}
|
||||
|
||||
func print(c echo.Context, p printer.Printer, r *resource) error {
|
||||
baseFilename, err := rand.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting result file name: %v", err)
|
||||
}
|
||||
filename := fmt.Sprintf("%s.pdf", baseFilename)
|
||||
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
|
||||
if r.webhookURL() == "" {
|
||||
// if no webhook URL given, run conversion
|
||||
// and directly return the resulting PDF file
|
||||
// or and error.
|
||||
if err := p.Print(fpath); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Attachment(fpath, filename)
|
||||
}
|
||||
// as a webhook URL has been given, we
|
||||
// run the following lines in a goroutine so that
|
||||
// it doesn't block.
|
||||
go func() {
|
||||
if err := p.Print(fpath); err != nil {
|
||||
c.Logger().Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(fpath)
|
||||
if err != nil {
|
||||
c.Logger().Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
resp, err := http.Post(r.webhookURL(), "application/pdf", f)
|
||||
if err != nil {
|
||||
c.Logger().Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
9
internal/app/api/doc.go
Normal file
9
internal/app/api/doc.go
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Package api starts a HTTP server on port 3000.
|
||||
|
||||
It accepts POST requests with a multipart/form-data Content-Type
|
||||
for converting HTML, Markdown and Office documents to PDF.
|
||||
|
||||
It is also able to merge a list of PDF files.
|
||||
*/
|
||||
package api
|
||||
52
internal/app/api/html.go
Normal file
52
internal/app/api/html.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
|
||||
)
|
||||
|
||||
func convertHTML(c echo.Context) error {
|
||||
r, err := newResource(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.removeAll()
|
||||
ctx, cancel := newContext(r)
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
p := &printer.HTML{Context: ctx}
|
||||
indexPath, err := r.filePath("index.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.WithLocalURL(indexPath)
|
||||
headerPath, _ := r.filePath("header.html")
|
||||
if err := p.WithHeaderFile(headerPath); err != nil {
|
||||
return err
|
||||
}
|
||||
footerPath, _ := r.filePath("footer.html")
|
||||
if err := p.WithFooterFile(footerPath); err != nil {
|
||||
return err
|
||||
}
|
||||
paperSize, err := r.paperSize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.PaperWidth = paperSize[0]
|
||||
p.PaperHeight = paperSize[1]
|
||||
paperMargins, err := r.paperMargins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.MarginTop = paperMargins[0]
|
||||
p.MarginBottom = paperMargins[1]
|
||||
p.MarginLeft = paperMargins[2]
|
||||
p.MarginRight = paperMargins[3]
|
||||
landscape, err := r.landscape()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Landscape = landscape
|
||||
return print(c, p, r)
|
||||
}
|
||||
21
internal/app/api/html_test.go
Normal file
21
internal/app/api/html_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/thecodingmachine/gotenberg/test"
|
||||
)
|
||||
|
||||
func TestHTML(t *testing.T) {
|
||||
body, contentType := test.HTMLMultipartForm(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/convert/html", body)
|
||||
req.Header.Set(echo.HeaderContentType, contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
e := echo.New()
|
||||
c := e.NewContext(req, rec)
|
||||
assert.NoError(t, convertHTML(c))
|
||||
}
|
||||
51
internal/app/api/markdown.go
Normal file
51
internal/app/api/markdown.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
|
||||
)
|
||||
|
||||
func convertMarkdown(c echo.Context) error {
|
||||
r, err := newResource(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.removeAll()
|
||||
ctx, cancel := newContext(r)
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
indexPath, err := r.filePath("index.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := &printer.Markdown{Context: ctx, TemplatePath: indexPath}
|
||||
headerPath, _ := r.filePath("header.html")
|
||||
if err := p.WithHeaderFile(headerPath); err != nil {
|
||||
return err
|
||||
}
|
||||
footerPath, _ := r.filePath("footer.html")
|
||||
if err := p.WithFooterFile(footerPath); err != nil {
|
||||
return err
|
||||
}
|
||||
paperSize, err := r.paperSize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.PaperWidth = paperSize[0]
|
||||
p.PaperHeight = paperSize[1]
|
||||
paperMargins, err := r.paperMargins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.MarginTop = paperMargins[0]
|
||||
p.MarginBottom = paperMargins[1]
|
||||
p.MarginLeft = paperMargins[2]
|
||||
p.MarginRight = paperMargins[3]
|
||||
landscape, err := r.landscape()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Landscape = landscape
|
||||
return print(c, p, r)
|
||||
}
|
||||
21
internal/app/api/markdown_test.go
Normal file
21
internal/app/api/markdown_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/thecodingmachine/gotenberg/test"
|
||||
)
|
||||
|
||||
func TestMarkdown(t *testing.T) {
|
||||
body, contentType := test.MarkdownMultipartForm(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
|
||||
req.Header.Set(echo.HeaderContentType, contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
e := echo.New()
|
||||
c := e.NewContext(req, rec)
|
||||
assert.NoError(t, convertMarkdown(c))
|
||||
}
|
||||
57
internal/app/api/merge.go
Normal file
57
internal/app/api/merge.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
|
||||
)
|
||||
|
||||
func merge(c echo.Context) error {
|
||||
r, err := newResource(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.removeAll()
|
||||
fpaths, err := r.filePaths([]string{".pdf"})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
baseFilename, err := rand.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting result file name: %v", err)
|
||||
}
|
||||
filename := fmt.Sprintf("%s.pdf", baseFilename)
|
||||
dest := fmt.Sprintf("%s/%s", r.dirPath, filename)
|
||||
if r.webhookURL() == "" {
|
||||
// if no webhook URL given, run merge
|
||||
// and directly return the resulting PDF file
|
||||
// or and error.
|
||||
return printer.Merge(fpaths, dest)
|
||||
}
|
||||
// as a webhook URL has been given, we
|
||||
// run the following lines in a goroutine so that
|
||||
// it doesn't block.
|
||||
go func() {
|
||||
if err := printer.Merge(fpaths, dest); err != nil {
|
||||
c.Logger().Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(dest)
|
||||
if err != nil {
|
||||
c.Logger().Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
resp, err := http.Post(r.webhookURL(), "application/pdf", f)
|
||||
if err != nil {
|
||||
c.Logger().Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
21
internal/app/api/merge_test.go
Normal file
21
internal/app/api/merge_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/thecodingmachine/gotenberg/test"
|
||||
)
|
||||
|
||||
func TestMerge(t *testing.T) {
|
||||
body, contentType := test.PDFMultipartForm(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/merge", body)
|
||||
req.Header.Set(echo.HeaderContentType, contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
e := echo.New()
|
||||
c := e.NewContext(req, rec)
|
||||
assert.NoError(t, merge(c))
|
||||
}
|
||||
41
internal/app/api/office.go
Normal file
41
internal/app/api/office.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
|
||||
)
|
||||
|
||||
var officeExts = []string{
|
||||
".doc",
|
||||
".docx",
|
||||
".odt",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ods",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".odp",
|
||||
}
|
||||
|
||||
func convertOffice(c echo.Context) error {
|
||||
r, err := newResource(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.removeAll()
|
||||
ctx, cancel := newContext(r)
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
fpaths, err := r.filePaths(officeExts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fpaths) == 0 {
|
||||
return errors.New("no suitable office documents to convert")
|
||||
}
|
||||
p := &printer.Office{Context: ctx, FilePaths: fpaths}
|
||||
return print(c, p, r)
|
||||
}
|
||||
21
internal/app/api/office_test.go
Normal file
21
internal/app/api/office_test.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/thecodingmachine/gotenberg/test"
|
||||
)
|
||||
|
||||
func TestOffice(t *testing.T) {
|
||||
body, contentType := test.OfficeMultipartForm(t)
|
||||
req := httptest.NewRequest(http.MethodPost, "/convert/office", body)
|
||||
req.Header.Set(echo.HeaderContentType, contentType)
|
||||
rec := httptest.NewRecorder()
|
||||
e := echo.New()
|
||||
c := e.NewContext(req, rec)
|
||||
assert.NoError(t, convertOffice(c))
|
||||
}
|
||||
184
internal/app/api/resource.go
Normal file
184
internal/app/api/resource.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
|
||||
)
|
||||
|
||||
const (
|
||||
webhookURL string = "webhookURL"
|
||||
paperWidth string = "paperWidth"
|
||||
paperHeight string = "paperHeight"
|
||||
marginTop string = "marginTop"
|
||||
marginBottom string = "marginBottom"
|
||||
marginLeft string = "marginLeft"
|
||||
marginRight string = "marginRight"
|
||||
landscape string = "landscape"
|
||||
)
|
||||
|
||||
// resource facilitates storing and accessing
|
||||
// data from a multipart/form-data request.
|
||||
type resource struct {
|
||||
values map[string]string
|
||||
dirPath string
|
||||
}
|
||||
|
||||
func newResource(c echo.Context) (*resource, error) {
|
||||
v := make(map[string]string)
|
||||
v[webhookURL] = c.FormValue(webhookURL)
|
||||
v[paperWidth] = c.FormValue(paperWidth)
|
||||
v[paperHeight] = c.FormValue(paperHeight)
|
||||
v[marginTop] = c.FormValue(marginTop)
|
||||
v[marginBottom] = c.FormValue(marginBottom)
|
||||
v[marginLeft] = c.FormValue(marginLeft)
|
||||
v[marginRight] = c.FormValue(marginRight)
|
||||
v[landscape] = c.FormValue(landscape)
|
||||
dirPath, err := rand.Get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("%s: making directory: %v", dirPath, err)
|
||||
}
|
||||
r := &resource{values: v, dirPath: dirPath}
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting multipart form: %v", err)
|
||||
}
|
||||
for _, files := range form.File {
|
||||
for _, fh := range files {
|
||||
in, err := fh.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: opening file: %v", fh.Filename, err)
|
||||
}
|
||||
defer in.Close()
|
||||
if err := r.writeFile(fh.Filename, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *resource) writeFile(filename string, in io.Reader) error {
|
||||
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
|
||||
out, err := os.Create(fpath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: creating new file: %v", fpath, err)
|
||||
}
|
||||
defer out.Close()
|
||||
if err := out.Chmod(0644); err != nil {
|
||||
return fmt.Errorf("%s: changing file mode: %v", fpath, err)
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return fmt.Errorf("%s: writing file: %v", fpath, err)
|
||||
}
|
||||
if _, err := out.Seek(0, 0); err != nil {
|
||||
return fmt.Errorf("%s: resetting read pointer: %v", fpath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *resource) filePath(filename string) (string, error) {
|
||||
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
|
||||
_, err := os.Stat(fpath)
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("%s: file does not exist", filename)
|
||||
}
|
||||
absPath, err := filepath.Abs(fpath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: getting absolute path: %v", fpath, err)
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func (r *resource) filePaths(exts []string) ([]string, error) {
|
||||
var fpaths []string
|
||||
err := filepath.Walk(r.dirPath, func(path string, info os.FileInfo, _ error) error {
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
fpath, err := r.filePath(info.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ext := range exts {
|
||||
if filepath.Ext(fpath) == ext {
|
||||
fpaths = append(fpaths, fpath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fpaths, nil
|
||||
}
|
||||
|
||||
func (r *resource) paperSize() ([2]float64, error) {
|
||||
defaultSize := [2]float64{8.27, 11.7}
|
||||
widthStr := r.values[paperWidth]
|
||||
heightStr := r.values[paperHeight]
|
||||
if widthStr == "" || heightStr == "" {
|
||||
return defaultSize, nil
|
||||
}
|
||||
width, err := strconv.ParseFloat(widthStr, 64)
|
||||
if err != nil {
|
||||
return defaultSize, fmt.Errorf("paper width: %v", err)
|
||||
}
|
||||
height, err := strconv.ParseFloat(heightStr, 64)
|
||||
if err != nil {
|
||||
return defaultSize, fmt.Errorf("paper height: %v", err)
|
||||
}
|
||||
return [2]float64{width, height}, nil
|
||||
}
|
||||
|
||||
func (r *resource) paperMargins() ([4]float64, error) {
|
||||
defaultMargins := [4]float64{1, 1, 1, 1}
|
||||
topStr := r.values[marginTop]
|
||||
bottomStr := r.values[marginBottom]
|
||||
leftStr := r.values[marginLeft]
|
||||
rightStr := r.values[marginRight]
|
||||
if topStr == "" || bottomStr == "" || leftStr == "" || rightStr == "" {
|
||||
return defaultMargins, nil
|
||||
}
|
||||
top, err := strconv.ParseFloat(topStr, 64)
|
||||
if err != nil {
|
||||
return defaultMargins, fmt.Errorf("margin top: %v", err)
|
||||
}
|
||||
bottom, err := strconv.ParseFloat(bottomStr, 64)
|
||||
if err != nil {
|
||||
return defaultMargins, fmt.Errorf("margin bottom: %v", err)
|
||||
}
|
||||
left, err := strconv.ParseFloat(leftStr, 64)
|
||||
if err != nil {
|
||||
return defaultMargins, fmt.Errorf("margin left: %v", err)
|
||||
}
|
||||
right, err := strconv.ParseFloat(rightStr, 64)
|
||||
if err != nil {
|
||||
return defaultMargins, fmt.Errorf("margin right: %v", err)
|
||||
}
|
||||
return [4]float64{top, bottom, left, right}, nil
|
||||
}
|
||||
|
||||
func (r *resource) landscape() (bool, error) {
|
||||
landscapeStr := r.values[landscape]
|
||||
if landscapeStr == "" {
|
||||
return false, nil
|
||||
}
|
||||
landscape, err := strconv.ParseBool(landscapeStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("landscape: %v", err)
|
||||
}
|
||||
return landscape, nil
|
||||
}
|
||||
|
||||
func (r *resource) webhookURL() string { return r.values[webhookURL] }
|
||||
func (r *resource) removeAll() error { return os.RemoveAll(r.dirPath) }
|
||||
5
internal/pkg/notify/doc.go
Normal file
5
internal/pkg/notify/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
Package notify is used across the application
|
||||
to display nice outputs to the user.
|
||||
*/
|
||||
package notify
|
||||
30
internal/pkg/notify/notify.go
Normal file
30
internal/pkg/notify/notify.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/labstack/gommon/color"
|
||||
)
|
||||
|
||||
var (
|
||||
stdout *color.Color
|
||||
stderr *color.Color
|
||||
)
|
||||
|
||||
func init() {
|
||||
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) {
|
||||
stderr.Printf("%s\n", color.Red(fmt.Sprintf("⇨ error: %v", err)))
|
||||
}
|
||||
70
internal/pkg/pm2/chrome.go
Normal file
70
internal/pkg/pm2/chrome.go
Normal file
@@ -0,0 +1,70 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Shutdown stops Chrome headless and
|
||||
// removes it from the list of PM2
|
||||
// processes.
|
||||
func (c *Chrome) Shutdown() error {
|
||||
return shutdown(c)
|
||||
}
|
||||
|
||||
func (c *Chrome) getArgs() []string {
|
||||
return []string{
|
||||
"--no-sandbox",
|
||||
"--headless",
|
||||
"--remote-debugging-port=9222",
|
||||
"--disable-gpu",
|
||||
"--disable-translate",
|
||||
"--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--safebrowsing-disable-auto-update",
|
||||
"--disable-sync",
|
||||
"--disable-default-apps",
|
||||
"--hide-scrollbars",
|
||||
"--metrics-recording-only",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Chrome) getName() string {
|
||||
return "google-chrome-stable"
|
||||
}
|
||||
|
||||
func (c *Chrome) getFullname() string {
|
||||
return "Chrome headless"
|
||||
}
|
||||
|
||||
func (c *Chrome) isViable() bool {
|
||||
// check if Chrome is correctly running.
|
||||
devt := devtool.New("http://127.0.0.1:9222")
|
||||
_, err := devt.Create(context.TODO())
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Chrome) warmup() {
|
||||
notify.Println(fmt.Sprintf("warming-up %s", c.getFullname()))
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Process(new(Chrome))
|
||||
)
|
||||
19
internal/pkg/pm2/chrome_test.go
Normal file
19
internal/pkg/pm2/chrome_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package pm2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChromeLaunch(t *testing.T) {
|
||||
p := &Chrome{}
|
||||
err := p.Launch()
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestChromeShutdown(t *testing.T) {
|
||||
p := &Chrome{}
|
||||
err := p.Shutdown()
|
||||
require.Nil(t, err)
|
||||
}
|
||||
12
internal/pkg/pm2/doc.go
Normal file
12
internal/pkg/pm2/doc.go
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Package pm2 facilitates starting external
|
||||
processes on which our API depends.
|
||||
|
||||
For instance, it starts Chrome headless and
|
||||
unoconv listener with PM2.
|
||||
|
||||
The PM2 process manager launch those processes and keep
|
||||
them running in the background. If for some reason they
|
||||
crash, it will also restart them.
|
||||
*/
|
||||
package pm2
|
||||
71
internal/pkg/pm2/pm2.go
Normal file
71
internal/pkg/pm2/pm2.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package pm2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
|
||||
)
|
||||
|
||||
// Process is a type that can launch or
|
||||
// shutdown a process with PM2.
|
||||
type Process interface {
|
||||
Launch() error
|
||||
Shutdown() error
|
||||
getArgs() []string
|
||||
getName() string
|
||||
getFullname() string
|
||||
isViable() bool
|
||||
warmup()
|
||||
}
|
||||
|
||||
const maxRestartAttempts int = 5
|
||||
|
||||
var humanNames = map[string]string{
|
||||
"start": "started",
|
||||
"restart": "restarted",
|
||||
"stop": "stopped",
|
||||
}
|
||||
|
||||
func launch(p Process) error {
|
||||
if err := run(p, "start"); err != nil {
|
||||
return err
|
||||
}
|
||||
p.warmup()
|
||||
if !p.isViable() {
|
||||
attempts := 0
|
||||
for attempts < maxRestartAttempts && !p.isViable() {
|
||||
run(p, "restart")
|
||||
p.warmup()
|
||||
attempts++
|
||||
}
|
||||
if !p.isViable() {
|
||||
return fmt.Errorf("failed to launch %s", p.getFullname())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shutdown(p Process) error {
|
||||
return run(p, "stop")
|
||||
}
|
||||
|
||||
func run(p Process, cmdName string) error {
|
||||
cmdArgs := []string{
|
||||
cmdName,
|
||||
p.getName(),
|
||||
}
|
||||
if cmdName == "start" {
|
||||
cmdArgs = append(cmdArgs, "--interpreter none", "--")
|
||||
cmdArgs = append(cmdArgs, p.getArgs()...)
|
||||
}
|
||||
cmd := exec.Command(
|
||||
"pm2",
|
||||
cmdArgs...,
|
||||
)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("%s %s with PM2: %v", cmdName, p.getFullname(), err)
|
||||
}
|
||||
notify.Println(fmt.Sprintf("%s %s with PM2", p.getFullname(), humanNames[cmdName]))
|
||||
return nil
|
||||
}
|
||||
47
internal/pkg/pm2/unoconv.go
Normal file
47
internal/pkg/pm2/unoconv.go
Normal file
@@ -0,0 +1,47 @@
|
||||
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)
|
||||
}
|
||||
|
||||
// Shutdown stops unoconv listener and
|
||||
// removes it from the list of PM2
|
||||
// processes.
|
||||
func (u *Unoconv) Shutdown() error {
|
||||
return shutdown(u)
|
||||
}
|
||||
|
||||
func (u *Unoconv) getArgs() []string {
|
||||
return []string{
|
||||
"--listener",
|
||||
"--verbose",
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Unoconv) getName() string {
|
||||
return "unoconv"
|
||||
}
|
||||
|
||||
func (u *Unoconv) getFullname() string {
|
||||
return "unoconv listener"
|
||||
}
|
||||
|
||||
func (u *Unoconv) isViable() bool {
|
||||
// TODO find a way to check if
|
||||
// unoconv is correctly started?
|
||||
return true
|
||||
}
|
||||
|
||||
func (u *Unoconv) warmup() {
|
||||
// let's do nothing.
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Process(new(Unoconv))
|
||||
)
|
||||
19
internal/pkg/pm2/unoconv_test.go
Normal file
19
internal/pkg/pm2/unoconv_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package pm2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUnoconvLaunch(t *testing.T) {
|
||||
p := &Unoconv{}
|
||||
err := p.Launch()
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestUnoconvShutdown(t *testing.T) {
|
||||
p := &Unoconv{}
|
||||
err := p.Shutdown()
|
||||
require.Nil(t, err)
|
||||
}
|
||||
52
internal/pkg/printer/doc.go
Normal file
52
internal/pkg/printer/doc.go
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Package printer contains structs which convert
|
||||
a specific file type to PDF:
|
||||
|
||||
// converting HTML to PDF.
|
||||
p := &printer.HTML{
|
||||
Context: context.Background(),
|
||||
PaperWidth: 8.27,
|
||||
PaperHeight: 11.7,
|
||||
Landscape: false,
|
||||
}
|
||||
p.WithLocalURL("index.html")
|
||||
if err := p.Print("result.pdf"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// converting Markdown to PDF:
|
||||
// it assumes here that our template "index.html"
|
||||
// will call toHTML method to convert
|
||||
// markdown files to HTML.
|
||||
p := &printer.Markdown{
|
||||
Context: context.Background(),
|
||||
TemplatePath: "index.html",
|
||||
PaperWidth: 8.27,
|
||||
PaperHeight: 11.7,
|
||||
Landscape: false,
|
||||
}
|
||||
if err := p.Print("result.pdf"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// converting Office documents to PDF:
|
||||
// it converts each files independently and
|
||||
// then merge them.
|
||||
//
|
||||
// Also, as unoconv cannot perform
|
||||
// concurrent conversions, a lock is applied.
|
||||
p := &printer.Office{
|
||||
Context: ctx,
|
||||
FilePaths: []string{"document.docx", "presentation.pptx"}
|
||||
}
|
||||
if err := p.Print("result.pdf"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
It is also able to merge a list of PDF files:
|
||||
|
||||
if err := printer.Merge([]string{"foo.pdf", "bar.pdf"}, "result.pdf"); err != nil {
|
||||
return err
|
||||
}
|
||||
*/
|
||||
package printer
|
||||
188
internal/pkg/printer/html.go
Normal file
188
internal/pkg/printer/html.go
Normal file
@@ -0,0 +1,188 @@
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
// use the DevTools HTTP/JSON API to manage targets (e.g. pages, webworkers).
|
||||
devt := devtool.New("http://127.0.0.1:9222")
|
||||
pt, err := devt.Create(html.Context)
|
||||
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, pt.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 := `new Promise((resolve, reject) => {
|
||||
document.fonts.ready.then(function () {
|
||||
resolve('fonts loaded');
|
||||
});
|
||||
setTimeout(resolve.bind(resolve, 'timeout'), %.0f);
|
||||
});`
|
||||
scriptArg := runtime.NewEvaluateArgs(script).SetAwaitPromise(true)
|
||||
returnObj, _ := c.Runtime.Evaluate(html.Context, scriptArg)
|
||||
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))
|
||||
)
|
||||
39
internal/pkg/printer/html_test.go
Normal file
39
internal/pkg/printer/html_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
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)
|
||||
}
|
||||
111
internal/pkg/printer/markdown.go
Normal file
111
internal/pkg/printer/markdown.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/russross/blackfriday/v2"
|
||||
"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
|
||||
|
||||
html *HTML
|
||||
}
|
||||
|
||||
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
|
||||
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) {
|
||||
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
|
||||
}
|
||||
|
||||
// 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))
|
||||
)
|
||||
39
internal/pkg/printer/markdown_test.go
Normal file
39
internal/pkg/printer/markdown_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
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)
|
||||
}
|
||||
62
internal/pkg/printer/office.go
Normal file
62
internal/pkg/printer/office.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"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
|
||||
}
|
||||
|
||||
// Print converts Office documents to PDF.
|
||||
func (o *Office) Print(destination string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
fpaths := make([]string, len(o.FilePaths))
|
||||
dirPath := filepath.Dir(destination)
|
||||
for i, fpath := range o.FilePaths {
|
||||
baseFilename, err := rand.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpDest := fmt.Sprintf("%s/%s.pdf", dirPath, baseFilename)
|
||||
cmd := exec.CommandContext(
|
||||
o.Context,
|
||||
"unoconv",
|
||||
"--format",
|
||||
"pdf",
|
||||
"--output",
|
||||
tmpDest,
|
||||
fpath,
|
||||
)
|
||||
_, 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)
|
||||
}
|
||||
fpaths[i] = tmpDest
|
||||
}
|
||||
if len(fpaths) == 1 {
|
||||
return os.Rename(fpaths[0], destination)
|
||||
}
|
||||
return Merge(fpaths, destination)
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Printer(new(Office))
|
||||
)
|
||||
31
internal/pkg/printer/office_test.go
Normal file
31
internal/pkg/printer/office_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
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"),
|
||||
},
|
||||
}
|
||||
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)
|
||||
}
|
||||
29
internal/pkg/printer/printer.go
Normal file
29
internal/pkg/printer/printer.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
|
||||
pdfcpuAPI "github.com/hhrutter/pdfcpu/pkg/api"
|
||||
pdfcpuConfig "github.com/hhrutter/pdfcpu/pkg/pdfcpu"
|
||||
)
|
||||
|
||||
// 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 {
|
||||
cmd := pdfcpuAPI.MergeCommand(fpaths, destination, pdfcpuConfig.NewDefaultConfiguration())
|
||||
_, err := pdfcpuAPI.Merge(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeBytesToFile(dst string, b []byte) error {
|
||||
if err := ioutil.WriteFile(dst, b, 0644); err != nil {
|
||||
return fmt.Errorf("%s: writting file: %v", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
27
internal/pkg/printer/printer_test.go
Normal file
27
internal/pkg/printer/printer_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
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.pdf"),
|
||||
},
|
||||
dst,
|
||||
)
|
||||
require.Nil(t, err)
|
||||
require.FileExists(t, dst)
|
||||
err = os.RemoveAll(dirPath)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
7
internal/pkg/rand/doc.go
Normal file
7
internal/pkg/rand/doc.go
Normal file
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
Package rand helps generating a random string.
|
||||
|
||||
It should be used for creating directory and
|
||||
file names in order to avoid collision.
|
||||
*/
|
||||
package rand
|
||||
17
internal/pkg/rand/rand.go
Normal file
17
internal/pkg/rand/rand.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package rand
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Get returns a random string.
|
||||
func Get() (string, error) {
|
||||
randBytes := make([]byte, 16)
|
||||
_, err := rand.Read(randBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating random string: %v", err)
|
||||
}
|
||||
return hex.EncodeToString(randBytes), nil
|
||||
}
|
||||
16
internal/pkg/rand/rand_test.go
Normal file
16
internal/pkg/rand/rand_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package rand
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
rand1, err := Get()
|
||||
require.Nil(t, err)
|
||||
rand2, err := Get()
|
||||
require.Nil(t, err)
|
||||
assert.NotEqual(t, rand1, rand2)
|
||||
}
|
||||
Reference in New Issue
Block a user