Compare commits

...

55 Commits
3.0.1 ... 5.0.0

Author SHA1 Message Date
Julien Neuhart
194670c3bf 5.0.0 (#66) 2019-04-14 17:07:45 +02:00
Jonathan
c4222e0981 Fix documentation url example (#59)
* Fix documentation url example

One \ was forgotten

* fix: doc
2019-03-31 22:16:35 +02:00
Julien Neuhart
61dc4b8bb8 improving documentation about Kubernetes (fixes #40 #53) 2019-03-27 11:44:42 +01:00
Julien Neuhart
df357c1008 fixing typo in documentation 2019-03-26 17:37:25 +01:00
Julien Neuhart
871b52a288 adding filename form field + updating echo package to v4 (#57) 2019-03-26 17:35:38 +01:00
Julien Neuhart
8a4a043e9f fixing documentation about go library 2019-03-15 17:15:47 +01:00
Julien Neuhart
c0297aa061 removing go client library => now available at github.com/thecodingmachine/gotenberg-go-client 2019-03-15 17:07:51 +01:00
Julien Neuhart
2aaa2eeaff Merge branch 'master' of https://github.com/thecodingmachine/gotenberg 2019-03-14 16:39:18 +01:00
Julien Neuhart
5f62e5bacf removing 'v' from tag 2019-03-14 16:39:08 +01:00
Alexander Joseph Trelore
ab78559710 remove unused imports (#55)
Thanks!
2019-03-13 10:53:20 +01:00
Julien Neuhart
0d75bb1d3e improving documentation on merge 2019-03-05 11:27:25 +01:00
Julien Neuhart
6441062e7f adding Google analytics 2019-02-19 15:27:20 +01:00
Julien Neuhart
4827a45f22 adding flat open document support (fixes #47) 2019-02-18 13:46:58 +01:00
Julien Neuhart
40eef457ab adding latest tag (fixes #48) 2019-02-18 13:44:30 +01:00
Julien Neuhart
c22a4d109b minor fix in doc for web fonts 2019-02-01 11:22:06 +01:00
Julien Neuhart
571c3e04c4 adding form field web fonts timeout 2019-02-01 09:40:16 +01:00
Sergei Opletaev
f9fb97a572 Fix for #44 promise wait for fonts (#45)
`runtime.ExceptionDetails: Uncaught exception at 4:47: SyntaxError: Unexpected token %`
2019-02-01 09:21:02 +01:00
Julien Neuhart
e6484bb2d5 Trigger GitHub documentation 2019-01-28 15:56:13 +01:00
Julien Neuhart
19f51fdc1f fixing PHP documentation 2019-01-28 15:36:59 +01:00
Julien Neuhart
25f7ba3ee6 4.0.0 (#42)
* adding URL conversions

* updating gotenberg version in documentation

* pkg: input as variadic string (#39)

* pkg: input as variadic string

* pkg: fix link to github doc

* update doc about v4 client

* make doc

* fixing missing variadic inputs in doc

* adding more fonts

* updating PHP documentation
2019-01-28 15:18:12 +01:00
Julien Neuhart
a3cd1ff4e1 Merge pull request #41 from thecodingmachine/office-paperSize
removing paper size for Office conversion
2019-01-25 15:57:25 +01:00
Julien Neuhart
f62c717f79 fixing typo in docs 2019-01-25 15:50:15 +01:00
Julien Neuhart
3ed99133d4 removing paper size for Office conversion 2019-01-25 15:48:40 +01:00
Julien Neuhart
ab25cc331d fixes #35 2019-01-25 15:02:18 +01:00
Julien Neuhart
2648992a9b adding moar favicons 2019-01-03 16:02:02 +01:00
Julien Neuhart
7bcc3d081e adding liveness section in documentation 2019-01-03 15:56:53 +01:00
Julien Neuhart
affd1c45ca Merge pull request #32 from Peltoche/master
Add a /ping endpoint
2019-01-03 15:37:18 +01:00
Peltoche
8c7458812d Add a /ping endpoint
This endpoint can be used to check the service liveness. It can be used
for the kubernetes liveness and readiness probs.
2019-01-03 15:21:57 +01:00
Julien Neuhart
053908c772 Merge pull request #31 from thecodingmachine/merge-alt
adding PDFtk for merging PDF files < 1.7
2019-01-03 11:10:07 +01:00
Julien Neuhart
d623736f9f rollbacking to debian 9.5 as the 9.6 image has issue with git installation 2019-01-03 11:01:02 +01:00
Julien Neuhart
a3e416ff68 adding PDFtk for merging PDF files < 1.7 2019-01-03 10:06:56 +01:00
Julien Neuhart
8bf207b9f7 improving contributing doc 2018-12-14 16:13:52 +01:00
Julien Neuhart
e1520d5e7e Merge branch 'master' of https://github.com/thecodingmachine/gotenberg 2018-12-14 15:59:59 +01:00
Julien Neuhart
4f89c9f7c6 updating logo and documentation 2018-12-14 15:59:45 +01:00
Ben Davies
d34fb92211 docs: cURL write to a file instead of stdout (#25) 2018-12-14 09:28:18 +01:00
Julien Neuhart
3f7b006d16 fixing docs generation: was adding Dockerfile too.. 2018-12-14 09:15:51 +01:00
Julien Neuhart
a15cf33691 Dockerfiles: removing useless labels. Docs: now generating with Docker. Travis: publishing done with make 2018-12-14 09:13:04 +01:00
Julien Neuhart
4c1699df6c webhook: resulting file was deleted because of defer outside of goroutine 2018-12-13 18:15:28 +01:00
Julien Neuhart
404535c512 fixing/simplifying godoc 2018-12-13 17:06:29 +01:00
Julien Neuhart
2cedcecc38 adding concurrent testing 2018-12-13 14:46:59 +01:00
Julien Neuhart
ba7b02d8d4 fixing various typos in documentation 2018-12-12 17:44:27 +01:00
Julien Neuhart
6aa3c16646 fixing golang client README 2018-12-12 16:44:32 +01:00
Julien Neuhart
5a03e17e00 fixing introduction in documentation 2018-12-12 16:37:36 +01:00
Julien Neuhart
417d145b43 fixing documentation typos 2018-12-12 16:36:21 +01:00
Julien Neuhart
e5636a2afa adding .txt and .rtf files support (#20) 2018-12-12 16:34:34 +01:00
Julien Neuhart
5291d2d129 disabling pdfcpu logs (#22) 2018-12-12 16:24:54 +01:00
Julien Neuhart
c6f6a87499 now using latest version of unoconv, allowing to specify paper size and orientation + updating go client (#24) 2018-12-12 16:16:35 +01:00
Julien Neuhart
1ef21c6ed6 RPC error (#23) 2018-12-12 12:20:07 +01:00
Julien Neuhart
6ee1932cbf improving API tests: now verifying response content type and status code 2018-12-11 14:16:43 +01:00
Julien Neuhart
83cf43a314 fixing cURL commands in documentation 2018-12-11 14:09:04 +01:00
Julien Neuhart
52c14a79a1 fixing GoDoc + some test function names 2018-12-10 20:21:31 +01:00
Julien Neuhart
152db0551d fixing GoDoc links in badges 2018-12-10 20:11:20 +01:00
Julien Neuhart
3a2467ccfe improving documentation 2018-12-10 20:08:45 +01:00
Julien Neuhart
5a28f3597c fixing typos in documentation 2018-12-10 19:59:45 +01:00
Julien Neuhart
9ce179f91b adding PHP examples in documentation 2018-12-10 19:12:54 +01:00
108 changed files with 5603 additions and 2622 deletions

View File

@@ -1,6 +1,6 @@
# Contributing # Contributing
Hi! Thank your for considering contributing to Gotenberg. You'll Hi! Thank you for considering contributing to Gotenberg. You'll
find below useful information about how to contribute to the Gotenberg project. find below useful information about how to contribute to the Gotenberg project.
## Contributing code ## Contributing code
@@ -8,8 +8,9 @@ find below useful information about how to contribute to the Gotenberg project.
### Install from sources ### Install from sources
1. Install and run the latest version of Docker 1. Install and run the latest version of Docker
2. Fork this repository 2. Verify your Go version (>= 1.12)
3. Clone it to your local Go environment (requires *Go* >= 1.11) 3. Fork this repository
4. Clone it outside of your `GOPATH` (we're using Go modules)
### Working with git ### Working with git

View File

@@ -11,13 +11,15 @@ env:
- secure: CmLh169xwt3OC7l19lDUwdZYfw+l/+XXKjTGxnuJiXHa9hAH+nc4mEUSmqHMHwjquyYE6LnSVGZ94LH8sCaGed/TMm0Lj3DZtZ0VsjYu3sKsNrVJn5WdYEah+GvNJ8i8uzvHnGk1L2fAagLpXFUB2K9UraA1CFiHs1fF5Fua7Ra0mE7lMz0MFvyvzcmc9u8nMz71K2YVsXqn+I/rv8onJCTtc/Ds0hVrBXwG5Fh0T3xHqjOhME9ZI2hRHnnQVdBojSY3USifQ//qAKowfBVc0pebEtgFpyXx/I17A6xCm0KVtnA2tNqu1+XFkjWaBzryaLE9ujhJcTRC/YK0JUZ+QxfYbD5C7jVy9Jlx1lOEjJ42B/ImNxPP+9p3wi2MWNbcbZJPxIe635fnT17lI7CLK2Qg1xjfTQ4WTEKW5IhnYMiyfRKiripFh4PCkDm8cJlTbpntsX1n0uheydbsgf/11vS7o1WGhVQ8+Jelc4DvIbBHCqienQEOLJDBwm7FiK5JbwZJ6ZAGeDM/IhGU1h9PNjVbHUyJhUxYoPYUE9QsZFT7ENCrRQpq49KOzXBRfffyJNJRh+Ep9X5YoW0dUp0MNu5B7z2+6w9X9n/C+loa11xF42bFzniCvvbSOQ37J+EngDinVxJkNPeyivR4fye2ENhIAjAEjouTJLOSXL9nAOU= - secure: CmLh169xwt3OC7l19lDUwdZYfw+l/+XXKjTGxnuJiXHa9hAH+nc4mEUSmqHMHwjquyYE6LnSVGZ94LH8sCaGed/TMm0Lj3DZtZ0VsjYu3sKsNrVJn5WdYEah+GvNJ8i8uzvHnGk1L2fAagLpXFUB2K9UraA1CFiHs1fF5Fua7Ra0mE7lMz0MFvyvzcmc9u8nMz71K2YVsXqn+I/rv8onJCTtc/Ds0hVrBXwG5Fh0T3xHqjOhME9ZI2hRHnnQVdBojSY3USifQ//qAKowfBVc0pebEtgFpyXx/I17A6xCm0KVtnA2tNqu1+XFkjWaBzryaLE9ujhJcTRC/YK0JUZ+QxfYbD5C7jVy9Jlx1lOEjJ42B/ImNxPP+9p3wi2MWNbcbZJPxIe635fnT17lI7CLK2Qg1xjfTQ4WTEKW5IhnYMiyfRKiripFh4PCkDm8cJlTbpntsX1n0uheydbsgf/11vS7o1WGhVQ8+Jelc4DvIbBHCqienQEOLJDBwm7FiK5JbwZJ6ZAGeDM/IhGU1h9PNjVbHUyJhUxYoPYUE9QsZFT7ENCrRQpq49KOzXBRfffyJNJRh+Ep9X5YoW0dUp0MNu5B7z2+6w9X9n/C+loa11xF42bFzniCvvbSOQ37J+EngDinVxJkNPeyivR4fye2ENhIAjAEjouTJLOSXL9nAOU=
stages: stages:
- lint - tests
- publish - publish
jobs: jobs:
include: include:
- stage: lint - stage: tests
script: make lint script: make lint
- stage: tests
script: make tests
- stage: publish - stage: publish
if: tag IS present if: tag IS present
script: ./scripts/publish.sh $TRAVIS_TAG $DOCKER_USER $DOCKER_PASS script: make publish VERSION=$TRAVIS_TAG DOCKER_USER=$DOCKER_USER DOCKER_PASSWORD=$DOCKER_PASS

View File

@@ -1,6 +1,6 @@
MIT License MIT License
Copyright (c) 2018 TheCodingMachine Copyright (c) 2019 TheCodingMachine
Permission is hereby granted, free of charge, to any person obtaining a copy Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal of this software and associated documentation files (the "Software"), to deal

View File

@@ -1,13 +1,17 @@
GOLANG_VERSION=1.12
VERSION=snapshot VERSION=snapshot
GOLANG_VERSION=1.11.2 DOCKER_USER=
DOCKER_PASSWORD=
# generate documentation. # generate documentation.
doc: doc:
static-docs --in build/docs --out docs --theme gotenberg --title Gotenberg --subtitle "A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF." docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t thecodingmachine/gotenberg:docs -f build/docs/Dockerfile .
docker run --rm -it -v "$(PWD):/docs" thecodingmachine/gotenberg:docs
# gofmt and goimports all go files. # gofmt and goimports all go files.
fmt: fmt:
go fmt ./... go fmt ./...
go mod tidy
# run all linters. # run all linters.
lint: lint:
@@ -27,4 +31,8 @@ image:
# start the API using previously built Docker image. # start the API using previously built Docker image.
gotenberg: gotenberg:
docker run -it --rm -p "3000:3000" thecodingmachine/gotenberg:$(VERSION) docker run -it --rm -p "3000:3000" thecodingmachine/gotenberg:$(VERSION)
# publish Gotenberg images according to version.
publish:
./scripts/publish.sh $(GOLANG_VERSION) $(VERSION) $(DOCKER_USER) $(DOCKER_PASSWORD)

View File

@@ -1,8 +1,9 @@
<p align="center"> <p align="center">
<img src="https://user-images.githubusercontent.com/8983173/49701110-4c8b9280-fbe8-11e8-895c-a4b9c7d2515b.png" alt="Gotenberg logo" width="250" height="250" /> <img src="https://user-images.githubusercontent.com/8983173/50009948-84b01e00-ffb8-11e8-850b-fc240382c626.png" alt="Gotenberg logo" width="250" height="250" />
</p> </p>
<h3 align="center">Gotenberg</h3> <h3 align="center">Gotenberg</h3>
<p align="center">A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.</p> <p align="center">A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.</p>
<p align="center"><a href="https://thecodingmachine.github.io/gotenberg">Documentation</a> &#183; <a href="/.github/CONTRIBUTING.md">Contributing</a></p>
--- ---
@@ -11,18 +12,18 @@ At TheCodingMachine, we build a lot of web applications (intranets, extranets an
## Features ## Features
* HTML and Markdown conversions using Google Chrome headless * HTML and Markdown conversions using Google Chrome headless
* Office conversions (.docx, .doc, .odt, .pptx, .ppt, .odp and so on) using [unoconv](https://github.com/dagwieers/unoconv) * Office conversions (.txt, .rtf, .docx, .doc, .odt, .pptx, .ppt, .odp and so on) using [unoconv](https://github.com/dagwieers/unoconv)
* Performance :zap:: Google Chrome and Libreoffice (unoconv) started once in the background thanks to PM2 * Performance :zap:: Google Chrome and LibreOffice (unoconv) started once in the background thanks to PM2
* Failure prevention :broken_heart:: PM2 automatically restarts previous processes if they fail * Failure prevention :broken_heart:: PM2 automatically restarts previous processes if they fail
* Assets :package:: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs! * Assets :package:: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs!
* Easily interact with the API using our [Go](/pkg) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) (**WIP**) libraries * Easily interact with the API using our [Go](https://github.com/thecodingmachine/gotenberg-go-client) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries
## Quick start ## Quick start
Open a terminal and run the following command: Open a terminal and run the following command:
```bash ```bash
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:3 $ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:5
``` ```
The API is now available on your host at `http://localhost:3000`. The API is now available on your host at `http://localhost:3000`.
@@ -32,9 +33,9 @@ to learn how to interact with it!
## Badges ## Badges
[![Docker image layers](https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:3.svg)](https://microbadger.com/images/thecodingmachine/gotenberg:3) [![Docker image layers](https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:5.svg)](https://microbadger.com/images/thecodingmachine/gotenberg:5)
[![Travis CI](https://travis-ci.org/thecodingmachine/gotenberg.svg?branch=master)](https://travis-ci.org/thecodingmachine/gotenberg) [![Travis CI](https://travis-ci.org/thecodingmachine/gotenberg.svg?branch=master)](https://travis-ci.org/thecodingmachine/gotenberg)
[![GoDoc](https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg)](https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg) [![GoDoc](https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg)](https://godoc.org/github.com/thecodingmachine/gotenberg)
[![Go Report Card](https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg)](https://goreportcard.com/report/thecodingmachine/gotenberg) [![Go Report Card](https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg)](https://goreportcard.com/report/thecodingmachine/gotenberg)
--- ---

View File

@@ -1,6 +1,5 @@
FROM debian:stretch-slim FROM thecodingmachine/gotenberg:3.2.0 AS hack
FROM debian:9.5-slim
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
# |-------------------------------------------------------------------------- # |--------------------------------------------------------------------------
# | Common libraries # | Common libraries
@@ -11,7 +10,7 @@ LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\ RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
apt-get update &&\ apt-get update &&\
apt-get install -y curl wget apt-get install -y curl wget python3-pip ttf-mscorefonts-installer
# |-------------------------------------------------------------------------- # |--------------------------------------------------------------------------
# | PM2 # | PM2
@@ -21,25 +20,85 @@ RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free"
# | recovering. In our case: Chrome (headless) and Office (headless). # | recovering. In our case: Chrome (headless) and Office (headless).
# | # |
RUN curl -sL https://raw.githubusercontent.com/Unitech/pm2/master/packager/setup.deb.sh | bash - # Yep, this is dirty. The following script does not work anymore (see https://github.com/Unitech/pm2/issues/4127):
#RUN curl -sL https://raw.githubusercontent.com/Unitech/pm2/master/packager/setup.deb.sh | bash -
# Installing PM2 with Node.js and npm breaks something which prevents Google Chrome to work as expected.
# So we just copy all PM2 related files from a previous version of Gotenberg.
RUN curl -sL https://deb.nodesource.com/setup_9.x | bash - &&\
apt-get install -y nodejs
COPY --from=hack /usr/bin/pm2 /usr/bin/pm2
COPY --from=hack /usr/share/pm2 /usr/share/pm2
COPY --from=hack /etc/default/pm2 /etc/default/pm2
COPY --from=hack /etc/systemd/system/pm2.service /etc/systemd/system/pm2.service
# |-------------------------------------------------------------------------- # |--------------------------------------------------------------------------
# | Chrome # | Chrome
# |-------------------------------------------------------------------------- # |--------------------------------------------------------------------------
# | # |
# | Installs Chrome for HTML and Markdown conversions. # | Installs Chrome.
# | # |
RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\ RUN wget -q -O - https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\ echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\
apt-get update &&\ apt-get update &&\
apt-get -y install google-chrome-stable apt-get -y --allow-unauthenticated install google-chrome-stable
# |-------------------------------------------------------------------------- # |--------------------------------------------------------------------------
# | Unoconv # | Unoconv
# |-------------------------------------------------------------------------- # |--------------------------------------------------------------------------
# | # |
# | Installs MS fonts and unoconv for Office documents conversions. # | Installs unoconv and LibreOffice.
# | # |
RUN apt-get -y install ttf-mscorefonts-installer unoconv RUN pip3 install unoconv &&\
# https://github.com/nextcloud/docker/issues/380
mkdir -p /usr/share/man/man1mkdir -p /usr/share/man/man1 &&\
apt-get -y install libreoffice
# |--------------------------------------------------------------------------
# | PDFtk
# |--------------------------------------------------------------------------
# |
# | Installs PDFtk as an alternative to pdfcpu for merging PDFs.
# | https://github.com/thecodingmachine/gotenberg/issues/29
# |
RUN apt-get -y install pdftk
# |--------------------------------------------------------------------------
# | Fonts
# |--------------------------------------------------------------------------
# |
# | Installs a handful of fonts.
# | Note: ttf-mscorefonts-installer are installed on top of this Dockerfile.
# |
# Credits:
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html
RUN apt-get install -y \
culmus \
fonts-beng \
fonts-hosny-amiri \
fonts-lklug-sinhala \
fonts-lohit-guru \
fonts-lohit-knda \
fonts-samyak-gujr \
fonts-samyak-mlym \
fonts-samyak-taml \
fonts-sarai \
fonts-sil-abyssinica \
fonts-sil-padauk \
fonts-telu \
fonts-thai-tlwg \
ttf-liberation \
ttf-wqy-zenhei \
fonts-arphic-uming \
fonts-ipafont-mincho \
fonts-ipafont-gothic \
fonts-unfonts-core
COPY build/base/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf

29
build/base/fonts.conf Normal file
View File

@@ -0,0 +1,29 @@
<?xml version='1.0'?>
<!DOCTYPE fontconfig SYSTEM 'fonts.dtd'>
<fontconfig>
<match target="font">
<edit mode="assign" name="rgba">
<const>rgb</const>
</edit>
</match>
<match target="font">
<edit mode="assign" name="hinting">
<bool>true</bool>
</edit>
</match>
<match target="font">
<edit mode="assign" name="hintstyle">
<const>hintslight</const>
</edit>
</match>
<match target="font">
<edit mode="assign" name="antialias">
<bool>true</bool>
</edit>
</match>
<match target="font">
<edit mode="assign" name="lcdfilter">
<const>lcddefault</const>
</edit>
</match>
</fontconfig>

View File

@@ -1,257 +0,0 @@
---
title: HTML
---
Gotenberg provides the endpoint `/convert/html` for HTML conversions.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
The only requirement is to send a file named `index.html`: it is the file
which will be converted to PDF.
For instance:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My PDF</title>
</head>
<body>
<h1>Hello world!</h1>
</body>
</html>
```
### Guzzle
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := &gotenberg.HTMLRequest{
IndexFilePath: "index.html",
}
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
TODO
## Header and footer
You may also add a header and/or a footer in the resulting PDF.
Respectively, a file named `header.html` and `footer.html`.
Each of them **has to be a complete HTML document**:
```html
<html>
<head>
<style>
body {
font-size: 8rem;
margin: 4rem auto;
}
</style>
</head>
<body>
<p>
<span class="pageNumber"></span> of <span class="totalPages"></span>
</p>
</body>
</html>
```
The following classes allow you to inject printing values:
* `date`: formatted print date
* `title`: document title
* `pageNumber`: current page number
* `totalPage`: total pages in the document
> **Attention:** the CSS properties are independant of the ones used in the `index.html` file.
> Also, `footer.html` CSS properties override the ones from `header.html`.
### Guzzle
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html
--form files=@header.html
--form files=@footer.html
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := &gotenberg.HTMLRequest{
IndexFilePath: "index.html",
Options: &gotenberg.HTMLOptions{
HeaderFilePath: "header.html",
FooterFilePath: "footer.html",
},
}
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
TODO
## Assets
You may also send additional files. For instance, images, fonts, stylesheets and so on.
The only requirement is to make sure that their paths
are on the same level as the `index.html` file.
In others words, this will work:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My PDF</title>
</head>
<body>
<h1>Hello world!</h1>
<img src="img.png">
</body>
</html>
```
But this won't:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My PDF</title>
</head>
<body>
<h1>Hello world!</h1>
<img src="/foo/img.png">
</body>
</html>
```
You may also use *remote* paths for Google fonts, images and so on.
> If you want to install fonts directly in the Gotenberg Docker image,
> see to the [fonts section](#office.fonts).
### Guzzle
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html
--form files=@style.css
--form files=@img.png
--form files=@font.woff
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := &gotenberg.HTMLRequest{
IndexFilePath: "index.html",
AssetFilePaths: []string{
"style.css",
"img.png",
"font.woff",
},
}
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
TODO
## Paper size, margins, orientation
You may also customize the resulting PDF paper.
By default, it will be rendered in `A4` size with `1 inch` margins and `portrait` orientation.
> Paper size and margins have to be provided in `inches`.
> Also, you have to set both `paperWidth` and `paperHeight`. Same for margins.
### Guzzle
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html
--form paperWidth=8.27
--form paperHeight=11.27
--form marginTop=0
--form marginBottom=0
--form marginLeft=0
--form marginRight=0
--form landscape=true
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := &gotenberg.HTMLRequest{
IndexFilePath: "index.html",
Options: &gotenberg.HTMLOptions{
PaperSize: gotenberg.A4,
PaperMargins: gotenberg.NoMargins,
Landscape: true
},
}
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
TODO

View File

@@ -1,72 +0,0 @@
---
title: Office
---
Gotenberg provides the endpoint `/convert/office` for Office documents conversions.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
You may send one or more Office documents. Following file extensions are accepted:
* `.doc`
* `.docx`
* `.odt`
* `.xls`
* `.xlsx`
* `.ods`
* `.ppt`
* `.pptx`
* `.odp`
All files will be merged into a single resulting PDF.
> **Attention:** currently, `unoconv` cannot perform concurrent conversions.
> That's why for Office conversions, the API does only one conversion at a time.
> See the [scalability section](#scalability) to find how to mitigate this issue.
### Guzzle
```bash
$ curl --request POST \
--url http://localhost:3000/convert/office \
--header 'Content-Type: multipart/form-data' \
--form files=@document.docx
--form files=@document2.docx
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := &gotenberg.OfficeRequest{
FilePaths: []string{
"document.docx",
"document2.docx",
},
}
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
TODO
## Fonts
By default, only `ttf-mscorefonts` fonts are installed.
If you wish to use more fonts, you will have to create your own image:
```Dockerfile
FROM thecodingmachine/gotenberg:3
RUN apt-get -y install yourfonts
```

View File

@@ -1,45 +0,0 @@
---
title: Merge
---
Gotenberg provides the endpoint `/merge` for merging PDFs.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
Nothing special here: you may send one or more PDF files and the API
will merge them and return the resulting PDF file.
### Guzzle
```bash
$ curl --request POST \
--url http://localhost:3000/merge \
--header 'Content-Type: multipart/form-data' \
--form files=@file.pdf
--form files=@file2.pdf
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := &gotenberg.MergeRequest{
FilePaths: []string{
"file.pdf",
"file2.pdf",
},
}
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
TODO

View File

@@ -1,45 +0,0 @@
---
title: Webhook
---
All endpoints accept a form field named `webhookURL`.
If provided, the API will send the resulting PDF file in a `POST` request with the `application/pdf` Content-Type
to given URL.
By doing so, your requests to the API will be done before the conversions are actually done!
## Examples
### Guzzle
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html
--form webhookURL='http://myapp.com/webhook/'
> result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := &gotenberg.HTMLRequest{
IndexFilePath: "index.html",
Options: &gotenberg.HTMLOptions{
WebHookURL: "http://myapp.com/webhook/",
},
}
dest := "result.pdf"
resp, err := c.Post(req)
}
```
### PHP
TODO

24
build/docs/Dockerfile Normal file
View File

@@ -0,0 +1,24 @@
ARG GOLANG_VERSION
FROM golang:${GOLANG_VERSION}-stretch
# |--------------------------------------------------------------------------
# | static
# |--------------------------------------------------------------------------
# |
# | Installs the static site anti-framework general-purpose library,
# | purpose-built commands for various domains.
# |
RUN go get github.com/apex/static/cmd/static-docs
# |--------------------------------------------------------------------------
# | Final touch
# |--------------------------------------------------------------------------
# |
# | Last instructions of this build.
# |
WORKDIR /docs
CMD [ "static-docs", "--in", "build/docs/content", "--out", "docs", "--theme", "gotenberg", "--title", "Gotenberg", "--subtitle", "A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF." ]

View File

@@ -5,8 +5,8 @@ title: Introduction
[Gotenberg](https://github.com/thecodingmachine/gotenberg/) is a Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF. [Gotenberg](https://github.com/thecodingmachine/gotenberg/) is a Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.
* HTML and Markdown conversions using Google Chrome headless * HTML and Markdown conversions using Google Chrome headless
* Office conversions (.docx, .doc, .odt, .pptx, .ppt, .odp and so on) using [unoconv](https://github.com/dagwieers/unoconv) * Office conversions (.txt, .rtf, .docx, .doc, .odt, .pptx, .ppt, .odp and so on) using [unoconv](https://github.com/dagwieers/unoconv)
* Performance: Google Chrome and Libreoffice (unoconv) started once in the background thanks to PM2 * Performance: Google Chrome and LibreOffice (unoconv) started once in the background thanks to PM2
* Failure prevention: PM2 automatically restarts previous processes if they fail * Failure prevention: PM2 automatically restarts previous processes if they fail
* Assets: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs! * Assets: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs!
* Easily interact with the API using our [Go](https://github.com/thecodingmachine/gotenberg/pkg) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries * Easily interact with the API using our [Go](https://github.com/thecodingmachine/gotenberg-go-client) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries

View File

@@ -7,12 +7,14 @@ Gotenberg is shipped within a Docker image.
You may start it with: You may start it with:
```bash ```bash
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:3 $ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:5
``` ```
> The API will be available at [http://localhost:3000](http://localhost:3000). > The API will be available at [http://localhost:3000](http://localhost:3000).
Or add it in your Docker Compose stack: ## Docker Compose
You may also add it in your Docker Compose stack:
```yaml ```yaml
version: '3' version: '3'
@@ -22,32 +24,19 @@ services:
# your others services # your others services
gotenberg: gotenberg:
image: thecodingmachine/gotenberg:3 image: thecodingmachine/gotenberg:5
``` ```
> The API will be available under `gotenberg:3000` in your Docker Compose network. > The API will be available under `gotenberg:3000` in your Docker Compose network.
## Kubernetes
It may also be deployed with Kubernetes. It may also be deployed with Kubernetes.
Make sure to provide enough memory and CPU requests (for instance `512Mi` and `0.2` CPU).
Otherwise the API will not be able to launch Google Chrome and LibreOffice (unoconv).
> The more resources are granted, the quicker will be the conversions.
In the following examples, we will assume your In the following examples, we will assume your
Gotenberg API is available at [http://localhost:3000](http://localhost:3000). Gotenberg API is available at [http://localhost:3000](http://localhost:3000).
## Go client
```bash
$ go get -u github.com/thecodingmachine/gotenberg
```
## PHP client
Unless your project already has a PSR7 `HttpClient`, install `php-http/guzzle6-adapter`:
```bash
$ composer require php-http/guzzle6-adapter
```
Then the PHP client:
```bash
$ composer require thecodingmachine/gotenberg-php-client
```

View File

@@ -0,0 +1,25 @@
---
title: Clients
---
We provide clients in various languages for easing the interactions with the API.
## Go client
```bash
$ go get -u github.com/thecodingmachine/gotenberg-go-client/v5
```
## PHP client
Unless your project already has a PSR7 `HttpClient`, install `php-http/guzzle6-adapter`:
```bash
$ composer require php-http/guzzle6-adapter
```
Then the PHP client:
```bash
$ composer require thecodingmachine/gotenberg-php-client
```

View File

@@ -0,0 +1,33 @@
---
title: Environment variables
---
You may customize the API behaviour thanks to environment variables.
## Disable Google Chrome
In order to save some resources, the Gotenberg image accepts the environment variable `DISABLE_GOOGLE_CHROME`.
It takes the strings `"0"` or `"1"` as value.
> If Google Chrome is disabled, the following conversions will **not** be available anymore:
> [HTML](#html), [URL](#url) and [Markdown](#markdown)
## Disable LibreOffice (unoconv)
You may also disable LibreOffice (unoconv) with `DISABLE_UNOCONV`.
> If LibreOffice (unoconv) is disabled, the following conversion will **not** be available anymore:
> [Office](#office)
## Default wait timeout
By default, the API will wait 10 seconds before it considers the conversion to be unsuccessful.
You may customize this timeout thanks to the environment variable `DEFAULT_WAIT_TIMEOUT`.
It takes a string representation of a float as value (e.g `"2.5"` for 2.5 seconds).
> The default timeout may also be overridden per request thanks to the form field `waitTimeout`.
> See the [timeout section](#timeout).

View File

@@ -0,0 +1,342 @@
---
title: HTML
---
Gotenberg provides the endpoint `/convert/html` for HTML conversions.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
The only requirement is to send a file named `index.html`: it is the file
which will be converted to PDF.
For instance:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My PDF</title>
</head>
<body>
<h1>Hello world!</h1>
</body>
</html>
```
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
-o result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$request = new HTMLRequest($index);
$dirPath = "/foo";
$filename = $client->store($request, $dirPath);
```
## Header and footer
You may also add a header and/or a footer in the resulting PDF.
Respectively, a file named `header.html` and `footer.html`.
Each of them **has to be a complete HTML document**:
```html
<html>
<head>
<style>
body {
font-size: 8rem;
margin: 4rem auto;
}
</style>
</head>
<body>
<p>
<span class="pageNumber"></span> of <span class="totalPages"></span>
</p>
</body>
</html>
```
The following classes allow you to inject printing values:
* `date`: formatted print date
* `title`: document title
* `pageNumber`: current page number
* `totalPage`: total pages in the document
> **Attention:** the CSS properties are independant of the ones used in the `index.html` file.
> Also, `footer.html` CSS properties override the ones from `header.html`.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form files=@header.html \
--form files=@footer.html \
-o result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.Header("header.html")
req.Footer("footer.html")
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$header = DocumentFactory::makeFromPath('header.html', 'header.html');
$footer = DocumentFactory::makeFromPath('footer.html', 'footer.html');
$request = new HTMLRequest($index);
$request->setHeader($header);
$request->setFooter($footer);
$dirPath = "/foo";
$filename = $client->store($request, $dirPath);
```
## Assets
You may also send additional files. For instance: images, fonts, stylesheets and so on.
The only requirement is to make sure that their paths
are on the same level as the `index.html` file.
In others words, this will work:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My PDF</title>
</head>
<body>
<h1>Hello world!</h1>
<img src="img.png">
</body>
</html>
```
But this won't:
```html
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>My PDF</title>
</head>
<body>
<h1>Hello world!</h1>
<img src="/foo/img.png">
</body>
</html>
```
You may also use *remote* paths for Google fonts, images and so on.
> If you want to install fonts directly in the Gotenberg Docker image,
> see to the [fonts section](#fonts).
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form files=@style.css \
--form files=@img.png \
--form files=@font.woff \
-o result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.Assets("font.woff", "img.gif", "style.css")
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$assets = [
DocumentFactory::makeFromPath('style.css', 'style.css'),
DocumentFactory::makeFromPath('img.png', 'img.png'),
DocumentFactory::makeFromPath('font.woff', 'font.woff'),
];
$request = new HTMLRequest($index);
$request->setAssets($assets);
$dest = "result.pdf";
$client->store($request, $dest);
```
## Paper size, margins, orientation
You may also customize the resulting PDF format.
By default, it will be rendered with `A4` size, `1 inch` margins and `portrait` orientation.
> Paper size and margins have to be provided in `inches`. Same for margins.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form paperWidth=8.27 \
--form paperHeight=11.27 \
--form marginTop=0 \
--form marginBottom=0 \
--form marginLeft=0 \
--form marginRight=0 \
--form landscape=true \
-o result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.PaperSize(gotenberg.A4)
req.Margins(gotenberg.NoMargins)
req.Landscape(true)
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
use TheCodingMachine\Gotenberg\Request;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$request = new HTMLRequest($index);
$request->setPaperSize(Request::A4);
$request->setMargins(Request::NO_MARGINS);
$request->setLandscape(true);
$dest = "result.pdf";
$client->store($request, $dest);
```
## Wait delay
In some cases, you may want to wait a certain amount of time to make sure the
page you're trying to generate is fully rendered.
> The wait delay is a duration in **seconds** (e.g `2.5` for 2.5 seconds).
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form waitDelay=5.5 \
-o result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.WaitDelay(5.5)
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
use TheCodingMachine\Gotenberg\Request;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$request = new HTMLRequest($index);
$request->setWaitDelay(5.5);
$dest = "result.pdf";
$client->store($request, $dest);
```

View File

@@ -0,0 +1,56 @@
---
title: URL
---
Gotenberg provides the endpoint `/convert/url` for remote URL conversions.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
This endpoint does not accept an `index.html` file nor assets files but a form field
named `remoteURL` instead. Otherwise, URL conversions work the same as HTML conversions.
> **Attention:** when converting a website to PDF, you should remove all margins.
> If not, some of the content of the page might be hidden.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/url \
--header 'Content-Type: multipart/form-data' \
--form remoteURL=https://google.com \
--form marginTop=0 \
--form marginBottom=0 \
--form marginLeft=0 \
--form marginRight=0 \
-o result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := gotenberg.NewURLRequest("https://google.com")
req.Margins(gotenberg.NoMargins)
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\URLRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$request = new URLRequest('https://google.com');
$request->setMargins(Request::NO_MARGINS);
$dest = "result.pdf";
$client->store($request, $dest);
```

View File

@@ -11,7 +11,7 @@ It accepts `POST` requests with a `multipart/form-data` Content-Type.
Markdown conversions work the same as HTML conversions. Markdown conversions work the same as HTML conversions.
Only difference is that you have access to the Go template function `toHTML` Only difference is that you have access to the Go template function `toHTML`
in the file `index.html` which will convert a given markdown file to HTML. in the file `index.html`. This function will convert a given markdown file to HTML.
For instance: For instance:
@@ -28,30 +28,25 @@ For instance:
</html> </html>
``` ```
### Guzzle ### cURL
```bash ```bash
$ curl --request POST \ $ curl --request POST \
--url http://localhost:3000/convert/markdown \ --url http://localhost:3000/convert/markdown \
--header 'Content-Type: multipart/form-data' \ --header 'Content-Type: multipart/form-data' \
--form files=@index.html --form files=@index.html \
--form files=@file.md --form files=@file.md \
> result.pdf -o result.pdf
``` ```
### Go ### Go
```golang ```golang
import "github.com/thecodingmachine/gotenberg/pkg" import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() { func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"} c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := &gotenberg.MarkdownRequest{ req, _ := gotenberg.NewMarkdownRequest("index.html", "file.md")
IndexFilePath: "index.html",
MarkdownFilePaths: []string{
"file.md",
},
}
dest := "result.pdf" dest := "result.pdf"
c.Store(req, dest) c.Store(req, dest)
} }
@@ -59,4 +54,17 @@ func main() {
### PHP ### PHP
TODO ```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\MarkdownRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$markdowns = [
DocumentFactory::makeFromPath('file.md', 'file.md'),
];
$request = new MarkdownRequest($index, $markdowns);
$dest = "result.pdf";
$client->store($request, $dest);
```

View File

@@ -0,0 +1,119 @@
---
title: Office
---
Gotenberg provides the endpoint `/convert/office` for Office document conversions.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
You may send one or more Office documents. Following file extensions are accepted:
* `.txt`
* `.rtf`
* `.fodt`
* `.doc`
* `.docx`
* `.odt`
* `.xls`
* `.xlsx`
* `.ods`
* `.ppt`
* `.pptx`
* `.odp`
All files will be merged into a single resulting PDF.
> **Attention:** currently, `unoconv` cannot perform concurrent conversions.
> That's why for Office conversions, the API does only one conversion at a time.
> See the [scalability section](#scalability) to find how to mitigate this issue.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/office \
--header 'Content-Type: multipart/form-data' \
--form files=@document.docx \
--form files=@document2.docx \
-o result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewOfficeRequest("document.docx", "document2.docx")
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\OfficeRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$files = [
DocumentFactory::makeFromPath('document.docx', 'document.docx'),
DocumentFactory::makeFromPath('document2.docx', 'document2.docx'),
];
$request = new OfficeRequest($files);
$dest = "result.pdf";
$client->store($request, $dest);
```
## Orientation
You may also customize the resulting PDF format.
By default, it will be rendered with `portrait` orientation.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/office \
--header 'Content-Type: multipart/form-data' \
--form files=@document.docx \
--form landscape=true \
-o result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewOfficeRequest("document.docx")
req.Landscape(true)
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\OfficeRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$files = [
DocumentFactory::makeFromPath('document.docx', 'document.docx'),
];
$request = new OfficeRequest($files);
$request->setLandscape(true);
$dest = "result.pdf";
$client->store($request, $dest);
```

View File

@@ -0,0 +1,55 @@
---
title: Merge
---
Gotenberg provides the endpoint `/convert/merge` for merging PDFs.
It accepts `POST` requests with a `multipart/form-data` Content-Type.
## Basic
Nothing fancy here: you may send one or more PDF files and the API
will merge them and return the resulting PDF file.
> **Attention:** Gotenberg merges the PDF files alphabetically.
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/merge \
--header 'Content-Type: multipart/form-data' \
--form files=@file.pdf \
--form files=@file2.pdf \
-o result.pdf
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewMergeRequest("file.pdf", "file2.pdf")
dest := "result.pdf"
c.Store(req, dest)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\MergeRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$files = [
DocumentFactory::makeFromPath('file.pdf', 'file.pdf'),
DocumentFactory::makeFromPath('file2.pdf', 'file2.pdf'),
];
$request = new MergeRequest($files);
$dest = "result.pdf";
$client->store($request, $dest);
```

View File

@@ -0,0 +1,52 @@
---
title: Timeout
---
All endpoints accept a form field named `waitTimeout`.
The API will wait the given **seconds** before it considers the conversion to be unsucessful.
It takes a float as value (e.g `2.5` for 2.5 seconds).
> You may also define this value globally: see the [environment variables](#environment_variables.default_wait_timeout) section.
## Examples
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form waitTimeout=2.5
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.WaitTimeout(2.5)
resp, _ := c.Post(req)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
use TheCodingMachine\Gotenberg\Request;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$request = new HTMLRequest($index);
$request->setWaitTimeout(2.5);
$dest = "result.pdf";
$client->store($request, $dest);
```

View File

@@ -0,0 +1,49 @@
---
title: Webhook
---
All endpoints accept a form field named `webhookURL`.
If provided, the API will send the resulting PDF file in a `POST` request with the `application/pdf` Content-Type
to given URL.
By doing so, your requests to the API will be over before the conversions are actually done!
## Examples
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form webhookURL='http://myapp.com/webhook/'
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.WebhookURL("http://myapp.com/webhook/")
resp, _ := c.Post(req)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$request = new HTMLRequest($index);
$request->setWebhookURL('http://myapp.com/webhook/');
$resp = $client->post($request);
```

View File

@@ -0,0 +1,50 @@
---
title: Result filename
---
All endpoints accept a form field named `resultFilename`.
If provided, the API will return the resulting PDF file with the given filename.
Otherwise a random filename is used.
> **Attention:** this feature does not work if the form field `webhookURL` is given.
## Examples
### cURL
```bash
$ curl --request POST \
--url http://localhost:3000/convert/html \
--header 'Content-Type: multipart/form-data' \
--form files=@index.html \
--form resultFilename='foo.pdf'
```
### Go
```golang
import "github.com/thecodingmachine/gotenberg-go-client/v5"
func main() {
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req, _ := gotenberg.NewHTMLRequest("index.html")
req.ResultFilename("foo.pdf")
resp, _ := c.Post(req)
}
```
### PHP
```php
use TheCodingMachine\Gotenberg\Client;
use TheCodingMachine\Gotenberg\DocumentFactory;
use TheCodingMachine\Gotenberg\HTMLRequest;
use TheCodingMachine\Gotenberg\Request;
$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
$index = DocumentFactory::makeFromPath('index.html', 'index.html');
$request = new HTMLRequest($index);
$request->setResultFilename('foo.pdf');
$resp = $client->post($request);
```

View File

@@ -14,7 +14,7 @@ services:
# your others services # your others services
gotenberg: gotenberg:
image: thecodingmachine/gotenberg:3 image: thecodingmachine/gotenberg:5
``` ```
You may now launch your services using: You may now launch your services using:

View File

@@ -0,0 +1,11 @@
---
title: Ping
---
Gotenberg provides the endpoint `/ping` for checking the API availability with
a simple `GET` request.
This feature is especially useful for liveness/readiness probes in Kubernetes:
* [Pod lifecycle](https://kubernetes.io/docs/concepts/workloads/pods/pod-lifecycle/#container-probes)
* [Configure Liveness and Readiness Probes](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-probes/)

View File

@@ -0,0 +1,13 @@
---
title: Fonts
---
By default, a handful of fonts are installed. Asian characters are also supported out of the box.
If you wish to use more fonts, you will have to create your own image:
```Dockerfile
FROM thecodingmachine/gotenberg:5
RUN apt-get -y install yourfonts
```

View File

@@ -2,8 +2,6 @@ ARG GOLANG_VERSION
FROM golang:${GOLANG_VERSION}-stretch FROM golang:${GOLANG_VERSION}-stretch
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
# |-------------------------------------------------------------------------- # |--------------------------------------------------------------------------
# | GolangCI-Lint # | GolangCI-Lint
# |-------------------------------------------------------------------------- # |--------------------------------------------------------------------------
@@ -12,7 +10,7 @@ LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
# | than gometalinter. # | than gometalinter.
# | # |
ENV GOLANGCI_LINT_VERSION 1.12.3 ENV GOLANGCI_LINT_VERSION 1.16.0
RUN curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b /usr/local/bin v${GOLANGCI_LINT_VERSION} &&\ RUN curl -sfL https://install.goreleaser.com/github.com/golangci/golangci-lint.sh | sh -s -- -b /usr/local/bin v${GOLANGCI_LINT_VERSION} &&\
golangci-lint --version golangci-lint --version
@@ -34,4 +32,4 @@ COPY go.sum .
# Install module dependencies. # Install module dependencies.
RUN go mod download RUN go mod download
CMD ["golangci-lint", "run" ,"--tests=false", "--enable-all", "--disable=dupl", "--disable=lll", "--disable=errcheck", "--disable=gosec", "--disable=gochecknoglobals", "--disable=gochecknoinits" ] CMD ["golangci-lint", "run" ,"--tests=false", "--enable-all", "--disable=dupl" ]

View File

@@ -4,8 +4,6 @@ FROM golang:${GOLANG_VERSION}-stretch AS golang
FROM thecodingmachine/gotenberg:base FROM thecodingmachine/gotenberg:base
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
# |-------------------------------------------------------------------------- # |--------------------------------------------------------------------------
# | Common libraries # | Common libraries
# |-------------------------------------------------------------------------- # |--------------------------------------------------------------------------
@@ -47,4 +45,4 @@ COPY go.sum .
# Install module dependencies. # Install module dependencies.
RUN go mod download RUN go mod download
ENTRYPOINT ["build/tests/docker-entrypoint.sh"] ENTRYPOINT [ "build/tests/docker-entrypoint.sh" ]

View File

@@ -5,20 +5,13 @@ set -xe
# Testing PM2 processes launch separatly for avoiding # Testing PM2 processes launch separatly for avoiding
# spending to much time on each tests depending on # spending to much time on each tests depending on
# them. # them.
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeLaunch go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeStart
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvLaunch go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvStart
# Running others tests. # Running others tests.
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/app/api
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/printer
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/rand go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/rand
# Finally testing processes shutdown. # Finally testing processes shutdown.
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeShutdown go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeShutdown
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvShutdown go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvShutdown
# Testing Go client.
go build -o /usr/local/bin/gotenberg cmd/gotenberg/main.go
gotenberg &
sleep 10
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/pkg

View File

@@ -1,21 +1,125 @@
package main package main
import ( import (
"context"
"fmt" "fmt"
"net/http"
"os" "os"
"os/signal"
"strconv"
"time"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/app/api" "github.com/thecodingmachine/gotenberg/internal/app/api"
"github.com/thecodingmachine/gotenberg/internal/pkg/notify" "github.com/thecodingmachine/gotenberg/internal/pkg/notify"
"github.com/thecodingmachine/gotenberg/internal/pkg/pm2"
) )
// version will be set on build time. // version will be set on build time.
// nolint: gochecknoglobals
var version = "snapshot" var version = "snapshot"
func main() { const (
notify.Println(fmt.Sprintf("Gotenberg %s", version)) defaultWaitTimeoutEnvVar = "DEFAULT_WAIT_TIMEOUT"
if err := api.Start(); err != nil { disableGoogleChromeEnvVar = "DISABLE_GOOGLE_CHROME"
notify.ErrPrintln(err) disableUnoconvEnvVar = "DISABLE_UNOCONV"
)
func mustParseEnvVar() *api.Options {
opts := api.DefaultOptions()
if os.Getenv(defaultWaitTimeoutEnvVar) != "" {
defaultWaitTimeout, err := strconv.ParseFloat(os.Getenv(defaultWaitTimeoutEnvVar), 64)
if err != nil {
notify.ErrPrint(fmt.Errorf("%s: wrong value: want float got %v", defaultWaitTimeoutEnvVar, err))
os.Exit(1)
}
opts.DefaultWaitTimeout = defaultWaitTimeout
}
if v, ok := os.LookupEnv(disableGoogleChromeEnvVar); ok {
if v != "1" && v != "0" {
notify.ErrPrint(fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %v", defaultWaitTimeoutEnvVar, v))
os.Exit(1)
}
opts.EnableChromeEndpoints = v != "1"
}
if v, ok := os.LookupEnv(disableUnoconvEnvVar); ok {
if v != "1" && v != "0" {
notify.ErrPrint(fmt.Errorf("%s: wrong value: want \"0\" or \"1\" got %v", disableUnoconvEnvVar, v))
os.Exit(1)
}
opts.EnableUnoconvEndpoints = v != "1"
}
return opts
}
func mustStartProcesses(opts *api.Options) []pm2.Process {
var processes []pm2.Process
if opts.EnableChromeEndpoints {
processes = append(processes, pm2.NewChrome())
}
if opts.EnableUnoconvEndpoints {
processes = append(processes, pm2.NewUnoconv())
}
for _, p := range processes {
notify.Printf("starting %s with PM2...", p.Fullname())
if err := p.Start(); err != nil {
notify.ErrPrint(err)
os.Exit(1)
}
}
return processes
}
func mustStartAPI(srv *echo.Echo) {
notify.Print("http server started on port 3000")
if err := srv.Start(":3000"); err != nil {
if err != http.ErrServerClosed {
notify.ErrPrint(err)
os.Exit(1)
}
}
}
func mustShutdownProcesses(processes []pm2.Process) {
for _, p := range processes {
notify.Printf("shutting down %s with PM2... (Ctrl+C to force)", p.Fullname())
if err := p.Shutdown(); err != nil {
notify.ErrPrint(err)
os.Exit(1)
}
}
}
func mustShutdownAPI(srv *echo.Echo) {
// 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.Print("shutting down http server... (Ctrl+C to force)")
if err := srv.Shutdown(ctx); err != nil {
notify.ErrPrint(err)
os.Exit(1) os.Exit(1)
} }
}
func main() {
notify.Printf("Gotenberg %s", version)
opts := mustParseEnvVar()
srv := api.New(opts)
processes := mustStartProcesses(opts)
// run our API in a goroutine so that it doesn't block.s
go func() {
mustStartAPI(srv)
}()
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
mustShutdownAPI(srv)
mustShutdownProcesses(processes)
notify.Print("bye!")
os.Exit(0) os.Exit(0)
} }

File diff suppressed because it is too large Load Diff

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

BIN
docs/theme/gotenberg/img/favicon-128.png vendored Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 923 B

After

Width:  |  Height:  |  Size: 917 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.8 KiB

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.3 KiB

After

Width:  |  Height:  |  Size: 34 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 95 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 182 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

View File

@@ -1,12 +1,32 @@
<!DOCTYPE html> <!DOCTYPE html>
<html> <html>
<head> <head>
<!-- Global site tag (gtag.js) - Google Analytics -->
<script async src="https://www.googletagmanager.com/gtag/js?id=UA-10196481-9"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag(){dataLayer.push(arguments);}
gtag('js', new Date());
gtag('config', 'UA-10196481-9');
</script>
<meta charset="utf-8"> <meta charset="utf-8">
<meta http-equiv="x-ua-compatible" content="ie=edge"> <meta http-equiv="x-ua-compatible" content="ie=edge">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{.Title}} &#183; {{.Subtitle}}</title> <title>{{.Title}} &#183; {{.Subtitle}}</title>
<link rel="apple-touch-icon-precomposed" sizes="57x57" href="theme/gotenberg/img/apple-touch-icon-57x57.png" />
<link rel="apple-touch-icon-precomposed" sizes="114x114" href="theme/gotenberg/img/apple-touch-icon-114x114.png" />
<link rel="apple-touch-icon-precomposed" sizes="72x72" href="theme/gotenberg/img/apple-touch-icon-72x72.png" />
<link rel="apple-touch-icon-precomposed" sizes="144x144" href="theme/gotenberg/img/apple-touch-icon-144x144.png" />
<link rel="apple-touch-icon-precomposed" sizes="60x60" href="theme/gotenberg/img/apple-touch-icon-60x60.png" />
<link rel="apple-touch-icon-precomposed" sizes="120x120" href="theme/gotenberg/img/apple-touch-icon-120x120.png" />
<link rel="apple-touch-icon-precomposed" sizes="76x76" href="theme/gotenberg/img/apple-touch-icon-76x76.png" />
<link rel="apple-touch-icon-precomposed" sizes="152x152" href="theme/gotenberg/img/apple-touch-icon-152x152.png" />
<link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-196x196.png" sizes="196x196" />
<link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-96x96.png" sizes="96x96" />
<link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-32x32.png" sizes="32x32" /> <link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-32x32.png" sizes="32x32" />
<link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-16x16.png" sizes="16x16" /> <link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-16x16.png" sizes="16x16" />
<link rel="icon" type="image/png" href="theme/gotenberg/img/favicon-128.png" sizes="128x128" />
<link rel="stylesheet" href="theme/gotenberg/css/index.css"> <link rel="stylesheet" href="theme/gotenberg/css/index.css">
</head> </head>
<body> <body>
@@ -14,7 +34,7 @@
<div class="Container"> <div class="Container">
<div class="Header"> <div class="Header">
<div class="Title center"> <div class="Title center">
<img src="https://user-images.githubusercontent.com/8983173/49701110-4c8b9280-fbe8-11e8-895c-a4b9c7d2515b.png" alt="Gotenberg logo" width="250" height="250" /> <img src="https://user-images.githubusercontent.com/8983173/50009948-84b01e00-ffb8-11e8-850b-fc240382c626.png" alt="Gotenberg logo" width="250" height="250" />
<span class="text">{{.Title}}</span> <span class="text">{{.Title}}</span>
<span class="subtext">{{.Subtitle}}</span> <span class="subtext">{{.Subtitle}}</span>
</div> </div>

24
go.mod
View File

@@ -1,21 +1,23 @@
module github.com/thecodingmachine/gotenberg module github.com/thecodingmachine/gotenberg
go 1.12
require ( require (
github.com/derekparker/delve v1.1.0 github.com/davecgh/go-spew v1.1.1 // indirect
github.com/google/go-cmp v0.2.0 // indirect github.com/google/go-cmp v0.2.0 // indirect
github.com/gorilla/websocket v1.4.0 // indirect github.com/gorilla/websocket v1.4.0 // indirect
github.com/hhrutter/pdfcpu v0.1.18 github.com/labstack/echo/v4 v4.0.0
github.com/labstack/echo v0.0.0-20181123063703-c7eb8da9ec73
github.com/labstack/gommon v0.2.8 github.com/labstack/gommon v0.2.8
github.com/mafredri/cdp v0.20.0 github.com/mafredri/cdp v0.22.0
github.com/mattn/go-colorable v0.1.1 // indirect
github.com/mattn/go-isatty v0.0.7 // indirect
github.com/microcosm-cc/bluemonday v1.0.1 github.com/microcosm-cc/bluemonday v1.0.1
github.com/pkg/errors v0.8.0 // indirect
github.com/russross/blackfriday/v2 v2.0.1 github.com/russross/blackfriday/v2 v2.0.1
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect
github.com/stretchr/objx v0.1.1 // indirect github.com/stretchr/testify v1.3.0
github.com/stretchr/testify v1.2.2 github.com/valyala/fasttemplate v1.0.1 // indirect
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 // indirect golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c // indirect
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc golang.org/x/net v0.0.0-20181201002055-351d144fa1fc // indirect
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect golang.org/x/sync v0.0.0-20181108010431-42b317875d0f
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35 // indirect golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc // indirect
) )

46
go.sum
View File

@@ -1,53 +1,53 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/derekparker/delve v1.1.0 h1:icd65nMp7s2HiLz6y/6RCVXBdoED3xxYLwX09EMaRCc=
github.com/derekparker/delve v1.1.0/go.mod h1:pMSZMfp0Nhbm8qdZJkuE/yPGOkLpGXLS1I4poXQpuJU=
github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM= github.com/dgrijalva/jwt-go v3.2.0+incompatible h1:7qlOGliEKZXTDg6OTjfoBKDXWrumCAMpl/TFQ4/5kLM=
github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ= github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ= github.com/google/go-cmp v0.2.0 h1:+dTQ8DZQJz0Mb/HjFlkptS1FeQ4cWSnN941F8aEG4SQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M= github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q= github.com/gorilla/websocket v1.4.0 h1:WDFjx/TMzVgy9VdMMQi2K2Emtwi2QcUQsztZ/zLaH/Q=
github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ= github.com/gorilla/websocket v1.4.0/go.mod h1:E7qHFY5m1UJ88s3WnNqhKjPHQ0heANvMoAMk2YaljkQ=
github.com/hhrutter/pdfcpu v0.1.18 h1:GX3d6tZxmQlmB5X9VUyOtx7+HZZpR+8ISLqBineP6dc= github.com/labstack/echo/v4 v4.0.0 h1:q1GH+caIXPP7H2StPIdzy/ez9CO0EepqYeUg6vi9SWM=
github.com/hhrutter/pdfcpu v0.1.18/go.mod h1:iaCmXGnOXPIvoGLELZZc4m/GpIopVsvLQ00thrbu8LU= github.com/labstack/echo/v4 v4.0.0/go.mod h1:tZv7nai5buKSg5h/8E6zz4LsD/Dqh9/91Mvs7Z5Zyno=
github.com/labstack/echo v0.0.0-20181123063703-c7eb8da9ec73 h1:oQvA3Xy8274W5Ldgo6Nogy4lX8XO5AaIBsjF5Ggk0IU=
github.com/labstack/echo v0.0.0-20181123063703-c7eb8da9ec73/go.mod h1:rS0D1UPvC8/3sXjhSwEq+K1olh7ipbDhjDWATN2KSgA=
github.com/labstack/echo v3.3.5+incompatible h1:9PfxPUmasKzeJor9uQTaXLT6WUG/r+vSTmvXxvv3JO4=
github.com/labstack/echo v3.3.5+incompatible/go.mod h1:0INS7j/VjnFxD4E2wkz67b8cVwCLbBmJyDaka6Cmk1s=
github.com/labstack/gommon v0.2.7/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0= github.com/labstack/gommon v0.2.8 h1:JvRqmeZcfrHC5u6uVleB4NxxNbzx6gpbJiQknDbKQu0=
github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4= github.com/labstack/gommon v0.2.8/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
github.com/mafredri/cdp v0.20.0 h1:dRq16FwXCAAbolR4W2iyRNsL14Bp6jFHLbRp2EkICVs= github.com/mafredri/cdp v0.22.0 h1:BV17j8hXLDWczo2SZIAFuOjMpQMIOq5DOcd9sgB2hv0=
github.com/mafredri/cdp v0.20.0/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE= github.com/mafredri/cdp v0.22.0/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE=
github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4= github.com/mattn/go-colorable v0.0.9 h1:UVL0vNpWh04HeJXV0KLcaT7r06gOH2l4OW6ddYRUIY4=
github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU= github.com/mattn/go-colorable v0.0.9/go.mod h1:9vuHe8Xs5qXnSaW/c/ABM9alt+Vo+STaOChaDxuIBZU=
github.com/mattn/go-colorable v0.1.1 h1:G1f5SKeVxmagw/IyvzvtZE4Gybcc4Tr1tf7I8z0XgOg=
github.com/mattn/go-colorable v0.1.1/go.mod h1:FuOcm+DKB9mbwrcAfNl7/TZVBZ6rcnceauSikq3lYCQ=
github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs= github.com/mattn/go-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4= github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
github.com/mattn/go-isatty v0.0.5/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/mattn/go-isatty v0.0.7 h1:UvyT9uN+3r7yLEYSlJsbQGdsaB/a0DlgWP3pql6iwOc=
github.com/mattn/go-isatty v0.0.7/go.mod h1:Iq45c/XA43vh69/j3iqttzPXn0bhXyGjM0Hdxcsrc5s=
github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44= github.com/microcosm-cc/bluemonday v1.0.1 h1:SIYunPjnlXcW+gVfvm0IlSeR5U3WZUOLfVmqg85Go44=
github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4= github.com/microcosm-cc/bluemonday v1.0.1/go.mod h1:hsXNsILzKxV+sX77C5b8FSuKF00vh2OMYv+xgHpAMF4=
github.com/pkg/errors v0.8.0 h1:WdK/asTD0HN+q6hsWO3/vpuAkAr+tw6aNJNDFFf0+qw=
github.com/pkg/errors v0.8.0/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q= github.com/russross/blackfriday/v2 v2.0.1 h1:lPqVAte+HuHNfhJ/0LC98ESWRz8afy9tM/0RK8m9o+Q=
github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.0.1/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 h1:/vdW8Cb7EXrkqWGufVMES1OH2sU9gKVb2n9/1y5NMBY= github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 h1:/vdW8Cb7EXrkqWGufVMES1OH2sU9gKVb2n9/1y5NMBY=
github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc= github.com/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.3.0 h1:TivCn/peBQ7UY8ooIcPgZFpTNSz0Q2U6UrFlUfqbe0Q=
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs=
github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8= github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4 h1:gKMu1Bf6QINDnvyZuTaACm9ofY+PRh+5vFz4oxBZeF8=
github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw= github.com/valyala/fasttemplate v0.0.0-20170224212429-dcecefd839c4/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw=
golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= github.com/valyala/fasttemplate v1.0.1 h1:tY9CJiPnMXf1ERmG2EyK7gNUd+c6RKGD0IfU8WdUSz8=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0= github.com/valyala/fasttemplate v1.0.1/go.mod h1:UQGH1tvbgY+Nz5t2n7tXsz52dQxojPUpymEIMZ47gx8=
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4= golang.org/x/crypto v0.0.0-20190130090550-b01c7a725664/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c h1:Vj5n4GlwjmQteupaxJ9+0FNOmBrHfq7vN4btdGoDZgI=
golang.org/x/crypto v0.0.0-20190325154230-a5d413f7728c/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc h1:a3CU5tJYVj92DY2LaA1kUkrsqD5/3mLDhx2NcNqyW+0= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc h1:a3CU5tJYVj92DY2LaA1kUkrsqD5/3mLDhx2NcNqyW+0=
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4= golang.org/x/net v0.0.0-20181201002055-351d144fa1fc/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f h1:Bl/8QSvNqXvPGPGXa2z5xUTmV7VDcZyvRZ+QQXkXTZQ=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20181107165924-66b7b1311ac8/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190129075346-302c3dd5f1cc/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35 h1:YAFjXN64LMvktoUZH9zgY4lGc/msGN7HQfoSuKCgaDU= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190222072716-a9d3bda3a223/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc h1:4gbWbmmPFp4ySWICouJl6emP0MyS31yy9SrTlAGFT+g=
golang.org/x/sys v0.0.0-20190322080309-f49334f85ddc/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=

View File

@@ -1,125 +1,48 @@
package api package api
import ( import (
"context" "github.com/labstack/echo/v4"
"fmt" "github.com/labstack/echo/v4/middleware"
"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. // Options allows to customize the behaviour
func Start() error { // of the API.
e := setup() type Options struct {
// start Chrome headless and DefaultWaitTimeout float64
// unoconv listener with PM2. EnableChromeEndpoints bool
chrome := &pm2.Chrome{} EnableUnoconvEndpoints bool
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 { // DefaultOptions returns default options.
e := echo.New() func DefaultOptions() *Options {
e.HideBanner = true return &Options{
e.HidePort = true DefaultWaitTimeout: 10,
e.Use(middleware.Logger()) EnableChromeEndpoints: true,
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc { EnableUnoconvEndpoints: true,
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) { // New returns an API.
webhookURL := r.webhookURL() func New(opts *Options) *echo.Echo {
if webhookURL == "" { api := echo.New()
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) api.HideBanner = true
return ctx, cancel api.HidePort = true
api.Use(middleware.Logger())
api.GET("/ping", func(c echo.Context) error { return nil })
g := api.Group("/convert")
g.Use(handleContext(opts))
g.Use(handleError())
g.POST("/merge", merge)
if !opts.EnableChromeEndpoints && !opts.EnableUnoconvEndpoints {
return api
} }
return context.Background(), nil if opts.EnableChromeEndpoints {
} g.POST("/html", convertHTML)
g.POST("/url", convertURL)
func print(c echo.Context, p printer.Printer, r *resource) error { g.POST("/markdown", convertMarkdown)
baseFilename, err := rand.Get() }
if err != nil { if opts.EnableUnoconvEndpoints {
return fmt.Errorf("getting result file name: %v", err) g.POST("/office", convertOffice)
} }
filename := fmt.Sprintf("%s.pdf", baseFilename) return api
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
} }

View File

@@ -0,0 +1,128 @@
package api
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/test"
)
func TestDefaultWaitTimeout(t *testing.T) {
opts := DefaultOptions()
opts.DefaultWaitTimeout = 0
srv := New(opts)
// testing if timeout.
body, contentType := test.URLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
// testing if no timeout.
body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "10"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
}
func TestDisableChromeEndpoints(t *testing.T) {
opts := DefaultOptions()
opts.EnableChromeEndpoints = false
srv := New(opts)
// Ping.
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Merge.
body, contentType := test.PDFTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// HTML.
body, contentType = test.HTMLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// Markdown.
body, contentType = test.MarkdownTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// URL.
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// Office.
body, contentType = test.OfficeTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
}
func TestDisableUnoconvEndpoints(t *testing.T) {
opts := DefaultOptions()
opts.EnableUnoconvEndpoints = false
srv := New(opts)
// Ping.
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Merge.
body, contentType := test.PDFTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// HTML.
body, contentType = test.HTMLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Markdown.
body, contentType = test.MarkdownTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// URL.
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Office.
body, contentType = test.OfficeTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
}
func TestDisableChromeAndUnoconvEndpoints(t *testing.T) {
opts := DefaultOptions()
opts.EnableChromeEndpoints = false
opts.EnableUnoconvEndpoints = false
srv := New(opts)
// Ping.
req := httptest.NewRequest(http.MethodGet, "/ping", nil)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Merge.
body, contentType := test.PDFTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// HTML.
body, contentType = test.HTMLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// Markdown.
body, contentType = test.MarkdownTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// URL.
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
// Office.
body, contentType = test.OfficeTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusNotFound, srv, req)
}

View File

@@ -1,9 +1,2 @@
/* // Package api helps managing the HTTP server behind Gotenberg.
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 package api

157
internal/app/api/handler.go Normal file
View File

@@ -0,0 +1,157 @@
package api
import (
"fmt"
"net/http"
"os"
"github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
)
type errBadRequest struct {
err error
}
func (e *errBadRequest) Error() string {
return e.err.Error()
}
func merge(c echo.Context) error {
ctx := c.(*resourceContext)
opts, err := ctx.resource.mergePrinterOptions()
if err != nil {
return &errBadRequest{err}
}
fpaths, err := ctx.resource.fpaths(".pdf")
if err != nil {
return &errBadRequest{err}
}
p := printer.NewMerge(fpaths, opts)
return convert(ctx, p)
}
func convertHTML(c echo.Context) error {
ctx := c.(*resourceContext)
opts, err := ctx.resource.chromePrinterOptions()
if err != nil {
return &errBadRequest{err}
}
fpath, err := ctx.resource.fpath("index.html")
if err != nil {
return &errBadRequest{err}
}
p := printer.NewHTML(fpath, opts)
return convert(ctx, p)
}
func convertMarkdown(c echo.Context) error {
ctx := c.(*resourceContext)
opts, err := ctx.resource.chromePrinterOptions()
if err != nil {
return &errBadRequest{err}
}
fpath, err := ctx.resource.fpath("index.html")
if err != nil {
return &errBadRequest{err}
}
p, err := printer.NewMarkdown(fpath, opts)
if err != nil {
return err
}
return convert(ctx, p)
}
func convertURL(c echo.Context) error {
ctx := c.(*resourceContext)
opts, err := ctx.resource.chromePrinterOptions()
if err != nil {
return &errBadRequest{err}
}
remote, err := ctx.resource.get(remoteURL)
if err != nil {
return &errBadRequest{err}
}
p := printer.NewURL(remote, opts)
return convert(ctx, p)
}
func convertOffice(c echo.Context) error {
ctx := c.(*resourceContext)
opts, err := ctx.resource.officePrinterOptions()
if err != nil {
return &errBadRequest{err}
}
fpaths, err := ctx.resource.fpaths(
".txt",
".rtf",
".fodt",
".doc",
".docx",
".odt",
".xls",
".xlsx",
".ods",
".ppt",
".pptx",
".odp",
)
if err != nil {
return &errBadRequest{err}
}
p := printer.NewOffice(fpaths, opts)
return convert(ctx, p)
}
func convert(ctx *resourceContext, p printer.Printer) error {
baseFilename, err := rand.Get()
if err != nil {
return err
}
filename := fmt.Sprintf("%s.pdf", baseFilename)
fpath := fmt.Sprintf("%s/%s", ctx.resource.formFilesDirPath, filename)
// if no webhook URL given, run conversion
// and directly return the resulting PDF file
// or an error.
if !ctx.resource.has(webhookURL) {
if err := p.Print(fpath); err != nil {
return err
}
if ctx.resource.has(resultFilename) {
filename, err = ctx.resource.get(resultFilename)
if err != nil {
return &errBadRequest{err}
}
}
return ctx.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() {
defer ctx.resource.close() // nolint: errcheck
if err := p.Print(fpath); err != nil {
ctx.Logger().Error(err)
return
}
f, err := os.Open(fpath)
if err != nil {
ctx.Logger().Error(err)
return
}
defer f.Close() // nolint: errcheck
webhook, err := ctx.resource.get(webhookURL)
if err != nil {
ctx.Logger().Error(err)
return
}
resp, err := http.Post(webhook, "application/pdf", f) /* #nosec */
if err != nil {
ctx.Logger().Error(err)
return
}
defer resp.Body.Close() // nolint: errcheck
}()
return nil
}

View File

@@ -0,0 +1,360 @@
package api
import (
"errors"
"fmt"
"io/ioutil"
"net/http"
"net/http/httptest"
"testing"
"github.com/labstack/echo/v4"
"github.com/stretchr/testify/assert"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMerge(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
// OK.
body, contentType := test.PDFTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Bad request.
body, contentType = test.PDFTestMultipartForm(t, map[string]string{waitTimeout: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// Timeout.
body, contentType = test.PDFTestMultipartForm(t, map[string]string{waitTimeout: "0"})
req = httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
}
func TestHTML(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
// OK.
body, contentType := test.HTMLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Bad request.
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitTimeout: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitDelay: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{paperWidth: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{paperHeight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginTop: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginBottom: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginLeft: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{marginRight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{landscape: "not a bool"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// Timeout.
body, contentType = test.HTMLTestMultipartForm(t, map[string]string{waitTimeout: "0"})
req = httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
}
func TestMarkdown(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
// OK.
body, contentType := test.MarkdownTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Bad request.
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitTimeout: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitDelay: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{paperWidth: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{paperHeight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginTop: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginBottom: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginLeft: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{marginRight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{landscape: "not a bool"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// Timeout.
body, contentType = test.MarkdownTestMultipartForm(t, map[string]string{waitTimeout: "0"})
req = httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
}
func TestURL(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
// OK.
body, contentType := test.URLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Bad request.
body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{waitDelay: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{paperWidth: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{paperHeight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{marginTop: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{marginBottom: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{marginLeft: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{marginRight: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, map[string]string{landscape: "not a bool"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// Timeout.
body, contentType = test.URLTestMultipartForm(t, map[string]string{waitTimeout: "0"})
req = httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
}
func TestOffice(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
// OK.
body, contentType := test.OfficeTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
// Bad request.
body, contentType = test.OfficeTestMultipartForm(t, map[string]string{waitTimeout: "not a float"})
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.OfficeTestMultipartForm(t, map[string]string{landscape: "not a bool"})
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
body, contentType = test.URLTestMultipartForm(t, nil)
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusBadRequest, srv, req)
// Timeout.
body, contentType = test.OfficeTestMultipartForm(t, map[string]string{waitTimeout: "0"})
req = httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusRequestTimeout, srv, req)
}
func TestConcurrent(t *testing.T) {
opts := DefaultOptions()
opts.DefaultWaitTimeout = 30
srv := New(opts)
// Merge.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.MarkdownTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code)
}
return nil
},
10,
)
// HTML.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.HTMLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/html", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code)
}
return nil
},
10,
)
// Markdown.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.MarkdownTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/markdown", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code)
}
return nil
},
10,
)
// URL.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.URLTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/url", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code)
}
return nil
},
10,
)
// Office.
test.AssertConcurrent(
t,
func() error {
body, contentType := test.OfficeTestMultipartForm(t, nil)
req := httptest.NewRequest(http.MethodPost, "/convert/office", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
return fmt.Errorf("wrong status code: want %d got %d", http.StatusOK, rec.Code)
}
return nil
},
10,
)
}
func TestWebhook(t *testing.T) {
status := make(chan error, 2)
rcv := echo.New()
rcv.POST("/foo", func(c echo.Context) error {
if c.Request().Header.Get("Content-type") != "application/pdf" {
status <- fmt.Errorf("wrong Content-type: got %s want %s", c.Request().Header.Get("Content-type"), "application/pdf")
return nil
}
body, err := ioutil.ReadAll(c.Request().Body)
if err != nil {
status <- err
return nil
}
if body == nil || len(body) == 0 {
status <- errors.New("empty body")
return nil
}
status <- nil
return nil
})
go func() {
rcv.Start(":3001")
}()
opts := DefaultOptions()
srv := New(opts)
body, contentType := test.PDFTestMultipartForm(t, map[string]string{webhookURL: "http://localhost:3001/foo"})
req := httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
test.AssertStatusCode(t, http.StatusOK, srv, req)
err := <-status
assert.NoError(t, err)
}
func TestResultFilename(t *testing.T) {
opts := DefaultOptions()
srv := New(opts)
body, contentType := test.PDFTestMultipartForm(t, map[string]string{resultFilename: "foo.pdf"})
req := httptest.NewRequest(http.MethodPost, "/convert/merge", body)
req.Header.Set(echo.HeaderContentType, contentType)
rec := httptest.NewRecorder()
srv.ServeHTTP(rec, req)
assert.Equal(t, "attachment; filename=\"foo.pdf\"", rec.Header().Get("Content-Disposition"))
}

View File

@@ -1,52 +0,0 @@
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)
}

View File

@@ -1,21 +0,0 @@
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))
}

View File

@@ -1,51 +0,0 @@
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)
}

View File

@@ -1,21 +0,0 @@
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))
}

View File

@@ -1,60 +0,0 @@
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)
fpath := 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.
if err := printer.Merge(fpaths, 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 := printer.Merge(fpaths, 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
}

View File

@@ -1,21 +0,0 @@
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))
}

View File

@@ -0,0 +1,61 @@
package api
import (
"context"
"net/http"
"strings"
"github.com/labstack/echo/v4"
)
func handleContext(opts *Options) echo.MiddlewareFunc {
// middleware for extending default context with our
// custom constext.
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
ctx := &resourceContext{c, opts, nil}
r, err := newResource(ctx)
if err != nil {
if resourceErr := r.close(); resourceErr != nil {
c.Logger().Error(resourceErr)
}
return err
}
ctx.resource = r
return next(ctx)
}
}
}
func handleError() echo.MiddlewareFunc {
// middleware for handling errors and removing resources
// once the request has been handled.
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
err := next(c)
ctx := c.(*resourceContext)
// if a webhookURL has been given,
// do not remove the resources here because
// we don't know if the result file has been
// generated or sent.
if !ctx.resource.has(webhookURL) {
if resourceErr := ctx.resource.close(); resourceErr != nil {
c.Logger().Error(resourceErr)
}
}
if err != nil {
if _, ok := err.(*echo.HTTPError); ok {
return err
}
if _, ok := err.(*errBadRequest); ok {
return echo.NewHTTPError(http.StatusBadRequest, err.Error())
}
if strings.Contains(err.Error(), context.DeadlineExceeded.Error()) {
return echo.NewHTTPError(http.StatusRequestTimeout)
}
return echo.NewHTTPError(http.StatusInternalServerError, err.Error())
}
return nil
}
}
}

View File

@@ -1,41 +0,0 @@
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)
}

View File

@@ -1,21 +0,0 @@
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))
}

View File

@@ -3,93 +3,262 @@ package api
import ( import (
"fmt" "fmt"
"io" "io"
"io/ioutil"
"os" "os"
"path/filepath" "path/filepath"
"strconv" "strconv"
"github.com/labstack/echo" "github.com/labstack/echo/v4"
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand" "github.com/thecodingmachine/gotenberg/internal/pkg/rand"
) )
const ( const (
webhookURL string = "webhookURL" resultFilename string = "resultFilename"
paperWidth string = "paperWidth" waitTimeout string = "waitTimeout"
paperHeight string = "paperHeight" webhookURL string = "webhookURL"
marginTop string = "marginTop" remoteURL string = "remoteURL"
marginBottom string = "marginBottom" waitDelay string = "waitDelay"
marginLeft string = "marginLeft" paperWidth string = "paperWidth"
marginRight string = "marginRight" paperHeight string = "paperHeight"
landscape string = "landscape" 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 { type resource struct {
values map[string]string formValues map[string]string
dirPath string formFilesDirPath string
opts *Options
} }
func newResource(c echo.Context) (*resource, error) { type resourceContext struct {
v := make(map[string]string) echo.Context
v[webhookURL] = c.FormValue(webhookURL) opts *Options
v[paperWidth] = c.FormValue(paperWidth) resource *resource
v[paperHeight] = c.FormValue(paperHeight) }
v[marginTop] = c.FormValue(marginTop)
v[marginBottom] = c.FormValue(marginBottom) func newResource(ctx *resourceContext) (*resource, error) {
v[marginLeft] = c.FormValue(marginLeft) r := &resource{
v[marginRight] = c.FormValue(marginRight) formValues: formValues(ctx),
v[landscape] = c.FormValue(landscape) opts: ctx.opts,
}
dirPath, err := rand.Get() dirPath, err := rand.Get()
if err != nil { if err != nil {
return nil, err return r, err
} }
r.formFilesDirPath = dirPath
if err := os.MkdirAll(dirPath, 0755); err != nil { if err := os.MkdirAll(dirPath, 0755); err != nil {
return nil, fmt.Errorf("%s: making directory: %v", dirPath, err) return nil, fmt.Errorf("%s: making directory: %v", dirPath, err)
} }
r := &resource{values: v, dirPath: dirPath} if err := formFiles(ctx, dirPath); err != nil {
form, err := c.MultipartForm() return r, err
}
return r, nil
}
func formValues(ctx *resourceContext) map[string]string {
v := make(map[string]string)
v[resultFilename] = ctx.FormValue(resultFilename)
v[waitTimeout] = ctx.FormValue(waitTimeout)
v[webhookURL] = ctx.FormValue(webhookURL)
v[remoteURL] = ctx.FormValue(remoteURL)
v[waitDelay] = ctx.FormValue(waitDelay)
v[paperWidth] = ctx.FormValue(paperWidth)
v[paperHeight] = ctx.FormValue(paperHeight)
v[marginTop] = ctx.FormValue(marginTop)
v[marginBottom] = ctx.FormValue(marginBottom)
v[marginLeft] = ctx.FormValue(marginLeft)
v[marginRight] = ctx.FormValue(marginRight)
v[landscape] = ctx.FormValue(landscape)
return v
}
func formFiles(ctx *resourceContext, dirPath string) error {
form, err := ctx.MultipartForm()
if err != nil { if err != nil {
return nil, fmt.Errorf("getting multipart form: %v", err) return fmt.Errorf("getting multipart form: %v", err)
} }
for _, files := range form.File { for _, files := range form.File {
for _, fh := range files { for _, fh := range files {
in, err := fh.Open() in, err := fh.Open()
if err != nil { if err != nil {
return nil, fmt.Errorf("%s: opening file: %v", fh.Filename, err) return fmt.Errorf("%s: opening file: %v", fh.Filename, err)
} }
defer in.Close() defer in.Close() // nolint: errcheck
if err := r.writeFile(fh.Filename, in); err != nil { fpath := fmt.Sprintf("%s/%s", dirPath, fh.Filename)
return nil, err out, err := os.Create(fpath)
if err != nil {
return fmt.Errorf("%s: creating new file: %v", fpath, err)
}
defer out.Close() // nolint: errcheck
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 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 return nil
} }
func (r *resource) filePath(filename string) (string, error) { func (r *resource) close() error {
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename) if _, err := os.Stat(r.formFilesDirPath); os.IsNotExist(err) {
return nil
}
return os.RemoveAll(r.formFilesDirPath)
}
const defaultHeaderFooterHTML string = "<html><head></head><body></body></html>"
func (r *resource) chromePrinterOptions() (*printer.ChromeOptions, error) {
timeout, err := r.float64(waitTimeout, r.opts.DefaultWaitTimeout)
if err != nil {
return nil, err
}
delay, err := r.float64(waitDelay, 0.0)
if err != nil {
return nil, err
}
header, err := r.content("header.html", defaultHeaderFooterHTML)
if err != nil {
return nil, err
}
footer, err := r.content("footer.html", defaultHeaderFooterHTML)
if err != nil {
return nil, err
}
width, err := r.float64(paperWidth, 8.27)
if err != nil {
return nil, err
}
height, err := r.float64(paperHeight, 11.7)
if err != nil {
return nil, err
}
top, err := r.float64(marginTop, 1)
if err != nil {
return nil, err
}
bottom, err := r.float64(marginBottom, 1)
if err != nil {
return nil, err
}
left, err := r.float64(marginLeft, 1)
if err != nil {
return nil, err
}
right, err := r.float64(marginRight, 1)
if err != nil {
return nil, err
}
landscape, err := r.bool(landscape, false)
if err != nil {
return nil, err
}
return &printer.ChromeOptions{
WaitTimeout: timeout,
WaitDelay: delay,
HeaderHTML: header,
FooterHTML: footer,
PaperWidth: width,
PaperHeight: height,
MarginTop: top,
MarginBottom: bottom,
MarginLeft: left,
MarginRight: right,
Landscape: landscape,
}, nil
}
func (r *resource) officePrinterOptions() (*printer.OfficeOptions, error) {
timeout, err := r.float64(waitTimeout, r.opts.DefaultWaitTimeout)
if err != nil {
return nil, err
}
landscape, err := r.bool(landscape, false)
if err != nil {
return nil, err
}
return &printer.OfficeOptions{
WaitTimeout: timeout,
Landscape: landscape,
}, nil
}
func (r *resource) mergePrinterOptions() (*printer.MergeOptions, error) {
timeout, err := r.float64(waitTimeout, r.opts.DefaultWaitTimeout)
if err != nil {
return nil, err
}
return &printer.MergeOptions{
WaitTimeout: timeout,
}, nil
}
func (r *resource) has(key string) bool {
v, ok := r.formValues[key]
if ok {
ok = v != ""
}
return ok
}
func (r *resource) hasFile(filename string) bool {
fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename)
_, err := os.Stat(fpath)
return !os.IsNotExist(err)
}
func (r *resource) get(key string) (string, error) {
v, ok := r.formValues[key]
if !ok {
return "", fmt.Errorf("form value %s does not exist", key)
}
return v, nil
}
func (r *resource) float64(key string, defaultValue float64) (float64, error) {
if !r.has(key) {
return defaultValue, nil
}
v, err := r.get(key)
if err != nil {
return 0.0, err
}
f, err := strconv.ParseFloat(v, 64)
if err != nil {
return 0.0, fmt.Errorf("form value %s: %v", key, err)
}
return f, nil
}
func (r *resource) bool(key string, defaultValue bool) (bool, error) {
if !r.has(key) {
return defaultValue, nil
}
v, err := r.get(key)
if err != nil {
return false, err
}
b, err := strconv.ParseBool(v)
if err != nil {
return false, fmt.Errorf("form value %s: %v", key, err)
}
return b, nil
}
func (r *resource) fpath(filename string) (string, error) {
fpath := fmt.Sprintf("%s/%s", r.formFilesDirPath, filename)
_, err := os.Stat(fpath) _, err := os.Stat(fpath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return "", fmt.Errorf("%s: file does not exist", filename) return "", fmt.Errorf("%s: form file does not exist", filename)
} }
absPath, err := filepath.Abs(fpath) absPath, err := filepath.Abs(fpath)
if err != nil { if err != nil {
@@ -98,13 +267,28 @@ func (r *resource) filePath(filename string) (string, error) {
return absPath, nil return absPath, nil
} }
func (r *resource) filePaths(exts []string) ([]string, error) { func (r *resource) content(filename string, defaultValue string) (string, error) {
if !r.hasFile(filename) {
return defaultValue, nil
}
fpath, err := r.fpath(filename)
if err != nil {
return "", err
}
b, err := ioutil.ReadFile(fpath)
if err != nil {
return "", fmt.Errorf("%s: reading form file: %v", fpath, err)
}
return string(b), nil
}
func (r *resource) fpaths(exts ...string) ([]string, error) {
var fpaths []string var fpaths []string
err := filepath.Walk(r.dirPath, func(path string, info os.FileInfo, _ error) error { err := filepath.Walk(r.formFilesDirPath, func(path string, info os.FileInfo, _ error) error {
if info.IsDir() { if info.IsDir() {
return nil return nil
} }
fpath, err := r.filePath(info.Name()) fpath, err := r.fpath(info.Name())
if err != nil { if err != nil {
return err return err
} }
@@ -119,66 +303,8 @@ func (r *resource) filePaths(exts []string) ([]string, error) {
if err != nil { if err != nil {
return nil, err return nil, err
} }
if len(fpaths) == 0 {
return nil, fmt.Errorf("no form files found for extensions: %v", exts)
}
return fpaths, nil 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) }

View File

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

View File

@@ -7,24 +7,29 @@ import (
"github.com/labstack/gommon/color" "github.com/labstack/gommon/color"
) )
var ( // Print prints a message to stdout.
stdout *color.Color func Print(message string) {
stderr *color.Color stdout := color.New()
)
func init() {
stdout = color.New()
stdout.SetOutput(os.Stdout) 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) stdout.Printf("⇨ %s\n", message)
} }
// ErrPrintln prints an error to stderr. // Printf prints a formatted message to stdout.
func ErrPrintln(err error) { 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))) stderr.Printf("%s\n", color.Red(fmt.Sprintf("⇨ error: %v", err)))
} }

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
Package pm2 facilitates starting external Package pm2 facilitates starting external
processes on which our API depends. 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. unoconv listener with PM2.
The PM2 process manager launch those processes and keep The PM2 process manager launch those processes and keep

View File

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

View File

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

View File

@@ -6,14 +6,14 @@ import (
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
) )
func TestUnoconvLaunch(t *testing.T) { func TestUnoconvStart(t *testing.T) {
p := &Unoconv{} p := NewUnoconv()
err := p.Launch() err := p.Start()
require.Nil(t, err) require.Nil(t, err)
} }
func TestUnoconvShutdown(t *testing.T) { func TestUnoconvShutdown(t *testing.T) {
p := &Unoconv{} p := NewUnoconv()
err := p.Shutdown() err := p.Shutdown()
require.Nil(t, err) 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,52 +1,5 @@
/* /*
Package printer contains structs which convert Package printer contains structs which convert
a specific file type to PDF: 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 package printer

View File

@@ -1,188 +1,14 @@
package printer package printer
import ( import (
"context"
"errors"
"fmt" "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. // NewHTML returns an HTML printer.
type HTML struct { func NewHTML(fpath string, opts *ChromeOptions) Printer {
Context context.Context URL := fmt.Sprintf("file://%s", fpath)
URL string return &chrome{
HeaderHTML string url: URL,
FooterHTML string opts: opts,
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))
)

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 ( import (
"bytes" "bytes"
"context"
"fmt" "fmt"
"html/template" "html/template"
"io/ioutil" "io/ioutil"
@@ -13,99 +12,48 @@ import (
"github.com/thecodingmachine/gotenberg/internal/pkg/rand" "github.com/thecodingmachine/gotenberg/internal/pkg/rand"
) )
// Markdown facilitates Markdown to PDF conversion. // NewMarkdown returns a Markdown printer.
type Markdown struct { func NewMarkdown(fpath string, opts *ChromeOptions) (Printer, error) {
Context context.Context tmpl, err := template.
TemplatePath string New(filepath.Base(fpath)).
HeaderHTML string Funcs(template.FuncMap{"toHTML": markdownToHTML}).
FooterHTML string ParseFiles(fpath)
PaperWidth float64 if err != nil {
PaperHeight float64 return nil, fmt.Errorf("%s: parsing template: %v", fpath, err)
MarginTop float64 }
MarginBottom float64 dirPath := filepath.Dir(fpath)
MarginLeft float64 data := &templateData{DirPath: dirPath}
MarginRight float64 var buffer bytes.Buffer
Landscape bool if err := tmpl.Execute(&buffer, data); err != nil {
return nil, fmt.Errorf("%s: executing template: %v", fpath, err)
html *HTML }
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 { type templateData struct {
DirPath string DirPath string
} }
// Print converts markdown to PDF. func markdownToHTML(dirPath, filename string) (template.HTML, error) {
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) fpath := fmt.Sprintf("%s/%s", dirPath, filename)
b, err := ioutil.ReadFile(fpath) b, err := ioutil.ReadFile(fpath)
if err != nil { if err != nil {
return "", fmt.Errorf("%s: reading file: %v", fpath, err) return "", fmt.Errorf("%s: reading file: %v", fpath, err)
} }
unsafe := blackfriday.Run(b) unsafe := blackfriday.Run(b)
contentHTML := bluemonday.UGCPolicy().SanitizeBytes(unsafe) content := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
return template.HTML(contentHTML), nil /* #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,61 +2,89 @@ package printer
import ( import (
"context" "context"
"errors"
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sync" "sync"
"time"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand" "github.com/thecodingmachine/gotenberg/internal/pkg/rand"
) )
var mu sync.Mutex type office struct {
fpaths []string
// Office facilitates Office documents to PDF conversion. opts *OfficeOptions
type Office struct {
Context context.Context
FilePaths []string
} }
// Print converts Office documents to PDF. // OfficeOptions helps customizing the
func (o *Office) Print(destination string) error { // Office printer behaviour.
mu.Lock() type OfficeOptions struct {
defer mu.Unlock() WaitTimeout float64
fpaths := make([]string, len(o.FilePaths)) 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) dirPath := filepath.Dir(destination)
for i, fpath := range o.FilePaths { for i, fpath := range p.fpaths {
baseFilename, err := rand.Get() baseFilename, err := rand.Get()
if err != nil { if err != nil {
return err return err
} }
tmpDest := fmt.Sprintf("%s/%s.pdf", dirPath, baseFilename) tmpDest := fmt.Sprintf("%s/%d%s.pdf", dirPath, i, baseFilename)
cmd := exec.CommandContext( if err := unoconv(ctx, fpath, tmpDest, p.opts); err != nil {
o.Context, return err
"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 fpaths[i] = tmpDest
} }
if len(fpaths) == 1 { if len(fpaths) == 1 {
return os.Rename(fpaths[0], destination) 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. // Compile-time checks to ensure type implements desired interfaces.
var ( var (
_ = Printer(new(Office)) _ = Printer(new(office))
) )

View File

@@ -1,31 +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"),
},
}
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,29 +1,7 @@
package printer 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. // Printer is a type that can create a PDF file from a source.
// The source is defined in the underlying implementation. // The source is defined in the underlying implementation.
type Printer interface { type Printer interface {
Print(destination string) error 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: 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.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,
}
}

View File

@@ -1,37 +0,0 @@
# Gotenberg Go client
A simple Go client for interacting with a Gotenberg API.
## Install
```bash
$ go get -u github.com/thecodingmachine/gotenberg
```
## Usage
```golang
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
// HTML conversion example.
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := &gotenberg.HTMLRequest{
IndexFilePath: "index.html",
AssetFilePaths: []string{
"style.css",
"img.png",
},
Options: &gotenberg.HTMLOptions{
HeaderFilePath: "header.html",
FooterFilePath: "footer.html",
PaperSize: gotenberg.A4,
PaperMargins: gotenberg.NormalMargins,
},
}
dest := "foo.pdf"
c.Store(req, dest)
}
```
For more complete usages, head to the [documentation](https://thecodingmachine.github.io/gotenberg).

View File

@@ -1,155 +0,0 @@
package gotenberg
import (
"bytes"
"errors"
"fmt"
"io"
"mime/multipart"
"net/http"
"os"
"path/filepath"
"runtime"
)
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"
)
var (
// A3 paper size.
A3 = [2]float64{11.7, 16.5}
// A4 paper size.
A4 = [2]float64{8.27, 11.7}
// A5 paper size.
A5 = [2]float64{5.8, 8.3}
// A6 paper size.
A6 = [2]float64{4.1, 5.8}
// Letter paper size.
Letter = [2]float64{8.5, 11}
// Legal paper size.
Legal = [2]float64{8.5, 14}
// Tabloid paper size.
Tabloid = [2]float64{11, 17}
)
var (
// NoMargins removes margins.
NoMargins = [4]float64{0, 0, 0, 0}
// NormalMargins uses 1 inche margins.
NormalMargins = [4]float64{1, 1, 1, 1}
// LargeMargins uses 2 inche margins.
LargeMargins = [4]float64{2, 2, 2, 2}
)
// Client facilitates interacting with
// the Gotenberg API.
type Client struct {
Hostname string
}
// Request is a type for sending
// form values and form files to
// the Gotenberg API.
type Request interface {
validate() error
getPostURL() string
getFormValues() map[string]string
getFormFiles() map[string]string
}
// Post sends a request to the Gotenberg API
// and returns the response.
func (c *Client) Post(req Request) (*http.Response, error) {
if err := req.validate(); err != nil {
return nil, err
}
body, contentType, err := multipartForm(req)
if err != nil {
return nil, err
}
URL := fmt.Sprintf("%s%s", c.Hostname, req.getPostURL())
resp, err := http.Post(URL, contentType, body)
if err != nil {
return nil, err
}
return resp, nil
}
// Store creates the resulting PDF to given destination.
func (c *Client) Store(req Request, dest string) error {
if hasWebhook(req) {
return errors.New("cannot use Store method with a webhook")
}
resp, err := c.Post(req)
if err != nil {
return err
}
return writeNewFile(dest, resp.Body)
}
func hasWebhook(req Request) bool {
webhookURL, ok := req.getFormValues()[webhookURL]
if !ok {
return false
}
return webhookURL != ""
}
func writeNewFile(fpath string, in io.Reader) error {
if err := os.MkdirAll(filepath.Dir(fpath), 0755); err != nil {
return fmt.Errorf("%s: making directory for file: %v", fpath, err)
}
out, err := os.Create(fpath)
if err != nil {
return fmt.Errorf("%s: creating new file: %v", fpath, err)
}
defer out.Close()
err = out.Chmod(0644)
if err != nil && runtime.GOOS != "windows" {
return fmt.Errorf("%s: changing file mode: %v", fpath, err)
}
_, err = io.Copy(out, in)
if err != nil {
return fmt.Errorf("%s: writing file: %v", fpath, err)
}
return nil
}
func fileExists(name string) bool {
_, err := os.Stat(name)
return !os.IsNotExist(err)
}
func multipartForm(req Request) (*bytes.Buffer, string, error) {
body := &bytes.Buffer{}
writer := multipart.NewWriter(body)
defer writer.Close()
for filename, fpath := range req.getFormFiles() {
in, err := os.Open(fpath)
if err != nil {
return nil, "", fmt.Errorf("%s: opening file: %v", filename, err)
}
part, err := writer.CreateFormFile("files", filename)
if err != nil {
return nil, "", fmt.Errorf("%s: creating form file: %v", filename, err)
}
_, err = io.Copy(part, in)
if err != nil {
return nil, "", fmt.Errorf("%s: copying file: %v", filename, err)
}
}
for name, value := range req.getFormValues() {
if err := writer.WriteField(name, value); err != nil {
return nil, "", fmt.Errorf("%s: writing form field: %v", name, err)
}
}
return body, writer.FormDataContentType(), nil
}

View File

@@ -1,31 +0,0 @@
/*
Package gotenberg is a Go client for
interacting with a Gotenberg API.
For instance, if you want to convert HTML:
import "github.com/thecodingmachine/gotenberg/pkg"
func main() {
// HTML conversion example.
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
req := &gotenberg.HTMLRequest{
IndexFilePath: "index.html",
AssetFilePaths: []string{
"style.css",
"img.png",
},
Options: &gotenberg.HTMLOptions{
HeaderFilePath: "header.html",
FooterFilePath: "footer.html",
PaperSize: gotenberg.A4,
PaperMargins: gotenberg.NormalMargins,
},
}
dest := "foo.pdf"
c.Store(req, dest)
}
For more complete usages, head to the https://thecodingmachine.gotenberg.github.io.
*/
package gotenberg

View File

@@ -1,78 +0,0 @@
package gotenberg
import (
"fmt"
"path/filepath"
"strconv"
)
// HTMLRequest facilitates HTML conversion
// with the Gotenberg API.
type HTMLRequest struct {
IndexFilePath string
AssetFilePaths []string
Options *HTMLOptions
}
// HTMLOptions gathers all options
// for HTML conversion with the Gotenberg API.
type HTMLOptions struct {
WebHookURL string
HeaderFilePath string
FooterFilePath string
PaperSize [2]float64
PaperMargins [4]float64
Landscape bool
}
func (html *HTMLRequest) validate() error {
if !fileExists(html.IndexFilePath) {
return fmt.Errorf("%s: index file does not exist", html.IndexFilePath)
}
if html.Options.HeaderFilePath != "" && !fileExists(html.Options.HeaderFilePath) {
return fmt.Errorf("%s: header file does not exist", html.Options.HeaderFilePath)
}
if html.Options.FooterFilePath != "" && !fileExists(html.Options.FooterFilePath) {
return fmt.Errorf("%s: footer file does not exist", html.Options.FooterFilePath)
}
return nil
}
func (html *HTMLRequest) getPostURL() string {
return "/convert/html"
}
func (html *HTMLRequest) getFormValues() map[string]string {
if html.Options == nil {
html.Options = &HTMLOptions{}
}
values := make(map[string]string)
values[webhookURL] = html.Options.WebHookURL
values[paperWidth] = fmt.Sprintf("%f", html.Options.PaperSize[0])
values[paperHeight] = fmt.Sprintf("%f", html.Options.PaperSize[1])
values[marginTop] = fmt.Sprintf("%f", html.Options.PaperMargins[0])
values[marginBottom] = fmt.Sprintf("%f", html.Options.PaperMargins[1])
values[marginLeft] = fmt.Sprintf("%f", html.Options.PaperMargins[2])
values[marginRight] = fmt.Sprintf("%f", html.Options.PaperMargins[3])
values[landscape] = strconv.FormatBool(html.Options.Landscape)
return values
}
func (html *HTMLRequest) getFormFiles() map[string]string {
if html.Options == nil {
html.Options = &HTMLOptions{}
}
files := make(map[string]string)
files["index.html"] = html.IndexFilePath
files["header.html"] = html.Options.HeaderFilePath
files["footer.html"] = html.Options.FooterFilePath
for _, fpath := range html.AssetFilePaths {
files[filepath.Base(fpath)] = fpath
}
return files
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Request(new(HTMLRequest))
)

View File

@@ -1,38 +0,0 @@
package gotenberg
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
"github.com/thecodingmachine/gotenberg/test"
)
func TestHTML(t *testing.T) {
c := &Client{Hostname: "http://localhost:3000"}
req := &HTMLRequest{
IndexFilePath: test.HTMLTestFilePath(t, "index.html"),
AssetFilePaths: []string{
test.HTMLTestFilePath(t, "font.woff"),
test.HTMLTestFilePath(t, "img.gif"),
test.HTMLTestFilePath(t, "style.css"),
},
Options: &HTMLOptions{
HeaderFilePath: test.HTMLTestFilePath(t, "header.html"),
FooterFilePath: test.HTMLTestFilePath(t, "footer.html"),
PaperSize: A4,
PaperMargins: NormalMargins,
},
}
dirPath, err := rand.Get()
require.Nil(t, err)
dest := fmt.Sprintf("%s/foo.pdf", dirPath)
err = c.Store(req, dest)
assert.Nil(t, err)
assert.FileExists(t, dest)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

@@ -1,87 +0,0 @@
package gotenberg
import (
"fmt"
"path/filepath"
"strconv"
)
// MarkdownRequest facilitates Markdown conversion
// with the Gotenberg API.
type MarkdownRequest struct {
IndexFilePath string
MarkdownFilePaths []string
AssetFilePaths []string
Options *MarkdownOptions
}
// MarkdownOptions gathers all options
// for Markdown conversion with the Gotenberg API.
type MarkdownOptions struct {
WebHookURL string
HeaderFilePath string
FooterFilePath string
PaperSize [2]float64
PaperMargins [4]float64
Landscape bool
}
func (markdown *MarkdownRequest) validate() error {
if !fileExists(markdown.IndexFilePath) {
return fmt.Errorf("%s: index file does not exist", markdown.IndexFilePath)
}
if markdown.Options.HeaderFilePath != "" && !fileExists(markdown.Options.HeaderFilePath) {
return fmt.Errorf("%s: header file does not exist", markdown.Options.HeaderFilePath)
}
if markdown.Options.FooterFilePath != "" && !fileExists(markdown.Options.FooterFilePath) {
return fmt.Errorf("%s: footer file does not exist", markdown.Options.FooterFilePath)
}
for _, fpath := range markdown.MarkdownFilePaths {
if !fileExists(fpath) {
return fmt.Errorf("%s: markdown file does not exist", fpath)
}
}
return nil
}
func (markdown *MarkdownRequest) getPostURL() string {
return "/convert/markdown"
}
func (markdown *MarkdownRequest) getFormValues() map[string]string {
if markdown.Options == nil {
markdown.Options = &MarkdownOptions{}
}
values := make(map[string]string)
values[webhookURL] = markdown.Options.WebHookURL
values[paperWidth] = fmt.Sprintf("%f", markdown.Options.PaperSize[0])
values[paperHeight] = fmt.Sprintf("%f", markdown.Options.PaperSize[1])
values[marginTop] = fmt.Sprintf("%f", markdown.Options.PaperMargins[0])
values[marginBottom] = fmt.Sprintf("%f", markdown.Options.PaperMargins[1])
values[marginLeft] = fmt.Sprintf("%f", markdown.Options.PaperMargins[2])
values[marginRight] = fmt.Sprintf("%f", markdown.Options.PaperMargins[3])
values[landscape] = strconv.FormatBool(markdown.Options.Landscape)
return values
}
func (markdown *MarkdownRequest) getFormFiles() map[string]string {
if markdown.Options == nil {
markdown.Options = &MarkdownOptions{}
}
files := make(map[string]string)
files["index.html"] = markdown.IndexFilePath
files["header.html"] = markdown.Options.HeaderFilePath
files["footer.html"] = markdown.Options.FooterFilePath
for _, fpath := range markdown.MarkdownFilePaths {
files[filepath.Base(fpath)] = fpath
}
for _, fpath := range markdown.AssetFilePaths {
files[filepath.Base(fpath)] = fpath
}
return files
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Request(new(MarkdownRequest))
)

View File

@@ -1,43 +0,0 @@
package gotenberg
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMarkdown(t *testing.T) {
c := &Client{Hostname: "http://localhost:3000"}
req := &MarkdownRequest{
IndexFilePath: test.MarkdownTestFilePath(t, "index.html"),
MarkdownFilePaths: []string{
test.MarkdownTestFilePath(t, "paragraph1.md"),
test.MarkdownTestFilePath(t, "paragraph2.md"),
test.MarkdownTestFilePath(t, "paragraph3.md"),
},
AssetFilePaths: []string{
test.HTMLTestFilePath(t, "font.woff"),
test.HTMLTestFilePath(t, "img.gif"),
test.HTMLTestFilePath(t, "style.css"),
},
Options: &MarkdownOptions{
HeaderFilePath: test.MarkdownTestFilePath(t, "header.html"),
FooterFilePath: test.MarkdownTestFilePath(t, "footer.html"),
PaperSize: A4,
PaperMargins: NormalMargins,
},
}
dirPath, err := rand.Get()
require.Nil(t, err)
dest := fmt.Sprintf("%s/foo.pdf", dirPath)
err = c.Store(req, dest)
assert.Nil(t, err)
assert.FileExists(t, dest)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

@@ -1,55 +0,0 @@
package gotenberg
import (
"fmt"
"path/filepath"
)
// MergeRequest facilitates merging PDF
// with the Gotenberg API.
type MergeRequest struct {
FilePaths []string
Options *MergeOptions
}
// MergeOptions gathers all options
// for merging PDF
// with the Gotenberg API.
type MergeOptions struct {
WebHookURL string
}
func (merge *MergeRequest) validate() error {
for _, fpath := range merge.FilePaths {
if !fileExists(fpath) {
return fmt.Errorf("%s: PDF file does not exist", fpath)
}
}
return nil
}
func (merge *MergeRequest) getPostURL() string {
return "/merge"
}
func (merge *MergeRequest) getFormValues() map[string]string {
if merge.Options == nil {
merge.Options = &MergeOptions{}
}
values := make(map[string]string)
values[webhookURL] = merge.Options.WebHookURL
return values
}
func (merge *MergeRequest) getFormFiles() map[string]string {
files := make(map[string]string)
for _, fpath := range merge.FilePaths {
files[filepath.Base(fpath)] = fpath
}
return files
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Request(new(MergeRequest))
)

View File

@@ -1,30 +0,0 @@
package gotenberg
import (
"fmt"
"os"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
"github.com/thecodingmachine/gotenberg/test"
)
func TestMerge(t *testing.T) {
c := &Client{Hostname: "http://localhost:3000"}
req := &MergeRequest{
FilePaths: []string{
test.PDFTestFilePath(t, "gotenberg.pdf"),
test.PDFTestFilePath(t, "gotenberg.pdf"),
},
}
dirPath, err := rand.Get()
require.Nil(t, err)
dest := fmt.Sprintf("%s/foo.pdf", dirPath)
err = c.Store(req, dest)
assert.Nil(t, err)
assert.FileExists(t, dest)
err = os.RemoveAll(dirPath)
assert.Nil(t, err)
}

View File

@@ -1,55 +0,0 @@
package gotenberg
import (
"fmt"
"path/filepath"
)
// OfficeRequest facilitates Office documents
// conversion with the Gotenberg API.
type OfficeRequest struct {
FilePaths []string
Options *OfficeOptions
}
// OfficeOptions gathers all options
// for Office documents conversion
// with the Gotenberg API.
type OfficeOptions struct {
WebHookURL string
}
func (office *OfficeRequest) validate() error {
for _, fpath := range office.FilePaths {
if !fileExists(fpath) {
return fmt.Errorf("%s: office file does not exist", fpath)
}
}
return nil
}
func (office *OfficeRequest) getPostURL() string {
return "/convert/office"
}
func (office *OfficeRequest) getFormValues() map[string]string {
if office.Options == nil {
office.Options = &OfficeOptions{}
}
values := make(map[string]string)
values[webhookURL] = office.Options.WebHookURL
return values
}
func (office *OfficeRequest) getFormFiles() map[string]string {
files := make(map[string]string)
for _, fpath := range office.FilePaths {
files[filepath.Base(fpath)] = fpath
}
return files
}
// Compile-time checks to ensure type implements desired interfaces.
var (
_ = Request(new(OfficeRequest))
)

Some files were not shown because too many files have changed in this diff Show More