mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-08 08:32:16 +01:00
Compare commits
25 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1ef21c6ed6 | ||
|
|
6ee1932cbf | ||
|
|
83cf43a314 | ||
|
|
52c14a79a1 | ||
|
|
152db0551d | ||
|
|
3a2467ccfe | ||
|
|
5a28f3597c | ||
|
|
9ce179f91b | ||
|
|
7aeb072cc3 | ||
|
|
8a652761a3 | ||
|
|
be667575d5 | ||
|
|
59ca8a44a0 | ||
|
|
c9f0d3bfd8 | ||
|
|
06e7a1fda1 | ||
|
|
495203c112 | ||
|
|
c1f6100382 | ||
|
|
223c0de35a | ||
|
|
e3366d0b62 | ||
|
|
5100693789 | ||
|
|
faec55dd01 | ||
|
|
2871fb1ffe | ||
|
|
810d35ffbe | ||
|
|
4997b73bac | ||
|
|
ef9801b1fd | ||
|
|
1f2317dc74 |
@@ -1,64 +0,0 @@
|
||||
FROM {{ if eq "CI" .Orbit.Image }}golang:1.10-stretch{{ else }}debian:stretch-slim{{ end }}
|
||||
|
||||
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Libraries
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs all required libraries.
|
||||
# |
|
||||
|
||||
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y xvfb wkhtmltopdf pdftk unoconv ttf-mscorefonts-installer &&\
|
||||
ln -s /usr/bin/xvfb-run /usr/local/bin/xvfb-run &&\
|
||||
ln -s /usr/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf &&\
|
||||
ln -s /usr/bin/pdftk /usr/local/bin/pdftk &&\
|
||||
ln -s /usr/bin/unoconv /usr/local/bin/unoconv
|
||||
|
||||
RUN apt-get install -y curl gnupg &&\
|
||||
curl -sL https://deb.nodesource.com/setup_8.x | bash - &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y nodejs
|
||||
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y yarn bzip2 &&\
|
||||
yarn global add markdown-pdf --prefix /usr/local
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Gotenberg
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | All Gotenberg related stuff.
|
||||
# |
|
||||
|
||||
{{- if eq "CI" .Orbit.Image }}
|
||||
|
||||
WORKDIR /go/src/github.com/thecodingmachine/gotenberg
|
||||
|
||||
# Installs lint dependencies.
|
||||
RUN go get -u gopkg.in/alecthomas/gometalinter.v2 &&\
|
||||
gometalinter.v2 --install
|
||||
|
||||
# Copies our Go source.
|
||||
COPY . .
|
||||
|
||||
# Installs project dependencies.
|
||||
RUN go get -d -v ./...
|
||||
|
||||
ENTRYPOINT [".ci/docker-entrypoint.sh"]
|
||||
{{- else }}
|
||||
|
||||
COPY .ci/gotenberg /usr/bin/gotenberg
|
||||
RUN ln -s /usr/bin/gotenberg /usr/local/bin/gotenberg
|
||||
|
||||
COPY .ci/gotenberg.yml /gotenberg/gotenberg.yml
|
||||
|
||||
WORKDIR /gotenberg
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["gotenberg"]
|
||||
{{- end }}
|
||||
@@ -1,136 +0,0 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/38133342-11df3bd8-340f-11e8-9fe4-50baecdceeca.png" alt="Gotenberg's logo" width="250" height="250" />
|
||||
</p>
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A stateless API for converting Markdown files, HTML files and Office documents to PDF</p>
|
||||
<p align="center">
|
||||
<a href="https://microbadger.com/images/thecodingmachine/gotenberg:{{ .Orbit.Latest }}">
|
||||
<img src="https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:{{ .Orbit.Latest }}.svg" alt="MicroBadger layers">
|
||||
</a>
|
||||
<a href="https://travis-ci.org/thecodingmachine/gotenberg">
|
||||
<img src="https://travis-ci.org/thecodingmachine/gotenberg.svg?branch={{ .Orbit.Branch }}" alt="Travis CI">
|
||||
</a>
|
||||
<a href="https://godoc.org/github.com/thecodingmachine/gotenberg">
|
||||
<img src="https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg" alt="GoDoc">
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/thecodingmachine/gotenberg">
|
||||
<img src="https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg" alt="Go Report Card">
|
||||
</a>
|
||||
<a href="https://codecov.io/gh/thecodingmachine/gotenberg/branch/{{ .Orbit.Branch }}">
|
||||
<img src="https://codecov.io/gh/thecodingmachine/gotenberg/branch/{{ .Orbit.Branch }}/graph/badge.svg" alt="Codecov">
|
||||
</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
At TheCodingMachine, we build a lot of web applications (intranets, extranets and so on) which require to generate PDF from
|
||||
various sources. Each time, we ended up using some well known libraries like **wkhtmltopdf** or **unoconv** and kind of lost time by
|
||||
reimplementing a solution from a project to another project. Meh.
|
||||
|
||||
# Menu
|
||||
|
||||
* [Usage](#usage)
|
||||
* [Security](#security)
|
||||
* [Scalability](#scalability)
|
||||
* [Custom implementation](#custom-implementation)
|
||||
* [Clients](#clients)
|
||||
|
||||
## Usage
|
||||
|
||||
Let's say you're starting the API using this simple command:
|
||||
|
||||
```sh
|
||||
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:{{ .Orbit.Latest }}
|
||||
```
|
||||
|
||||
The API is now available on your host under `http://127.0.0.1:3000`.
|
||||
|
||||
It accepts `POST` requests with a `multipart/form-data` Content-Type. Your form data should provide one or more files to convert.
|
||||
It currently accepts the following:
|
||||
|
||||
* Markdown files
|
||||
* HTML files
|
||||
* Office documents (.docx, .doc, .odt, .pptx, .ppt, .odp and so on)
|
||||
* PDF files (if more than one file to convert)
|
||||
|
||||
**Heads up:** the API relies on the file extension to determine which library to use for conversion.
|
||||
|
||||
There are two use cases:
|
||||
|
||||
* If you send one file, it will convert it and return the resulting PDF
|
||||
* If many files, it will convert them to PDF, merge the resulting PDFs into a single PDF and return it
|
||||
|
||||
### Examples:
|
||||
|
||||
* One file
|
||||
|
||||
```sh
|
||||
$ curl --request POST \
|
||||
--url http://127.0.0.1:3000 \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form files=@file.docx \
|
||||
> result.pdf
|
||||
```
|
||||
|
||||
* Many files
|
||||
|
||||
```sh
|
||||
$ curl --request POST \
|
||||
--url http://127.0.0.1:3000 \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form files=@file.md \
|
||||
--form files=@file.html \
|
||||
--form files=@file.pdf \
|
||||
--form files=@file.docx \
|
||||
> result.pdf
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
The API does not provide any authentication mechanisms. Make sure to not put it on a public facing port and your client(s) should always
|
||||
controls what is sent to the API.
|
||||
|
||||
## Scalability
|
||||
|
||||
Some libraries like **unoconv** cannot perform concurrent conversions. That's why the API does only one conversion at a time.
|
||||
If your API is under heavy load, a request will take time to be processed.
|
||||
|
||||
Fortunately, you may pass through this limitation by scaling the API.
|
||||
|
||||
In the following example, I'll demonstrate how to do some vertical scaling (= on the same machine) with Docker Compose, but of course horizontal scaling works too!
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
|
||||
# your others services
|
||||
|
||||
gotenberg:
|
||||
image: gotenberg:1.0.0
|
||||
```
|
||||
|
||||
You may now launch your services using:
|
||||
|
||||
```bash
|
||||
docker-compose up --scale gotenberg=your_number_of_instances
|
||||
```
|
||||
|
||||
When requesting the Gotenberg service with your client(s), Docker will automatically redirect a request to a Gotenberg container
|
||||
according to the round-robin strategy.
|
||||
|
||||
## Custom implementation
|
||||
|
||||
The API relies on a simple YAML configuration file called `gotenberg.yml`. It allows you to tweak some values and even provides you
|
||||
a way to change the commands called for each kind of conversion. The configuration file should be located under `/gotenberg` in your container.
|
||||
|
||||
The default configuration is located here: [.ci/gotenberg.yml](.ci/gotenberg.yml)
|
||||
|
||||
## Clients
|
||||
|
||||
* https://github.com/thecodingmachine/gotenberg-php-client (PHP client)
|
||||
* Add your own client by submitting a [pull request](../../pulls)!
|
||||
|
||||
---
|
||||
|
||||
Would you like to update this documentation ? Feel free to open an [issue](../../issues).
|
||||
@@ -1,33 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -xe
|
||||
|
||||
# Statically checking Go source for errors and warnings.
|
||||
gometalinter.v2 --disable-all -E vet -E gofmt -E misspell -E ineffassign -E goimports -E deadcode -E gocyclo --vendor ./...;
|
||||
|
||||
# Running tests according to current Gotenberg version.
|
||||
if [[ "$VERSION" == "snapshot" ]]; then
|
||||
for d in $(go list ./... | grep -v vendor); do
|
||||
go test -race -cover -covermode=atomic $d;
|
||||
done
|
||||
else
|
||||
echo "" > .ci/coverage.txt;
|
||||
for d in $(go list ./... | grep -v vendor); do
|
||||
go test -race -coverprofile=profile.out -covermode=atomic $d;
|
||||
if [ -f profile.out ]; then
|
||||
cat profile.out >> .ci/coverage.txt;
|
||||
rm profile.out;
|
||||
fi
|
||||
done
|
||||
fi
|
||||
|
||||
|
||||
# Builds the Linux binary.
|
||||
if [ -f .ci/gotenberg ]; then
|
||||
rm .ci/gotenberg
|
||||
fi
|
||||
|
||||
env GOOS=linux GOARCH=amd64 CGO_ENABLED=0 go build -ldflags "-X main.version=${VERSION}" && mv gotenberg .ci/;
|
||||
|
||||
# Bye!
|
||||
exit 0;
|
||||
@@ -1,33 +0,0 @@
|
||||
# The port the application will listen to.
|
||||
port: 3000
|
||||
|
||||
logs:
|
||||
# Accepted values, in order of severity: DEBUG, INFO, WARN, ERROR, FATAL, PANIC.
|
||||
# Messages at and above the selected level will be logged.
|
||||
level: "INFO"
|
||||
|
||||
# Accepted values: text, json.
|
||||
# When a TTY is not attached, the output will be in the defined format.
|
||||
format: "text"
|
||||
|
||||
# You don't like a library which is used for a conversion? You may provide here your own implementation.
|
||||
commands:
|
||||
|
||||
markdown:
|
||||
# Duration in seconds after which the command will be killed if it has not finished.
|
||||
timeout: 30
|
||||
# The command template: you have access to FilePath and ResultFilePath variables.
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
|
||||
merge:
|
||||
timeout: 30
|
||||
# Unlike others commands' templates, you have access to FilesPaths instead of FilePath: it gathers all PDF files which should be merged.
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
11
.github/CONTRIBUTING.md
vendored
11
.github/CONTRIBUTING.md
vendored
@@ -7,9 +7,9 @@ find below useful information about how to contribute to the Gotenberg project.
|
||||
|
||||
### Install from sources
|
||||
|
||||
1. Fork this repository
|
||||
2. Clone it to your local Go environment (requires *Go* >= 1.10)
|
||||
3. Install the latest [Orbit](https://github.com/gulien/orbit) release
|
||||
1. Install and run the latest version of Docker
|
||||
2. Fork this repository
|
||||
3. Clone it to your local Go environment (requires *Go* >= 1.11)
|
||||
|
||||
### Working with git
|
||||
|
||||
@@ -18,6 +18,11 @@ find below useful information about how to contribute to the Gotenberg project.
|
||||
3. Push to the branch (`git push origin my-new-feature`)
|
||||
4. Create a new pull request
|
||||
|
||||
### Testing
|
||||
|
||||
1. Run all linters (`make lint`)
|
||||
2. Run all tests (`make tests`)
|
||||
|
||||
## Reporting bugs and feature request
|
||||
|
||||
Your issue or feature request may already be reported!
|
||||
|
||||
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
7
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -27,7 +27,7 @@ Explain the **motivation** for making this change. What existing problem does th
|
||||
|
||||
Demonstrate the code is solid. Example: The exact commands you ran and their output.
|
||||
|
||||
<!-- Make sure tests pass on both Travis and AppVeyor. -->
|
||||
<!-- Make sure tests pass on Travis. -->
|
||||
|
||||
**Closing issues**
|
||||
|
||||
@@ -37,8 +37,9 @@ Fixes #
|
||||
**Checklist**
|
||||
|
||||
- [ ] Have you followed the guidelines in our [CONTRIBUTING](CONTRIBUTING.md) guide?
|
||||
- [ ] Have you lint your code locally prior to submission (`orbit run fmt`)?
|
||||
- [ ] Have you lint your code locally prior to submission (`make lint`)?
|
||||
- [ ] Have you written new tests for your core changes, as applicable?
|
||||
- [ ] Have you successfully ran tests with your changes locally (`orbit run ci`)?
|
||||
- [ ] Have you successfully ran tests with your changes locally (`make tests`)?
|
||||
- [ ] Have you updated the documentation (`make doc`)?
|
||||
- [ ] I have squashed any insignificant commits
|
||||
- [ ] This change has comments for package types, values, functions, and non-obvious lines of code
|
||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,2 +0,0 @@
|
||||
.ci/coverage.txt
|
||||
.ci/gotenberg
|
||||
|
||||
22
.travis.yml
22
.travis.yml
@@ -10,14 +10,16 @@ env:
|
||||
- secure: d6pliM7iR7KJ3TQkr1C0tHi0HgU6Vdmc/ycw11XrXs794/dgj6g9UDrhgcvT2mTzOVIUNouHx7gyEWAlwJv7p8PPyguHJfIsyqMWfaXNPoytTx1ETUiygl1G/UsvXVdry6I5tZF/U+/PJ3hBXoAvo8EOFe0/MxxvP3ghCeeCpeEwEdoYP2jbADGffywVUNI7OEdmS29A28GusXXp2cWCIZYSq5Gn5iu1ZMPaJA6SkRTXpS+iVVLUvlbz5HW09ETFfurTvsdz5Ze4JICiuQt///CR1uwdxD8K7Ils/mkenaMtja9NHA9RdQBa51Azn8iCLsObCTYc52Zb6atbCi/X4ae9M8GA8qaaLigNz5G4v1BHneJylYfWKj5GwqUA3VmRu1mlEw7d2sN2uFRmgIl5kgsYYyOVOLBOKaJWhyBhNnR4U8KeqwUkOnlSdmNZnjfexXXBEcKHen1SozpXwMnzZh8LZuN6BNYd6TVpKmjulGI3vqNMM0c4JJZ9pSm4q5FjbNGQArwCsujDDwY4bCGZSJJTPMXjYOrx4yJ1RY+0TpyV9oDU3ogEqaWnhrkgrphfiYBlDY2bEvrxMTMukLCxJb0Nru6LGCpg4X1P9hu9r0gceqUSnBgdJI4rYTNddenxlEd4j/Thqv835stJWz6YoV+rfvERuSUpntl8xvrk3U8=
|
||||
- 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=
|
||||
|
||||
before_install:
|
||||
- wget -qO- https://github.com/gulien/orbit/releases/download/v3.0.0/orbit_Linux_x86_64.tar.gz | tar xvz -C .
|
||||
- sudo mv ./orbit /usr/local/bin && chmod +x /usr/local/bin/orbit
|
||||
stages:
|
||||
- tests
|
||||
- publish
|
||||
|
||||
script:
|
||||
- orbit run ci -p "Version,${TRAVIS_BRANCH}"
|
||||
- orbit run build -p "Version,${TRAVIS_BRANCH}"
|
||||
# Uploads coverage.
|
||||
- bash <(curl -s https://codecov.io/bash)
|
||||
# Let's push to Docker hub only if not in a pull request and the branch name matches the semver format.
|
||||
- if [[ "$TRAVIS_PULL_REQUEST" = false && "$TRAVIS_BRANCH" =~ ^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(-(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(\.(0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*)?(\+[0-9a-zA-Z-]+(\.[0-9a-zA-Z-]+)*)?$ ]]; then docker login -u $DOCKER_USER -p $DOCKER_PASS && docker push thecodingmachine/gotenberg:${TRAVIS_BRANCH}; fi
|
||||
jobs:
|
||||
include:
|
||||
- stage: tests
|
||||
script: make lint
|
||||
- stage: tests
|
||||
script: make tests
|
||||
- stage: publish
|
||||
if: tag IS present
|
||||
script: ./scripts/publish.sh $TRAVIS_TAG $DOCKER_USER $DOCKER_PASS
|
||||
46
Dockerfile
46
Dockerfile
@@ -1,46 +0,0 @@
|
||||
FROM debian:stretch-slim
|
||||
|
||||
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Libraries
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs all required libraries.
|
||||
# |
|
||||
|
||||
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y xvfb wkhtmltopdf pdftk unoconv ttf-mscorefonts-installer &&\
|
||||
ln -s /usr/bin/xvfb-run /usr/local/bin/xvfb-run &&\
|
||||
ln -s /usr/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf &&\
|
||||
ln -s /usr/bin/pdftk /usr/local/bin/pdftk &&\
|
||||
ln -s /usr/bin/unoconv /usr/local/bin/unoconv
|
||||
|
||||
RUN apt-get install -y curl gnupg &&\
|
||||
curl -sL https://deb.nodesource.com/setup_8.x | bash - &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y nodejs
|
||||
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y yarn bzip2 &&\
|
||||
yarn global add markdown-pdf --prefix /usr/local
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Gotenberg
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | All Gotenberg related stuff.
|
||||
# |
|
||||
|
||||
COPY .ci/gotenberg /usr/bin/gotenberg
|
||||
RUN ln -s /usr/bin/gotenberg /usr/local/bin/gotenberg
|
||||
|
||||
COPY .ci/gotenberg.yml /gotenberg/gotenberg.yml
|
||||
|
||||
WORKDIR /gotenberg
|
||||
|
||||
EXPOSE 3000
|
||||
CMD ["gotenberg"]
|
||||
@@ -1,50 +0,0 @@
|
||||
FROM golang:1.10-stretch
|
||||
|
||||
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Libraries
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs all required libraries.
|
||||
# |
|
||||
|
||||
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y xvfb wkhtmltopdf pdftk unoconv ttf-mscorefonts-installer &&\
|
||||
ln -s /usr/bin/xvfb-run /usr/local/bin/xvfb-run &&\
|
||||
ln -s /usr/bin/wkhtmltopdf /usr/local/bin/wkhtmltopdf &&\
|
||||
ln -s /usr/bin/pdftk /usr/local/bin/pdftk &&\
|
||||
ln -s /usr/bin/unoconv /usr/local/bin/unoconv
|
||||
|
||||
RUN apt-get install -y curl gnupg &&\
|
||||
curl -sL https://deb.nodesource.com/setup_8.x | bash - &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y nodejs
|
||||
|
||||
RUN curl -sS https://dl.yarnpkg.com/debian/pubkey.gpg | apt-key add - &&\
|
||||
echo "deb https://dl.yarnpkg.com/debian/ stable main" | tee /etc/apt/sources.list.d/yarn.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y yarn bzip2 &&\
|
||||
yarn global add markdown-pdf --prefix /usr/local
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Gotenberg
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | All Gotenberg related stuff.
|
||||
# |
|
||||
|
||||
WORKDIR /go/src/github.com/thecodingmachine/gotenberg
|
||||
|
||||
# Installs lint dependencies.
|
||||
RUN go get -u gopkg.in/alecthomas/gometalinter.v2 &&\
|
||||
gometalinter.v2 --install
|
||||
|
||||
# Copies our Go source.
|
||||
COPY . .
|
||||
|
||||
# Installs project dependencies.
|
||||
RUN go get -d -v ./...
|
||||
|
||||
ENTRYPOINT [".ci/docker-entrypoint.sh"]
|
||||
60
Gopkg.lock
generated
60
Gopkg.lock
generated
@@ -1,60 +0,0 @@
|
||||
# This file is autogenerated, do not edit; changes may be undone by the next 'dep ensure'.
|
||||
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/gorilla/context"
|
||||
packages = ["."]
|
||||
revision = "1ea25387ff6f684839d82767c1733ff4d4d15d0a"
|
||||
version = "v1.1"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/gorilla/mux"
|
||||
packages = ["."]
|
||||
revision = "53c1911da2b537f792e7cafcb446b05ffe33b996"
|
||||
version = "v1.6.1"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "github.com/justinas/alice"
|
||||
packages = ["."]
|
||||
revision = "03f45bd4b7dad4734bc4620e46a35789349abb20"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/satori/go.uuid"
|
||||
packages = ["."]
|
||||
revision = "f58768cc1a7a7e77a3bd49e98cdd21419399b6a3"
|
||||
version = "v1.2.0"
|
||||
|
||||
[[projects]]
|
||||
name = "github.com/sirupsen/logrus"
|
||||
packages = ["."]
|
||||
revision = "c155da19408a8799da419ed3eeb0cb5db0ad5dbc"
|
||||
version = "v1.0.5"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/crypto"
|
||||
packages = ["ssh/terminal"]
|
||||
revision = "88942b9c40a4c9d203b82b3731787b672d6e809b"
|
||||
|
||||
[[projects]]
|
||||
branch = "master"
|
||||
name = "golang.org/x/sys"
|
||||
packages = [
|
||||
"unix",
|
||||
"windows"
|
||||
]
|
||||
revision = "13d03a9a82fba647c21a0ef8fba44a795d0f0835"
|
||||
|
||||
[[projects]]
|
||||
name = "gopkg.in/yaml.v2"
|
||||
packages = ["."]
|
||||
revision = "86f5ed62f8a0ee96bd888d2efdfd6d4fb100a4eb"
|
||||
version = "v2.2.0"
|
||||
|
||||
[solve-meta]
|
||||
analyzer-name = "dep"
|
||||
analyzer-version = 1
|
||||
inputs-digest = "9d7a607d9b015e46c535126cbc62648ce78459507cd523d6ff0e048051cf3228"
|
||||
solver-name = "gps-cdcl"
|
||||
solver-version = 1
|
||||
50
Gopkg.toml
50
Gopkg.toml
@@ -1,50 +0,0 @@
|
||||
# Gopkg.toml example
|
||||
#
|
||||
# Refer to https://github.com/golang/dep/blob/master/docs/Gopkg.toml.md
|
||||
# for detailed Gopkg.toml documentation.
|
||||
#
|
||||
# required = ["github.com/user/thing/cmd/thing"]
|
||||
# ignored = ["github.com/user/project/pkgX", "bitbucket.org/user/project/pkgA/pkgY"]
|
||||
#
|
||||
# [[constraint]]
|
||||
# name = "github.com/user/project"
|
||||
# version = "1.0.0"
|
||||
#
|
||||
# [[constraint]]
|
||||
# name = "github.com/user/project2"
|
||||
# branch = "dev"
|
||||
# source = "github.com/myfork/project2"
|
||||
#
|
||||
# [[override]]
|
||||
# name = "github.com/x/y"
|
||||
# version = "2.4.0"
|
||||
#
|
||||
# [prune]
|
||||
# non-go = false
|
||||
# go-tests = true
|
||||
# unused-packages = true
|
||||
|
||||
|
||||
[prune]
|
||||
go-tests = true
|
||||
unused-packages = true
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/satori/go.uuid"
|
||||
version = "1.2.0"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/gorilla/mux"
|
||||
version = "1.6.1"
|
||||
|
||||
[[constraint]]
|
||||
branch = "master"
|
||||
name = "github.com/justinas/alice"
|
||||
|
||||
[[constraint]]
|
||||
name = "gopkg.in/yaml.v2"
|
||||
version = "2.2.0"
|
||||
|
||||
[[constraint]]
|
||||
name = "github.com/sirupsen/logrus"
|
||||
version = "1.0.5"
|
||||
30
Makefile
Normal file
30
Makefile
Normal file
@@ -0,0 +1,30 @@
|
||||
VERSION=snapshot
|
||||
GOLANG_VERSION=1.11.2
|
||||
|
||||
# generate documentation.
|
||||
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."
|
||||
|
||||
# gofmt and goimports all go files.
|
||||
fmt:
|
||||
go fmt ./...
|
||||
|
||||
# run all linters.
|
||||
lint:
|
||||
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t thecodingmachine/gotenberg:lint -f build/lint/Dockerfile .
|
||||
docker run --rm -it -v "$(PWD):/lint" thecodingmachine/gotenberg:lint
|
||||
|
||||
# run all tests.
|
||||
tests:
|
||||
docker build -t thecodingmachine/gotenberg:base -f build/base/Dockerfile .
|
||||
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) -t thecodingmachine/gotenberg:tests -f build/tests/Dockerfile .
|
||||
docker run --rm -it -v "$(PWD):/tests" thecodingmachine/gotenberg:tests
|
||||
|
||||
# build Docker image.
|
||||
image:
|
||||
docker build -t thecodingmachine/gotenberg:base -f build/base/Dockerfile .
|
||||
docker build --build-arg GOLANG_VERSION=$(GOLANG_VERSION) --build-arg VERSION=$(VERSION) -t thecodingmachine/gotenberg:$(VERSION) -f build/package/Dockerfile .
|
||||
|
||||
# start the API using previously built Docker image.
|
||||
gotenberg:
|
||||
docker run -it --rm -p "3000:3000" thecodingmachine/gotenberg:$(VERSION)
|
||||
138
README.md
138
README.md
@@ -1,136 +1,42 @@
|
||||
<p align="center">
|
||||
<img src="https://user-images.githubusercontent.com/8983173/38133342-11df3bd8-340f-11e8-9fe4-50baecdceeca.png" alt="Gotenberg's logo" width="250" height="250" />
|
||||
<img src="https://user-images.githubusercontent.com/8983173/49701110-4c8b9280-fbe8-11e8-895c-a4b9c7d2515b.png" alt="Gotenberg logo" width="250" height="250" />
|
||||
</p>
|
||||
<h3 align="center">Gotenberg</h3>
|
||||
<p align="center">A stateless API for converting Markdown files, HTML files and Office documents to PDF</p>
|
||||
<p align="center">
|
||||
<a href="https://microbadger.com/images/thecodingmachine/gotenberg:1.0.0">
|
||||
<img src="https://images.microbadger.com/badges/image/thecodingmachine/gotenberg:1.0.0.svg" alt="MicroBadger layers">
|
||||
</a>
|
||||
<a href="https://travis-ci.org/thecodingmachine/gotenberg">
|
||||
<img src="https://travis-ci.org/thecodingmachine/gotenberg.svg?branch=1.0.0" alt="Travis CI">
|
||||
</a>
|
||||
<a href="https://godoc.org/github.com/thecodingmachine/gotenberg">
|
||||
<img src="https://godoc.org/github.com/thecodingmachine/gotenberg?status.svg" alt="GoDoc">
|
||||
</a>
|
||||
<a href="https://goreportcard.com/report/thecodingmachine/gotenberg">
|
||||
<img src="https://goreportcard.com/badge/github.com/thecodingmachine/gotenberg" alt="Go Report Card">
|
||||
</a>
|
||||
<a href="https://codecov.io/gh/thecodingmachine/gotenberg/branch/1.0.0">
|
||||
<img src="https://codecov.io/gh/thecodingmachine/gotenberg/branch/1.0.0/graph/badge.svg" alt="Codecov">
|
||||
</a>
|
||||
</p>
|
||||
<p align="center">A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.</p>
|
||||
|
||||
---
|
||||
|
||||
At TheCodingMachine, we build a lot of web applications (intranets, extranets and so on) which require to generate PDF from
|
||||
various sources. Each time, we ended up using some well known libraries like **wkhtmltopdf** or **unoconv** and kind of lost time by
|
||||
reimplementing a solution from a project to another project. Meh.
|
||||
At TheCodingMachine, we build a lot of web applications (intranets, extranets and so on) which require to generate PDF from various sources. Each time, we ended up using some well known libraries and kind of lost time by reimplementing a solution from a project to another project. Meh.
|
||||
|
||||
# Menu
|
||||
## Features
|
||||
|
||||
* [Usage](#usage)
|
||||
* [Security](#security)
|
||||
* [Scalability](#scalability)
|
||||
* [Custom implementation](#custom-implementation)
|
||||
* [Clients](#clients)
|
||||
* 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)
|
||||
* 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
|
||||
* 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) libraries
|
||||
|
||||
## Usage
|
||||
## Quick start
|
||||
|
||||
Let's say you're starting the API using this simple command:
|
||||
|
||||
```sh
|
||||
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:1.0.0
|
||||
```
|
||||
|
||||
The API is now available on your host under `http://127.0.0.1:3000`.
|
||||
|
||||
It accepts `POST` requests with a `multipart/form-data` Content-Type. Your form data should provide one or more files to convert.
|
||||
It currently accepts the following:
|
||||
|
||||
* Markdown files
|
||||
* HTML files
|
||||
* Office documents (.docx, .doc, .odt, .pptx, .ppt, .odp and so on)
|
||||
* PDF files (if more than one file to convert)
|
||||
|
||||
**Heads up:** the API relies on the file extension to determine which library to use for conversion.
|
||||
|
||||
There are two use cases:
|
||||
|
||||
* If you send one file, it will convert it and return the resulting PDF
|
||||
* If many files, it will convert them to PDF, merge the resulting PDFs into a single PDF and return it
|
||||
|
||||
### Examples:
|
||||
|
||||
* One file
|
||||
|
||||
```sh
|
||||
$ curl --request POST \
|
||||
--url http://127.0.0.1:3000 \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form files=@file.docx \
|
||||
> result.pdf
|
||||
```
|
||||
|
||||
* Many files
|
||||
|
||||
```sh
|
||||
$ curl --request POST \
|
||||
--url http://127.0.0.1:3000 \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form files=@file.md \
|
||||
--form files=@file.html \
|
||||
--form files=@file.pdf \
|
||||
--form files=@file.docx \
|
||||
> result.pdf
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
The API does not provide any authentication mechanisms. Make sure to not put it on a public facing port and your client(s) should always
|
||||
controls what is sent to the API.
|
||||
|
||||
## Scalability
|
||||
|
||||
Some libraries like **unoconv** cannot perform concurrent conversions. That's why the API does only one conversion at a time.
|
||||
If your API is under heavy load, a request will take time to be processed.
|
||||
|
||||
Fortunately, you may pass through this limitation by scaling the API.
|
||||
|
||||
In the following example, I'll demonstrate how to do some vertical scaling (= on the same machine) with Docker Compose, but of course horizontal scaling works too!
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
|
||||
# your others services
|
||||
|
||||
gotenberg:
|
||||
image: gotenberg:1.0.0
|
||||
```
|
||||
|
||||
You may now launch your services using:
|
||||
Open a terminal and run the following command:
|
||||
|
||||
```bash
|
||||
docker-compose up --scale gotenberg=your_number_of_instances
|
||||
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:3
|
||||
```
|
||||
|
||||
When requesting the Gotenberg service with your client(s), Docker will automatically redirect a request to a Gotenberg container
|
||||
according to the round-robin strategy.
|
||||
The API is now available on your host at `http://localhost:3000`.
|
||||
|
||||
## Custom implementation
|
||||
Head to the [documentation](https://thecodingmachine.github.io/gotenberg)
|
||||
to learn how to interact with it!
|
||||
|
||||
The API relies on a simple YAML configuration file called `gotenberg.yml`. It allows you to tweak some values and even provides you
|
||||
a way to change the commands called for each kind of conversion. The configuration file should be located under `/gotenberg` in your container.
|
||||
## Badges
|
||||
|
||||
The default configuration is located here: [.ci/gotenberg.yml](.ci/gotenberg.yml)
|
||||
|
||||
## Clients
|
||||
|
||||
* https://github.com/thecodingmachine/gotenberg-php-client (PHP client)
|
||||
* Add your own client by submitting a [pull request](../../pulls)!
|
||||
[](https://microbadger.com/images/thecodingmachine/gotenberg:3)
|
||||
[](https://travis-ci.org/thecodingmachine/gotenberg)
|
||||
[](https://godoc.org/github.com/thecodingmachine/gotenberg)
|
||||
[](https://goreportcard.com/report/thecodingmachine/gotenberg)
|
||||
|
||||
---
|
||||
|
||||
Would you like to update this documentation ? Feel free to open an [issue](../../issues).
|
||||
Psst: TheCodingMachine is always looking for [talented coders](https://coders.thecodingmachine.com).
|
||||
|
||||
@@ -1,20 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: {
|
||||
value: [
|
||||
...
|
||||
}
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 0
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 0
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 0
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 0
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 0
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "DEBUG"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "text"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output {{ .ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} cat output {{ .ResultFilePath }}"
|
||||
@@ -1,17 +0,0 @@
|
||||
port: 3000
|
||||
logs:
|
||||
level: "DEBUG"
|
||||
format: "text"
|
||||
commands:
|
||||
markdown:
|
||||
timeout: 30
|
||||
template: "markdown-pdf {{ .FilePath }} -o {{ .ResultFilePath }}"
|
||||
html:
|
||||
timeout: 30
|
||||
template: "xvfb-run -e /dev/stdout wkhtmltopdf {{ .FilePath }} {{ .ResultFilePath }}"
|
||||
office:
|
||||
timeout: 30
|
||||
template: "unoconv --format pdf --output \"{{ ResultFilePath }}\" \"{{ .FilePath }}\""
|
||||
merge:
|
||||
timeout: 30
|
||||
template: "pdftk {{ range $filePath := .FilesPaths }} {{ $filePath }} {{ end }} cat output {{ .ResultFilePath }}"
|
||||
BIN
_tests/file.docx
BIN
_tests/file.docx
Binary file not shown.
@@ -1,16 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Some title</title>
|
||||
<meta name="author" content="Julien Neuhart">
|
||||
<meta name="description" content="Simple HTML page">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<p>Hi! I have been converted from an HTML page!</p>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -1 +0,0 @@
|
||||
# Hi, I've been converted from a markdown file!
|
||||
BIN
_tests/file.pdf
BIN
_tests/file.pdf
Binary file not shown.
@@ -1,254 +0,0 @@
|
||||
/*
|
||||
Package config contains all the logic allowing us to instantiate the application's configuration.
|
||||
|
||||
The application's configuration is loaded from a YAML file named gotenberg.yml.
|
||||
It should be located where the user starts the application from the CLI.
|
||||
*/
|
||||
package config
|
||||
|
||||
import (
|
||||
"io/ioutil"
|
||||
"text/template"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
type (
|
||||
// AppConfig gathers all data required to instantiate the application.
|
||||
AppConfig struct {
|
||||
// Port is the port which the application will listen to.
|
||||
Port string
|
||||
// Logs contains the logging configuration.
|
||||
Logs struct {
|
||||
// Level is the level of messages which will be logged.
|
||||
Level logrus.Level
|
||||
// Formatter defines the logging format when a TTY is not attached.
|
||||
Formatter logrus.Formatter
|
||||
}
|
||||
// CommandsConfig is... an instance of CommandsConfig.
|
||||
CommandsConfig *CommandsConfig
|
||||
}
|
||||
|
||||
// CommandsConfig gathers all commands' configurations as defined
|
||||
// by the user in the gotenberg.yml file.
|
||||
CommandsConfig struct {
|
||||
// Markdown is the command's configuration for converting
|
||||
// an Markdown file to PDF.
|
||||
Markdown *CommandConfig
|
||||
// HTML is the command's configuration for converting
|
||||
// an HTML file to PDF.
|
||||
HTML *CommandConfig
|
||||
// Office is the command's configuration for converting
|
||||
// an Office document to PDF.
|
||||
Office *CommandConfig
|
||||
// Merge is the command's configuration for merging
|
||||
// multiple PDF files into one PDF file.
|
||||
Merge *CommandConfig
|
||||
}
|
||||
|
||||
// CommandConfig is a command's configuration.
|
||||
CommandConfig struct {
|
||||
// Timeout is the duration in seconds after which the command's process will be killed
|
||||
// if it does not finish before.
|
||||
Timeout int
|
||||
// Template is the data-driven template of the command.
|
||||
Template *template.Template
|
||||
}
|
||||
)
|
||||
|
||||
// NewAppConfig instantiates the application's configuration.
|
||||
// If something bad happens here, the application should not start.
|
||||
func NewAppConfig(configurationFilePath string) (*AppConfig, error) {
|
||||
fileConfig, err := loadFileConfig(configurationFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
c := &AppConfig{}
|
||||
c.Port = fileConfig.Port
|
||||
|
||||
if err := makeLogs(c, fileConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := makeCommandsConfig(c, fileConfig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// fileConfig gathers all data coming from the configuration file gotenberg.yml.
|
||||
type fileConfig struct {
|
||||
Port string `yaml:"port"`
|
||||
Logs struct {
|
||||
Level string `yaml:"level"`
|
||||
Format string `yaml:"format"`
|
||||
} `yaml:"logs"`
|
||||
Commands struct {
|
||||
Markdown struct {
|
||||
Timeout int `yaml:"timeout"`
|
||||
Template string `yaml:"template"`
|
||||
} `yaml:"markdown"`
|
||||
HTML struct {
|
||||
Timeout int
|
||||
Template string
|
||||
} `yaml:"html"`
|
||||
Office struct {
|
||||
Timeout int `yaml:"timeout"`
|
||||
Template string `yaml:"template"`
|
||||
} `yaml:"office"`
|
||||
Merge struct {
|
||||
Timeout int `yaml:"timeout"`
|
||||
Template string `yaml:"template"`
|
||||
} `yaml:"merge"`
|
||||
} `yaml:"commands"`
|
||||
}
|
||||
|
||||
// loadFileConfig instantiates a fileConfig instance by loading
|
||||
// the configuration file gotenberg.yml.
|
||||
func loadFileConfig(configurationFilePath string) (*fileConfig, error) {
|
||||
c := &fileConfig{}
|
||||
|
||||
data, err := ioutil.ReadFile(configurationFilePath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if err := yaml.Unmarshal(data, &c); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// makeLogs is a simple wrapper which populates all data related
|
||||
// to application's logging.
|
||||
func makeLogs(appConfig *AppConfig, fileConfig *fileConfig) error {
|
||||
lvl, err := getLoggingLevelFromFileConfig(fileConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
formatter, err := getLoggingFormatterFromFileConfig(fileConfig)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appConfig.Logs.Level = lvl
|
||||
appConfig.Logs.Formatter = formatter
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// levels associates logging levels as defined in the configuration file gotenberg.yml
|
||||
// with its counterpart from the logrus library.
|
||||
var levels = map[string]logrus.Level{
|
||||
"DEBUG": logrus.DebugLevel,
|
||||
"INFO": logrus.InfoLevel,
|
||||
"WARN": logrus.WarnLevel,
|
||||
"ERROR": logrus.ErrorLevel,
|
||||
"FATAL": logrus.FatalLevel,
|
||||
"PANIC": logrus.PanicLevel,
|
||||
}
|
||||
|
||||
type wrongLoggingLevelError struct{}
|
||||
|
||||
const wrongLoggingLevelErrorMessage = "Accepted values for logging level: DEBUG, INFO, WARN, ERROR, FATAL, PANIC"
|
||||
|
||||
func (e *wrongLoggingLevelError) Error() string {
|
||||
return wrongLoggingLevelErrorMessage
|
||||
}
|
||||
|
||||
// getLoggingLevelFromFileConfig returns a logrus level if a matching was found
|
||||
// with the one defined by the user.
|
||||
// If no match, throws an error.
|
||||
func getLoggingLevelFromFileConfig(c *fileConfig) (logrus.Level, error) {
|
||||
l, ok := levels[c.Logs.Level]
|
||||
if !ok {
|
||||
return 999, &wrongLoggingLevelError{}
|
||||
}
|
||||
|
||||
return l, nil
|
||||
}
|
||||
|
||||
// levels associates logging formats as defined in the configuration file gotenberg.yml
|
||||
// with its counterpart from the logrus library.
|
||||
var formatters = map[string]logrus.Formatter{
|
||||
"text": &logrus.TextFormatter{},
|
||||
"json": &logrus.JSONFormatter{},
|
||||
}
|
||||
|
||||
type wrongLoggingFormatError struct{}
|
||||
|
||||
const wrongLoggingFormatErrorMessage = "Accepted value for logging format: text, json"
|
||||
|
||||
func (e *wrongLoggingFormatError) Error() string {
|
||||
return wrongLoggingFormatErrorMessage
|
||||
}
|
||||
|
||||
// getLoggingLevelFromFileConfig returns a logrus Formatter if a matching was found
|
||||
// with the format defined by the user.
|
||||
// If no match, throws an error.
|
||||
func getLoggingFormatterFromFileConfig(c *fileConfig) (logrus.Formatter, error) {
|
||||
f, ok := formatters[c.Logs.Format]
|
||||
if !ok {
|
||||
return nil, &wrongLoggingFormatError{}
|
||||
}
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// makeCommandsConfigs is a simple wrapper which populates all data related
|
||||
// to commands' configurations.
|
||||
func makeCommandsConfig(appConfig *AppConfig, fileConfig *fileConfig) error {
|
||||
appConfig.CommandsConfig = &CommandsConfig{}
|
||||
appConfig.CommandsConfig.Markdown = &CommandConfig{}
|
||||
appConfig.CommandsConfig.HTML = &CommandConfig{}
|
||||
appConfig.CommandsConfig.Office = &CommandConfig{}
|
||||
appConfig.CommandsConfig.Merge = &CommandConfig{}
|
||||
|
||||
appConfig.CommandsConfig.Markdown.Timeout = fileConfig.Commands.Markdown.Timeout
|
||||
appConfig.CommandsConfig.HTML.Timeout = fileConfig.Commands.HTML.Timeout
|
||||
appConfig.CommandsConfig.Office.Timeout = fileConfig.Commands.Office.Timeout
|
||||
appConfig.CommandsConfig.Merge.Timeout = fileConfig.Commands.Merge.Timeout
|
||||
|
||||
tmplMarkdown, err := getCommandTemplate(fileConfig.Commands.Markdown.Template, "Markdown")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmplHTML, err := getCommandTemplate(fileConfig.Commands.HTML.Template, "HTML")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmplOffice, err := getCommandTemplate(fileConfig.Commands.Office.Template, "Office")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
tmplMerge, err := getCommandTemplate(fileConfig.Commands.Merge.Template, "Merge")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
appConfig.CommandsConfig.Markdown.Template = tmplMarkdown
|
||||
appConfig.CommandsConfig.HTML.Template = tmplHTML
|
||||
appConfig.CommandsConfig.Office.Template = tmplOffice
|
||||
appConfig.CommandsConfig.Merge.Template = tmplMerge
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCommandTemplate is a simple helper for parsing a command template as defined by the user.
|
||||
// If the user gives us a wrong template, throws an error.
|
||||
func getCommandTemplate(command string, commandName string) (*template.Template, error) {
|
||||
t, err := template.New(commandName).Parse(command)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
@@ -1,77 +0,0 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewAppConfig(t *testing.T) {
|
||||
var path string
|
||||
|
||||
// case 1: uses an empty configuration file path.
|
||||
if _, err := NewAppConfig(""); err == nil {
|
||||
t.Error("AppConfig should not have been instantiated by using an empty configuration file path")
|
||||
}
|
||||
|
||||
// case 2: uses a broken configuration file.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/broken-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 3: uses a configuration file with a wrong logging level.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-logging-level-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 4: uses a configuration file with a wrong logging format.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-logging-format-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 5: uses a configuration file with a wrong markdown command template.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-markdown-command-template-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 6: uses a configuration file with a wrong HTML command template.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-html-command-template-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 7: uses a configuration file with a wrong Office command template.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-office-command-template-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 8: uses a configuration file with a wrong merge command template.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/wrong-merge-command-template-gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err == nil {
|
||||
t.Errorf("AppConfig should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 9: uses a correct configuration file.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
|
||||
if _, err := NewAppConfig(path); err != nil {
|
||||
t.Errorf("AppConfig should have been instantiated with '%s'", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongLoggingLevelError(t *testing.T) {
|
||||
err := &wrongLoggingLevelError{}
|
||||
if err.Error() != wrongLoggingLevelErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), wrongLoggingLevelErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWrongLoggingFormatError(t *testing.T) {
|
||||
err := &wrongLoggingFormatError{}
|
||||
if err.Error() != wrongLoggingFormatErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), wrongLoggingFormatErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
// Package context provides functions for setting and getting values from
|
||||
// a request's context.
|
||||
package context
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net/http"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/converter"
|
||||
)
|
||||
|
||||
type key uint32
|
||||
|
||||
const (
|
||||
converterKey key = iota
|
||||
resultFilePathKey
|
||||
)
|
||||
|
||||
// WithConverter populates a request's context with the given converter
|
||||
// and returns the updated request.
|
||||
func WithConverter(r *http.Request, converter *converter.Converter) *http.Request {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, converterKey, converter)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type converterNotFoundError struct{}
|
||||
|
||||
const converterNotFoundErrorMessage = "The converter was not found in request context"
|
||||
|
||||
func (e *converterNotFoundError) Error() string {
|
||||
return converterNotFoundErrorMessage
|
||||
}
|
||||
|
||||
// GetConverter returns the converter if found in
|
||||
// the request's context. Otherwise throws an error.
|
||||
func GetConverter(r *http.Request) (*converter.Converter, error) {
|
||||
c, ok := r.Context().Value(converterKey).(*converter.Converter)
|
||||
if !ok {
|
||||
return nil, &converterNotFoundError{}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// WithResultFilePath populates a request's context with the given result file path
|
||||
// and returns the updated request.
|
||||
func WithResultFilePath(r *http.Request, resultFilePath string) *http.Request {
|
||||
ctx := r.Context()
|
||||
ctx = context.WithValue(ctx, resultFilePathKey, resultFilePath)
|
||||
r = r.WithContext(ctx)
|
||||
|
||||
return r
|
||||
}
|
||||
|
||||
type resultFilePathNotFoundError struct{}
|
||||
|
||||
const resultFilePathNotFoundErrorMessage = "The result file path was not found in request context"
|
||||
|
||||
func (e *resultFilePathNotFoundError) Error() string {
|
||||
return resultFilePathNotFoundErrorMessage
|
||||
}
|
||||
|
||||
// GetResultFilePath returns the result file path if found in
|
||||
// the request's context. Otherwise throws an error.
|
||||
func GetResultFilePath(r *http.Request) (string, error) {
|
||||
path, ok := r.Context().Value(resultFilePathKey).(string)
|
||||
if !ok {
|
||||
return "", &resultFilePathNotFoundError{}
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
package context
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/converter"
|
||||
)
|
||||
|
||||
func TestWithConverter(t *testing.T) {
|
||||
req := WithConverter(httptest.NewRequest(http.MethodPost, "/", nil), &converter.Converter{})
|
||||
if c, _ := req.Context().Value(converterKey).(*converter.Converter); c == nil {
|
||||
t.Errorf("Context returned a wrong converter: got '%v' want not nil", c)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConverter(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
// case 1: uses a request without a converter entry in its context.
|
||||
if _, err := GetConverter(req); err == nil {
|
||||
t.Error("Context should not have a converter entry")
|
||||
}
|
||||
|
||||
// case 2: uses a request with a converter entry in its context.
|
||||
req = WithConverter(req, &converter.Converter{})
|
||||
if _, err := GetConverter(req); err != nil {
|
||||
t.Error("Context should have a converter entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithResultFilePath(t *testing.T) {
|
||||
filePath := "file.pdf"
|
||||
req := WithResultFilePath(httptest.NewRequest(http.MethodPost, "/", nil), filePath)
|
||||
if path, _ := req.Context().Value(resultFilePathKey).(string); path != filePath {
|
||||
t.Errorf("Context returned a wrong result file path: got '%s' want '%s'", path, filePath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetResultFilePath(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
// case 1: uses a request without a result file path entry in its context.
|
||||
if _, err := GetResultFilePath(req); err == nil {
|
||||
t.Error("Context should not have a result file path entry")
|
||||
}
|
||||
|
||||
// case 2: uses a request with a result file path entry in its context.
|
||||
req = WithResultFilePath(req, "file.pdf")
|
||||
if _, err := GetResultFilePath(req); err != nil {
|
||||
t.Error("Context should have a result file path entry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConverterNotFoundError(t *testing.T) {
|
||||
err := &converterNotFoundError{}
|
||||
if err.Error() != converterNotFoundErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), converterNotFoundErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResultFilePathNotFoundError(t *testing.T) {
|
||||
err := &resultFilePathNotFoundError{}
|
||||
if err.Error() != resultFilePathNotFoundErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), resultFilePathNotFoundErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,105 +0,0 @@
|
||||
// Package converter implements a solution for converting one or more files to PDF.
|
||||
package converter
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
|
||||
"github.com/thecodingmachine/gotenberg/app/converter/process"
|
||||
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
// Converter handles conversion into PDF of files coming from a request.
|
||||
type Converter struct {
|
||||
files []*gfile.File
|
||||
workingDir string
|
||||
}
|
||||
|
||||
// NoFileToConvertError is raided when a request has not file attached to it.
|
||||
type NoFileToConvertError struct{}
|
||||
|
||||
const noFileToConvertErrorMessage = "No file to convert"
|
||||
|
||||
func (e *NoFileToConvertError) Error() string {
|
||||
return noFileToConvertErrorMessage
|
||||
}
|
||||
|
||||
// NewConverter instantiates a converter by parsing a request.
|
||||
func NewConverter(r *http.Request) (*Converter, error) {
|
||||
c := &Converter{
|
||||
workingDir: fmt.Sprintf("./%s/", uuid.NewV4().String()),
|
||||
}
|
||||
|
||||
if err := os.Mkdir(c.workingDir, 0666); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
reader, err := r.MultipartReader()
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
for {
|
||||
part, err := reader.NextPart()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
|
||||
fileName := part.FileName()
|
||||
if fileName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
f, err := gfile.NewFile(c.workingDir, part, fileName)
|
||||
if err != nil {
|
||||
return c, err
|
||||
}
|
||||
|
||||
c.files = append(c.files, f)
|
||||
}
|
||||
|
||||
if len(c.files) == 0 {
|
||||
return c, &NoFileToConvertError{}
|
||||
}
|
||||
|
||||
return c, nil
|
||||
}
|
||||
|
||||
// Convert converts its associated files to PDF. If more than one file,
|
||||
// it will merge all of them into one unique PDF file.
|
||||
// Returns the new file path or an error if something bad happened.
|
||||
func (c *Converter) Convert() (string, error) {
|
||||
var filesPaths []string
|
||||
for _, f := range c.files {
|
||||
if f.Type != gfile.PDFType {
|
||||
path, err := process.Unconv(c.workingDir, f)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
filesPaths = append(filesPaths, path)
|
||||
} else {
|
||||
filesPaths = append(filesPaths, f.Path)
|
||||
}
|
||||
}
|
||||
|
||||
if len(filesPaths) == 1 {
|
||||
return filesPaths[0], nil
|
||||
}
|
||||
|
||||
path, err := process.Merge(c.workingDir, filesPaths)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// Clear removes all file inside its working directory.
|
||||
func (c *Converter) Clear() error {
|
||||
return os.RemoveAll(c.workingDir)
|
||||
}
|
||||
@@ -1,147 +0,0 @@
|
||||
package converter
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/config"
|
||||
"github.com/thecodingmachine/gotenberg/app/converter/process"
|
||||
)
|
||||
|
||||
func makeRequest(filesPaths ...string) *http.Request {
|
||||
r, w := io.Pipe()
|
||||
mpw := multipart.NewWriter(w)
|
||||
|
||||
go func() {
|
||||
var part io.Writer
|
||||
defer w.Close()
|
||||
|
||||
if len(filesPaths) == 0 {
|
||||
part, _ = mpw.CreateFormField("foo")
|
||||
part.Write([]byte("bar"))
|
||||
} else {
|
||||
for _, filePath := range filesPaths {
|
||||
file, _ := os.Open(filePath)
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, _ := file.Stat()
|
||||
part, _ = mpw.CreateFormFile("files", fileInfo.Name())
|
||||
io.Copy(part, file)
|
||||
}
|
||||
}
|
||||
|
||||
mpw.Close()
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", r)
|
||||
req.Header.Set("Content-Type", mpw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func loadCommandConfigs(configurationFilePath string) {
|
||||
path, _ := filepath.Abs(configurationFilePath)
|
||||
c, _ := config.NewAppConfig(path)
|
||||
process.Load(c.CommandsConfig)
|
||||
}
|
||||
|
||||
func TestNewConverter(t *testing.T) {
|
||||
var (
|
||||
path string
|
||||
oPath string
|
||||
)
|
||||
|
||||
// case 1: uses a request with a single file.
|
||||
path, _ = filepath.Abs("../../_tests/file.docx")
|
||||
if _, err := NewConverter(makeRequest(path)); err != nil {
|
||||
t.Errorf("Converter should have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 2: uses a request with wrong file type.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
|
||||
if _, err := NewConverter(makeRequest(path)); err == nil {
|
||||
t.Errorf("Converter should not have been instantiated with '%s'", path)
|
||||
}
|
||||
|
||||
// case 3: uses a request with two files.
|
||||
path, _ = filepath.Abs("../../_tests/file.pdf")
|
||||
oPath, _ = filepath.Abs("../../_tests/file.docx")
|
||||
if _, err := NewConverter(makeRequest(path, oPath)); err != nil {
|
||||
t.Errorf("Converter should have been instantiated with '%s' and '%s'", path, oPath)
|
||||
}
|
||||
|
||||
// case 4: uses a request with one Office file type and one wrong file type.
|
||||
path, _ = filepath.Abs("../../_tests/configurations/gotenberg.yml")
|
||||
oPath, _ = filepath.Abs("../../_tests/file.docx")
|
||||
if _, err := NewConverter(makeRequest(path, oPath)); err == nil {
|
||||
t.Errorf("Converter should not have been instantiated with '%s' and '%s'", path, oPath)
|
||||
}
|
||||
|
||||
// case 5: uses a request with no file.
|
||||
if _, err := NewConverter(makeRequest()); err == nil {
|
||||
t.Error("Converter should not have been instantiated with no file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvert(t *testing.T) {
|
||||
var (
|
||||
path string
|
||||
oPath string
|
||||
c *Converter
|
||||
)
|
||||
|
||||
loadCommandConfigs("../../_tests/configurations/gotenberg.yml")
|
||||
|
||||
// case 1: uses a request with a single file.
|
||||
path, _ = filepath.Abs("../../_tests/file.docx")
|
||||
c, _ = NewConverter(makeRequest(path))
|
||||
if _, err := c.Convert(); err != nil {
|
||||
t.Errorf("Converter should have been able to convert '%s' to PDF", path)
|
||||
}
|
||||
|
||||
// case 2: uses a request with two files.
|
||||
path, _ = filepath.Abs("../../_tests/file.pdf")
|
||||
oPath, _ = filepath.Abs("../../_tests/file.docx")
|
||||
c, _ = NewConverter(makeRequest(path, oPath))
|
||||
if _, err := c.Convert(); err != nil {
|
||||
t.Errorf("Converter should have been able to convert '%s' and '%s' to PDF", path, oPath)
|
||||
}
|
||||
|
||||
loadCommandConfigs("../../_tests/configurations/timeout-gotenberg.yml")
|
||||
|
||||
// case 3: uses a request with a single file and a configuration with an unsuitable timeout for the conversion commands.
|
||||
path, _ = filepath.Abs("../../_tests/file.docx")
|
||||
c, _ = NewConverter(makeRequest(path))
|
||||
if _, err := c.Convert(); err == nil {
|
||||
t.Errorf("Converter should not have been able to convert '%s' to PDF", path)
|
||||
}
|
||||
|
||||
loadCommandConfigs("../../_tests/configurations/merge-timeout-gotenberg.yml")
|
||||
|
||||
// case 4: uses a request with two files and a configuration with an unsuitable timeout for the merge command.
|
||||
path, _ = filepath.Abs("../../_tests/file.pdf")
|
||||
oPath, _ = filepath.Abs("../../_tests/file.docx")
|
||||
c, _ = NewConverter(makeRequest(path, oPath))
|
||||
if _, err := c.Convert(); err == nil {
|
||||
t.Errorf("Converter should not have been able to merge '%s' and '%s' into PDF", path, oPath)
|
||||
}
|
||||
}
|
||||
|
||||
func TestClear(t *testing.T) {
|
||||
path, _ := filepath.Abs("../../_tests/file.docx")
|
||||
c, _ := NewConverter(makeRequest(path))
|
||||
if err := c.Clear(); err != nil {
|
||||
t.Error("Converter should have been able to clear itself")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoFileToConvertError(t *testing.T) {
|
||||
err := &NoFileToConvertError{}
|
||||
if err.Error() != noFileToConvertErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), noFileToConvertErrorMessage)
|
||||
}
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
// Package file implements a solution for handling files coming from a request.
|
||||
package file
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/satori/go.uuid"
|
||||
)
|
||||
|
||||
// File represents a file which has been created
|
||||
// from a request.
|
||||
type File struct {
|
||||
// Type is the kind of file.
|
||||
Type Type
|
||||
// Path is the file path.
|
||||
Path string
|
||||
}
|
||||
|
||||
// Type represents what kind of file we're dealing with.
|
||||
type Type uint32
|
||||
|
||||
const (
|
||||
// PDFType represents a... PDF file.
|
||||
PDFType Type = iota
|
||||
// MarkdownType represents a... Markdown file.
|
||||
MarkdownType
|
||||
// HTMLType represents an... HTML file.
|
||||
HTMLType
|
||||
// OfficeType represents an... Office document.
|
||||
OfficeType
|
||||
)
|
||||
|
||||
// filesTypes associates a file extension with its file kind counterpart.
|
||||
var filesTypes = map[string]Type{
|
||||
".pdf": PDFType,
|
||||
".md": MarkdownType,
|
||||
".htm": HTMLType,
|
||||
".html": HTMLType,
|
||||
".doc": OfficeType,
|
||||
".docx": OfficeType,
|
||||
".odt": OfficeType,
|
||||
".xls": OfficeType,
|
||||
".xlsx": OfficeType,
|
||||
".ods": OfficeType,
|
||||
".ppt": OfficeType,
|
||||
".pptx": OfficeType,
|
||||
".odp": OfficeType,
|
||||
}
|
||||
|
||||
type fileTypeNotFoundError struct {
|
||||
fileName string
|
||||
}
|
||||
|
||||
func (e *fileTypeNotFoundError) Error() string {
|
||||
return fmt.Sprintf("File type was not found for '%s'", e.fileName)
|
||||
}
|
||||
|
||||
// NewFile creates a file in the considered directory.
|
||||
// Returns a *File instance or an error if something bad happened.
|
||||
func NewFile(workingDir string, r io.Reader, fileName string) (*File, error) {
|
||||
ext := filepath.Ext(fileName)
|
||||
|
||||
t, ok := filesTypes[ext]
|
||||
if !ok {
|
||||
return nil, &fileTypeNotFoundError{fileName: fileName}
|
||||
}
|
||||
|
||||
f := &File{
|
||||
Path: MakeFilePath(workingDir, ext),
|
||||
Type: t,
|
||||
}
|
||||
|
||||
file, err := os.Create(f.Path)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
defer file.Close()
|
||||
|
||||
_, err = io.Copy(file, r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// resets the read pointer.
|
||||
file.Seek(0, 0)
|
||||
|
||||
return f, nil
|
||||
}
|
||||
|
||||
// MakeFilePath is a simple helper which generates a random file name
|
||||
// and associates it with the considered directory to make a path.
|
||||
func MakeFilePath(workingDir string, ext string) string {
|
||||
return fmt.Sprintf("%s%s%s", workingDir, uuid.NewV4().String(), ext)
|
||||
}
|
||||
@@ -1,37 +0,0 @@
|
||||
package file
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNewFile(t *testing.T) {
|
||||
workingDir := "test"
|
||||
os.Mkdir(workingDir, 0666)
|
||||
|
||||
// case 1: uses a wrong file name.
|
||||
if _, err := NewFile(workingDir, new(bytes.Buffer), "file.yml"); err == nil {
|
||||
t.Error("File should not have been instantiated with an empty buffer")
|
||||
}
|
||||
|
||||
// case 2: uses a reader from a correct file type.
|
||||
filePath, _ := filepath.Abs("../../../_tests/file.pdf")
|
||||
r, _ := os.Open(filePath)
|
||||
defer r.Close()
|
||||
if _, err := NewFile(workingDir, r, "file.pdf"); err != nil {
|
||||
t.Errorf("File should have been instantiated using a reader from '%s'", filePath)
|
||||
}
|
||||
|
||||
os.RemoveAll(workingDir)
|
||||
}
|
||||
func TestFileTypeNotFoundError(t *testing.T) {
|
||||
fileName := "file.wp"
|
||||
err := &fileTypeNotFoundError{fileName: fileName}
|
||||
expected := fmt.Sprintf("File type was not found for '%s'", fileName)
|
||||
if err.Error() != expected {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
// Package process handles all commands executions.
|
||||
package process
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"os/exec"
|
||||
"sync"
|
||||
"text/template"
|
||||
"time"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/config"
|
||||
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
|
||||
)
|
||||
|
||||
type runner struct {
|
||||
mu sync.Mutex
|
||||
commandsConfig *config.CommandsConfig
|
||||
}
|
||||
|
||||
var forest = &runner{}
|
||||
|
||||
type commandTimeoutError struct {
|
||||
command string
|
||||
timeout int
|
||||
}
|
||||
|
||||
func (e *commandTimeoutError) Error() string {
|
||||
return fmt.Sprintf("The command '%s' has reached the %d second(s) timeout", e.command, e.timeout)
|
||||
}
|
||||
|
||||
// run runs the given command. If timeout is reached or
|
||||
// something bad happened, returns an error.
|
||||
func (r *runner) run(command string, timeout int) error {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
|
||||
cmd := exec.Command("/bin/sh", "-c", command)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
done <- cmd.Wait()
|
||||
}()
|
||||
|
||||
// wait for the process to finish or kill it after a timeout.
|
||||
select {
|
||||
case <-time.After(time.Duration(timeout) * time.Second):
|
||||
if err := cmd.Process.Kill(); err != nil {
|
||||
return err
|
||||
}
|
||||
return &commandTimeoutError{
|
||||
command: command,
|
||||
timeout: timeout,
|
||||
}
|
||||
case err := <-done:
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Load loads the commands configuration coming from the application configuration.
|
||||
func Load(config *config.CommandsConfig) {
|
||||
forest.commandsConfig = config
|
||||
}
|
||||
|
||||
// conversionData will be applied to the data-driven templates of conversions commands.
|
||||
type conversionData struct {
|
||||
FilePath string
|
||||
ResultFilePath string
|
||||
}
|
||||
|
||||
type impossibleConversionError struct{}
|
||||
|
||||
const impossibleConversionErrorMessage = "Impossible conversion"
|
||||
|
||||
func (e *impossibleConversionError) Error() string {
|
||||
return impossibleConversionErrorMessage
|
||||
}
|
||||
|
||||
// Unconv converts a file to PDF and returns the new file path.
|
||||
func Unconv(workingDir string, file *gfile.File) (string, error) {
|
||||
cmdData := &conversionData{
|
||||
FilePath: file.Path,
|
||||
ResultFilePath: gfile.MakeFilePath(workingDir, ".pdf"),
|
||||
}
|
||||
|
||||
var (
|
||||
cmdTimeout int
|
||||
cmdTemplate *template.Template
|
||||
)
|
||||
|
||||
switch file.Type {
|
||||
case gfile.MarkdownType:
|
||||
cmdTimeout = forest.commandsConfig.Markdown.Timeout
|
||||
cmdTemplate = forest.commandsConfig.Markdown.Template
|
||||
break
|
||||
case gfile.HTMLType:
|
||||
cmdTimeout = forest.commandsConfig.HTML.Timeout
|
||||
cmdTemplate = forest.commandsConfig.HTML.Template
|
||||
break
|
||||
case gfile.OfficeType:
|
||||
cmdTimeout = forest.commandsConfig.Office.Timeout
|
||||
cmdTemplate = forest.commandsConfig.Office.Template
|
||||
break
|
||||
default:
|
||||
return "", &impossibleConversionError{}
|
||||
}
|
||||
|
||||
var data bytes.Buffer
|
||||
if err := cmdTemplate.Execute(&data, cmdData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err := forest.run(data.String(), cmdTimeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cmdData.ResultFilePath, nil
|
||||
}
|
||||
|
||||
// mergeData will be applied to the data-driven template of the merge command.
|
||||
type mergeData struct {
|
||||
FilesPaths []string
|
||||
ResultFilePath string
|
||||
}
|
||||
|
||||
// Merge merges many PDF files to one unique PDF file and returns the new file path.
|
||||
func Merge(workingDir string, filesPaths []string) (string, error) {
|
||||
cmdData := &mergeData{
|
||||
FilesPaths: filesPaths,
|
||||
ResultFilePath: gfile.MakeFilePath(workingDir, ".pdf"),
|
||||
}
|
||||
|
||||
cmdTimeout := forest.commandsConfig.Merge.Timeout
|
||||
cmdTemplate := forest.commandsConfig.Merge.Template
|
||||
|
||||
var data bytes.Buffer
|
||||
if err := cmdTemplate.Execute(&data, cmdData); err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
err := forest.run(data.String(), cmdTimeout)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
return cmdData.ResultFilePath, nil
|
||||
}
|
||||
@@ -1,148 +0,0 @@
|
||||
package process
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/config"
|
||||
gfile "github.com/thecodingmachine/gotenberg/app/converter/file"
|
||||
)
|
||||
|
||||
func makeFile(workingDir string, fileName string) *gfile.File {
|
||||
filePath := fmt.Sprintf("%s%s", "../../../_tests/", fileName)
|
||||
absPath, _ := filepath.Abs(filePath)
|
||||
|
||||
r, _ := os.Open(absPath)
|
||||
defer r.Close()
|
||||
|
||||
f, _ := gfile.NewFile(workingDir, r, fileName)
|
||||
|
||||
return f
|
||||
}
|
||||
|
||||
func loadCommandConfigs(configurationFilePath string) {
|
||||
path, _ := filepath.Abs(configurationFilePath)
|
||||
c, _ := config.NewAppConfig(path)
|
||||
Load(c.CommandsConfig)
|
||||
}
|
||||
|
||||
func TestLoad(t *testing.T) {
|
||||
path, _ := filepath.Abs("../../../_tests/configurations/gotenberg.yml")
|
||||
c, _ := config.NewAppConfig(path)
|
||||
Load(c.CommandsConfig)
|
||||
|
||||
if c.CommandsConfig != forest.commandsConfig {
|
||||
t.Error("Commands configuration should have been loaded correctly")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun(t *testing.T) {
|
||||
var cmd string
|
||||
|
||||
// case 1: uses a simple command.
|
||||
cmd = "echo Hello world"
|
||||
if err := forest.run(cmd, 30); err != nil {
|
||||
t.Errorf("Command '%s' should have worked", cmd)
|
||||
}
|
||||
|
||||
// case 2: uses a simple command but with an unsuitable timeout.
|
||||
cmd = "sleep 5"
|
||||
if err := forest.run(cmd, 0); err == nil {
|
||||
t.Errorf("Command '%s' should not have worked", cmd)
|
||||
}
|
||||
|
||||
// case 3: uses a broken command.
|
||||
cmd = "helloworld"
|
||||
if err := forest.run(cmd, 30); err == nil {
|
||||
t.Errorf("Command '%s' should not have worked", cmd)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnconv(t *testing.T) {
|
||||
var file *gfile.File
|
||||
|
||||
workingDir := "test"
|
||||
os.Mkdir(workingDir, 0666)
|
||||
|
||||
loadCommandConfigs("../../../_tests/configurations/gotenberg.yml")
|
||||
|
||||
// case 1: uses an Markdown file type.
|
||||
file = makeFile(workingDir, "file.md")
|
||||
if _, err := Unconv(workingDir, file); err != nil {
|
||||
t.Errorf("Converting '%s' to PDF should have worked", file.Path)
|
||||
}
|
||||
|
||||
// case 2: uses an HTML file type.
|
||||
file = makeFile(workingDir, "file.html")
|
||||
if _, err := Unconv(workingDir, file); err != nil {
|
||||
t.Errorf("Converting '%s' to PDF should have worked", file.Path)
|
||||
}
|
||||
|
||||
// case 3: uses an Office file type.
|
||||
file = makeFile(workingDir, "file.docx")
|
||||
if _, err := Unconv(workingDir, file); err != nil {
|
||||
t.Errorf("Converting '%s' to PDF should have worked", file.Path)
|
||||
}
|
||||
|
||||
// case 4: uses a PDF file type.
|
||||
file = makeFile(workingDir, "file.pdf")
|
||||
if _, err := Unconv(workingDir, file); err == nil {
|
||||
t.Errorf("Converting '%s' to PDF should not have worked", file.Path)
|
||||
}
|
||||
|
||||
loadCommandConfigs("../../../_tests/configurations/timeout-gotenberg.yml")
|
||||
|
||||
// case 5: uses a command with an unsuitable timeout.
|
||||
file = makeFile(workingDir, "file.docx")
|
||||
if _, err := Unconv(workingDir, makeFile(workingDir, "file.docx")); err == nil {
|
||||
t.Errorf("Converting '%s' to PDF should have reached timeout", file.Path)
|
||||
}
|
||||
|
||||
os.RemoveAll(workingDir)
|
||||
}
|
||||
|
||||
func TestMerge(t *testing.T) {
|
||||
workingDir := "test"
|
||||
os.Mkdir(workingDir, 0666)
|
||||
|
||||
loadCommandConfigs("../../../_tests/configurations/gotenberg.yml")
|
||||
|
||||
var filesPaths []string
|
||||
path, _ := filepath.Abs("../../../_tests/file.pdf")
|
||||
filesPaths = append(filesPaths, path)
|
||||
filesPaths = append(filesPaths, path)
|
||||
|
||||
// case 1: simple merge.
|
||||
if _, err := Merge(workingDir, filesPaths); err != nil {
|
||||
t.Error("Merge should have worked")
|
||||
}
|
||||
|
||||
loadCommandConfigs("../../../_tests/configurations/timeout-gotenberg.yml")
|
||||
|
||||
// case 2: uses a command with an unsuitable timeout.
|
||||
if _, err := Merge(workingDir, filesPaths); err == nil {
|
||||
t.Error("Merge should have reached timeout")
|
||||
}
|
||||
|
||||
os.RemoveAll(workingDir)
|
||||
}
|
||||
|
||||
func TestImpossibleConversionError(t *testing.T) {
|
||||
err := &impossibleConversionError{}
|
||||
if err.Error() != impossibleConversionErrorMessage {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), impossibleConversionErrorMessage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommandTimeoutError(t *testing.T) {
|
||||
err := &commandTimeoutError{
|
||||
command: "echo hello",
|
||||
timeout: 30,
|
||||
}
|
||||
expected := fmt.Sprintf("The command '%s' has reached the %d second(s) timeout", err.command, err.timeout)
|
||||
if err.Error() != expected {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
|
||||
}
|
||||
}
|
||||
153
app/handlers.go
153
app/handlers.go
@@ -1,153 +0,0 @@
|
||||
// Package app implements all functions on which a request will pass through.
|
||||
package app
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/context"
|
||||
"github.com/thecodingmachine/gotenberg/app/converter"
|
||||
ghttp "github.com/thecodingmachine/gotenberg/app/http"
|
||||
"github.com/thecodingmachine/gotenberg/app/logger"
|
||||
|
||||
"github.com/justinas/alice"
|
||||
)
|
||||
|
||||
// GetHandlersChain returns the handlers chaining
|
||||
// thanks to the alice library.
|
||||
func GetHandlersChain() http.Handler {
|
||||
return alice.New(enforceContentLengthHandler, enforceContentTypeHandler, convertHandler).ThenFunc(serveHandler)
|
||||
}
|
||||
|
||||
type requestHasNoContentError struct{}
|
||||
|
||||
func (e *requestHasNoContentError) Error() string {
|
||||
return "Request has not content"
|
||||
}
|
||||
|
||||
// enforeContentLengthHandler checks if the request has content.
|
||||
func enforceContentLengthHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.ContentLength == 0 {
|
||||
e := &requestHasNoContentError{}
|
||||
http.Error(w, e.Error(), http.StatusBadRequest)
|
||||
logger.Error(e)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// enforceContentTypeHandler checks if the "Content-Type" entry
|
||||
// from the request's header matches the allowed content type.
|
||||
func enforceContentTypeHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if err := ghttp.CheckAuthorizedContentType(r.Header); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusUnsupportedMediaType)
|
||||
logger.Error(err)
|
||||
return
|
||||
}
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// convertHandler is in charge of converting the file(s) from the request to PDF.
|
||||
func convertHandler(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
c, err := converter.NewConverter(r)
|
||||
if err != nil {
|
||||
if _, ok := err.(*converter.NoFileToConvertError); ok {
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
} else {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
logger.Error(err)
|
||||
|
||||
if c != nil {
|
||||
r = context.WithConverter(r, c)
|
||||
cleanup(r)
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
r = context.WithConverter(r, c)
|
||||
|
||||
path, err := c.Convert()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
logger.Error(err)
|
||||
cleanup(r)
|
||||
return
|
||||
}
|
||||
|
||||
r = context.WithResultFilePath(r, path)
|
||||
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
// serveHandler simply serves the created PDF.
|
||||
func serveHandler(w http.ResponseWriter, r *http.Request) {
|
||||
path, err := context.GetResultFilePath(r)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
logger.Error(err)
|
||||
cleanup(r)
|
||||
return
|
||||
}
|
||||
|
||||
reader, err := os.Open(path)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
logger.Error(err)
|
||||
cleanup(r)
|
||||
return
|
||||
}
|
||||
|
||||
defer reader.Close()
|
||||
|
||||
resultFileInfo, err := reader.Stat()
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
logger.Error(err)
|
||||
cleanup(r)
|
||||
return
|
||||
}
|
||||
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", resultFileInfo.Name()))
|
||||
w.Header().Set("Content-Type", "application/pdf")
|
||||
w.Header().Set("Content-Length", fmt.Sprintf("%d", resultFileInfo.Size()))
|
||||
_, err := io.Copy(w, reader)
|
||||
|
||||
done <- err
|
||||
}()
|
||||
|
||||
err = <-done
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusInternalServerError)
|
||||
logger.Error(err)
|
||||
}
|
||||
|
||||
cleanup(r)
|
||||
}
|
||||
|
||||
// cleanup removes all files created during the conversion.
|
||||
func cleanup(r *http.Request) {
|
||||
c, err := context.GetConverter(r)
|
||||
if err != nil {
|
||||
logger.Warn(err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
if err := c.Clear(); err != nil {
|
||||
logger.Warn(err.Error())
|
||||
}
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app/config"
|
||||
"github.com/thecodingmachine/gotenberg/app/context"
|
||||
"github.com/thecodingmachine/gotenberg/app/converter/process"
|
||||
|
||||
"github.com/justinas/alice"
|
||||
)
|
||||
|
||||
func makeRequest(filesPaths ...string) *http.Request {
|
||||
r, w := io.Pipe()
|
||||
mpw := multipart.NewWriter(w)
|
||||
|
||||
go func() {
|
||||
var part io.Writer
|
||||
defer w.Close()
|
||||
|
||||
if len(filesPaths) == 0 {
|
||||
part, _ = mpw.CreateFormField("foo")
|
||||
part.Write([]byte("bar"))
|
||||
} else {
|
||||
for _, filePath := range filesPaths {
|
||||
file, _ := os.Open(filePath)
|
||||
defer file.Close()
|
||||
|
||||
fileInfo, _ := file.Stat()
|
||||
part, _ = mpw.CreateFormFile("files", fileInfo.Name())
|
||||
io.Copy(part, file)
|
||||
}
|
||||
}
|
||||
|
||||
mpw.Close()
|
||||
}()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/", r)
|
||||
req.Header.Set("Content-Type", mpw.FormDataContentType())
|
||||
return req
|
||||
}
|
||||
|
||||
func loadCommandConfigs(configurationFilePath string) {
|
||||
path, _ := filepath.Abs(configurationFilePath)
|
||||
c, _ := config.NewAppConfig(path)
|
||||
process.Load(c.CommandsConfig)
|
||||
}
|
||||
|
||||
func fakeSuccessHandler(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}
|
||||
|
||||
func TestGetHandlersChain(t *testing.T) {
|
||||
// dumb test to improve code coverage...
|
||||
if GetHandlersChain() == nil {
|
||||
t.Errorf("Handler chains should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceContentLengthHandler(t *testing.T) {
|
||||
var (
|
||||
req *http.Request
|
||||
rr *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
h := alice.New(enforceContentLengthHandler).ThenFunc(fakeSuccessHandler)
|
||||
|
||||
// case 1: sends an empty request.
|
||||
req = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusBadRequest {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
// case 2: sends a real body.
|
||||
path, _ := filepath.Abs("../_tests/file.docx")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, makeRequest(path))
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnforceContentTypeHandler(t *testing.T) {
|
||||
var (
|
||||
req *http.Request
|
||||
rr *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
h := alice.New(enforceContentTypeHandler).ThenFunc(fakeSuccessHandler)
|
||||
|
||||
// case 1: sends a wrong content type.
|
||||
req = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
req.Header.Set("Content-Type", "application/pdf")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusUnsupportedMediaType {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusUnsupportedMediaType)
|
||||
}
|
||||
|
||||
// case 2: sends a good content type.
|
||||
path, _ := filepath.Abs("../_tests/file.docx")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, makeRequest(path))
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Handler returned wrong a status code: got '%v' want '%v'", status, http.StatusOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestConvertHandler(t *testing.T) {
|
||||
var (
|
||||
req *http.Request
|
||||
rr *httptest.ResponseRecorder
|
||||
path string
|
||||
oPath string
|
||||
)
|
||||
|
||||
h := alice.New(convertHandler).ThenFunc(fakeSuccessHandler)
|
||||
|
||||
// case 1: sends a request without body.
|
||||
req = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusInternalServerError {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// case 2: sends a request with no file.
|
||||
req = makeRequest()
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusBadRequest {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusBadRequest)
|
||||
}
|
||||
|
||||
loadCommandConfigs("../_tests/configurations/merge-timeout-gotenberg.yml")
|
||||
|
||||
// case 3: sends a request with two files and using an unsuitable timeout for merge commande.
|
||||
path, _ = filepath.Abs("../_tests/file.pdf")
|
||||
oPath, _ = filepath.Abs("../_tests/file.docx")
|
||||
req = makeRequest(path, oPath)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusInternalServerError {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
loadCommandConfigs("../_tests/configurations/gotenberg.yml")
|
||||
|
||||
// case 4: sends a request with two files.
|
||||
path, _ = filepath.Abs("../_tests/file.pdf")
|
||||
oPath, _ = filepath.Abs("../_tests/file.docx")
|
||||
req = makeRequest(path, oPath)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
|
||||
}
|
||||
|
||||
// case 5: sends five requests (almost) simultany.
|
||||
path, _ = filepath.Abs("../_tests/file.docx")
|
||||
filesPaths := []string{
|
||||
path,
|
||||
path,
|
||||
path,
|
||||
path,
|
||||
path,
|
||||
}
|
||||
|
||||
for i := 0; i < len(filesPaths); i++ {
|
||||
go func(i int) {
|
||||
req := makeRequest(filesPaths[i])
|
||||
rr := httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
|
||||
}
|
||||
}(i)
|
||||
}
|
||||
}
|
||||
|
||||
func TestServeHandler(t *testing.T) {
|
||||
var (
|
||||
req *http.Request
|
||||
rr *httptest.ResponseRecorder
|
||||
)
|
||||
|
||||
h := alice.New().ThenFunc(serveHandler)
|
||||
|
||||
// case 1: sends a request without a result file path entry in its context.
|
||||
req = httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusInternalServerError {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// case 2: sends a request with a wrong result file path entry in its context.
|
||||
req = context.WithResultFilePath(httptest.NewRequest(http.MethodPost, "/", nil), "file")
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusInternalServerError {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusInternalServerError)
|
||||
}
|
||||
|
||||
// case 3: sends a request with a correct result file path entry in its context.
|
||||
path, _ := filepath.Abs("../_tests/file.pdf")
|
||||
req = context.WithResultFilePath(httptest.NewRequest(http.MethodPost, "/", nil), path)
|
||||
rr = httptest.NewRecorder()
|
||||
h.ServeHTTP(rr, req)
|
||||
if status := rr.Code; status != http.StatusOK {
|
||||
t.Errorf("Handler returned a wrong status code: got '%v' want '%v'", status, http.StatusOK)
|
||||
}
|
||||
}
|
||||
@@ -1,47 +0,0 @@
|
||||
// Package http provides functions for detecting a request or a file content type.
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ContentType is a string which represents a content type.
|
||||
type ContentType string
|
||||
|
||||
// MultipartFormDataContentType represents... the multipart form data content type.
|
||||
const MultipartFormDataContentType ContentType = "multipart/form-data"
|
||||
|
||||
type notAuthorizedContentTypeError struct{}
|
||||
|
||||
func (e *notAuthorizedContentTypeError) Error() string {
|
||||
return fmt.Sprintf("Accepted value for 'Content-Type': %s", MultipartFormDataContentType)
|
||||
}
|
||||
|
||||
// CheckAuthorizedContentType checks if the request header header has an authorized content type.
|
||||
// If no authorized content type found, throws an error.
|
||||
func CheckAuthorizedContentType(h http.Header) error {
|
||||
ct := findContentType(h.Get("Content-Type"), MultipartFormDataContentType)
|
||||
if ct == "" {
|
||||
return ¬AuthorizedContentTypeError{}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// findContentType parses a string representing a content type and tries to find
|
||||
// one of the given content types.
|
||||
func findContentType(requestContentType string, contentTypes ...ContentType) ContentType {
|
||||
for _, ct := range contentTypes {
|
||||
if i := strings.IndexRune(requestContentType, ';'); i != -1 {
|
||||
requestContentType = requestContentType[0:i]
|
||||
}
|
||||
|
||||
if requestContentType == string(ct) {
|
||||
return ct
|
||||
}
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
@@ -1,38 +0,0 @@
|
||||
package http
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestCheckAuthorizedContentType(t *testing.T) {
|
||||
req := httptest.NewRequest(http.MethodPost, "/", nil)
|
||||
|
||||
// case 1: uses a request without a content type entry in its header.
|
||||
if err := CheckAuthorizedContentType(req.Header); err == nil {
|
||||
t.Error("Function should not have been able to retrieve an authorized content type from request's header")
|
||||
}
|
||||
|
||||
// case 2: uses a request with a content type entry in its header.
|
||||
req.Header.Set("Content-Type", string(MultipartFormDataContentType))
|
||||
if err := CheckAuthorizedContentType(req.Header); err != nil {
|
||||
t.Error("Function should have been able to retrieve an authorized content type from request's header")
|
||||
}
|
||||
|
||||
// case 3: uses a request with a composed content type entry in its header.
|
||||
req.Header.Set("Content-Type", "multipart/form-data; boundary=—-WebKitFormBoundary7MA4YWxkTrZu0gW")
|
||||
if err := CheckAuthorizedContentType(req.Header); err != nil {
|
||||
t.Error("Function should have been able to retrieve an authorized content type from request's header")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
func TestNotAuthorizedContentTypeError(t *testing.T) {
|
||||
err := ¬AuthorizedContentTypeError{}
|
||||
expected := fmt.Sprintf("Accepted value for 'Content-Type': %s", MultipartFormDataContentType)
|
||||
if err.Error() != expected {
|
||||
t.Errorf("Error returned a wrong message: got '%s' want '%s'", err.Error(), expected)
|
||||
}
|
||||
}
|
||||
@@ -1,79 +0,0 @@
|
||||
// Package logger implements a simple wrapper of the logrus library.
|
||||
package logger
|
||||
|
||||
import (
|
||||
"os"
|
||||
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// logger wraps a logrus.Logger instance.
|
||||
type logger struct {
|
||||
logger *logrus.Logger
|
||||
}
|
||||
|
||||
// log is our logger instance used across the application.
|
||||
var log = newLogger()
|
||||
|
||||
// newLogger instantiates a logger instance with default values.
|
||||
func newLogger() *logger {
|
||||
l := &logger{
|
||||
logger: logrus.New(),
|
||||
}
|
||||
|
||||
l.logger.Out = os.Stdout
|
||||
l.logger.Level = logrus.InfoLevel
|
||||
|
||||
return l
|
||||
}
|
||||
|
||||
// SetLevel updates the level of messages which will be logged.
|
||||
func SetLevel(level logrus.Level) {
|
||||
log.logger.SetLevel(level)
|
||||
}
|
||||
|
||||
// SetFormatter updates the output format.
|
||||
// When a TTY is not attached, the output will be in the defined format.
|
||||
func SetFormatter(formatter logrus.Formatter) {
|
||||
log.logger.Formatter = formatter
|
||||
}
|
||||
|
||||
// Debug is a wrapper of the logrus Debug function.
|
||||
func Debug(message string) {
|
||||
log.logger.Debug(message)
|
||||
}
|
||||
|
||||
// Debugf is a wrapper of the logrus Debugf function.
|
||||
func Debugf(format string, args ...interface{}) {
|
||||
log.logger.Debugf(format, args)
|
||||
}
|
||||
|
||||
// Info is a wrapper of the logrus Info function.
|
||||
func Info(message string) {
|
||||
log.logger.Info(message)
|
||||
}
|
||||
|
||||
// Infof is a wrapper of the logrus Infof function.
|
||||
func Infof(format string, args ...interface{}) {
|
||||
log.logger.Infof(format, args)
|
||||
}
|
||||
|
||||
// Warn is a wrapper of the logrus Warn function.
|
||||
func Warn(message string) {
|
||||
log.logger.Warn(message)
|
||||
}
|
||||
|
||||
// Error is a wrapper of the logrus Error function.
|
||||
func Error(err error) {
|
||||
log.logger.Error(err.Error())
|
||||
}
|
||||
|
||||
// Fatal is a wrapper of the logrus Fatal function.
|
||||
func Fatal(err error) {
|
||||
log.logger.Fatal(err.Error())
|
||||
}
|
||||
|
||||
// Panic is a wrapper of the logrus Panic function.
|
||||
func Panic(err error) {
|
||||
log.logger.Panic(err.Error())
|
||||
}
|
||||
45
build/base/Dockerfile
Normal file
45
build/base/Dockerfile
Normal file
@@ -0,0 +1,45 @@
|
||||
FROM debian:stretch-slim
|
||||
|
||||
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Common libraries
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Libraries used in the build process of this image.
|
||||
# |
|
||||
|
||||
RUN echo "deb http://httpredir.debian.org/debian/ stretch main contrib non-free" > /etc/apt/sources.list &&\
|
||||
apt-get update &&\
|
||||
apt-get install -y curl wget
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | PM2
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs PM2 for launching programs in background and with failure
|
||||
# | recovering. In our case: Chrome (headless) and Office (headless).
|
||||
# |
|
||||
|
||||
RUN curl -sL https://raw.githubusercontent.com/Unitech/pm2/master/packager/setup.deb.sh | bash -
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Chrome
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs Chrome for HTML and Markdown conversions.
|
||||
# |
|
||||
|
||||
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 &&\
|
||||
apt-get update &&\
|
||||
apt-get -y install google-chrome-stable
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Unoconv
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs MS fonts and unoconv for Office documents conversions.
|
||||
# |
|
||||
|
||||
RUN apt-get -y install ttf-mscorefonts-installer unoconv
|
||||
12
build/docs/00-introduction.md
Normal file
12
build/docs/00-introduction.md
Normal file
@@ -0,0 +1,12 @@
|
||||
---
|
||||
title: Introduction
|
||||
---
|
||||
|
||||
[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
|
||||
* Office conversions (.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
|
||||
* 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!
|
||||
* Easily interact with the API using our [Go](https://github.com/thecodingmachine/gotenberg/pkg) and [PHP](https://github.com/thecodingmachine/gotenberg-php-client) libraries
|
||||
53
build/docs/01-install.md
Normal file
53
build/docs/01-install.md
Normal file
@@ -0,0 +1,53 @@
|
||||
---
|
||||
title: Install
|
||||
---
|
||||
|
||||
Gotenberg is shipped within a Docker image.
|
||||
|
||||
You may start it with:
|
||||
|
||||
```bash
|
||||
$ docker run --rm -p 3000:3000 thecodingmachine/gotenberg:3
|
||||
```
|
||||
|
||||
> The API will be available at [http://localhost:3000](http://localhost:3000).
|
||||
|
||||
Or add it in your Docker Compose stack:
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
|
||||
# your others services
|
||||
|
||||
gotenberg:
|
||||
image: thecodingmachine/gotenberg:3
|
||||
```
|
||||
|
||||
> The API will be available under `gotenberg:3000` in your Docker Compose network.
|
||||
|
||||
It may also be deployed with Kubernetes.
|
||||
|
||||
In the following examples, we will assume your
|
||||
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
|
||||
```
|
||||
311
build/docs/02-html.md
Normal file
311
build/docs/02-html.md
Normal file
@@ -0,0 +1,311 @@
|
||||
---
|
||||
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 \
|
||||
> 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
|
||||
|
||||
```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 \
|
||||
> 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
|
||||
|
||||
```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](#office.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 \
|
||||
> 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
|
||||
|
||||
```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);
|
||||
$dirPath = "/foo";
|
||||
$filename = $client->store($request, $dirPath);
|
||||
```
|
||||
|
||||
## 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`.
|
||||
> Also, you have to set both `paperWidth` and `paperHeight`. 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 \
|
||||
> 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
|
||||
|
||||
```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);
|
||||
$dirPath = "/foo";
|
||||
$filename = $client->store($request, $dirPath);
|
||||
```
|
||||
75
build/docs/03-markdown.md
Normal file
75
build/docs/03-markdown.md
Normal file
@@ -0,0 +1,75 @@
|
||||
---
|
||||
title: Markdown
|
||||
---
|
||||
|
||||
Gotenberg provides the endpoint `/convert/markdown` for Markdown conversions.
|
||||
|
||||
It accepts `POST` requests with a `multipart/form-data` Content-Type.
|
||||
|
||||
## Basic
|
||||
|
||||
Markdown conversions work the same as HTML conversions.
|
||||
|
||||
Only difference is that you have access to the Go template function `toHTML`
|
||||
in the file `index.html`. This function will convert a given markdown file to HTML.
|
||||
|
||||
For instance:
|
||||
|
||||
```html
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>My PDF</title>
|
||||
</head>
|
||||
<body>
|
||||
{{ toHTML .DirPath "file.md" }}
|
||||
</body>
|
||||
</html>
|
||||
```
|
||||
|
||||
### cURL
|
||||
|
||||
```bash
|
||||
$ curl --request POST \
|
||||
--url http://localhost:3000/convert/markdown \
|
||||
--header 'Content-Type: multipart/form-data' \
|
||||
--form files=@index.html \
|
||||
--form files=@file.md \
|
||||
> result.pdf
|
||||
```
|
||||
|
||||
### Go
|
||||
|
||||
```golang
|
||||
import "github.com/thecodingmachine/gotenberg/pkg"
|
||||
|
||||
func main() {
|
||||
c := &gotenberg.Client{Hostname: "http://localhost:3000"}
|
||||
req := &gotenberg.MarkdownRequest{
|
||||
IndexFilePath: "index.html",
|
||||
MarkdownFilePaths: []string{
|
||||
"file.md",
|
||||
},
|
||||
}
|
||||
dest := "result.pdf"
|
||||
c.Store(req, dest)
|
||||
}
|
||||
```
|
||||
|
||||
### PHP
|
||||
|
||||
```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);
|
||||
$dirPath = "/foo";
|
||||
$filename = $client->store($request, $dirPath);
|
||||
```
|
||||
85
build/docs/04-office.md
Normal file
85
build/docs/04-office.md
Normal file
@@ -0,0 +1,85 @@
|
||||
---
|
||||
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:
|
||||
|
||||
* `.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 \
|
||||
> 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
|
||||
|
||||
```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);
|
||||
$dirPath = "/foo";
|
||||
$filename = $client->store($request, $dirPath);
|
||||
```
|
||||
|
||||
## 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
|
||||
```
|
||||
58
build/docs/05-merge.md
Normal file
58
build/docs/05-merge.md
Normal file
@@ -0,0 +1,58 @@
|
||||
---
|
||||
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.
|
||||
|
||||
### cURL
|
||||
|
||||
```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
|
||||
|
||||
```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);
|
||||
$dirPath = "/foo";
|
||||
$filename = $client->store($request, $dirPath);
|
||||
```
|
||||
54
build/docs/06-webhook.md
Normal file
54
build/docs/06-webhook.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
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/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
|
||||
|
||||
```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);
|
||||
```
|
||||
27
build/docs/07-scalability.md
Normal file
27
build/docs/07-scalability.md
Normal file
@@ -0,0 +1,27 @@
|
||||
---
|
||||
title: Scalability
|
||||
---
|
||||
|
||||
The API being stateless, you may scale it as much as you want.
|
||||
|
||||
For instance, using the following Docker Compose file:
|
||||
|
||||
```yaml
|
||||
version: '3'
|
||||
|
||||
services:
|
||||
|
||||
# your others services
|
||||
|
||||
gotenberg:
|
||||
image: thecodingmachine/gotenberg:3
|
||||
```
|
||||
|
||||
You may now launch your services using:
|
||||
|
||||
```bash
|
||||
$ docker-compose up --scale gotenberg=your_number_of_instances
|
||||
```
|
||||
|
||||
When requesting the Gotenberg service with your client(s), Docker will automatically
|
||||
redirect a request to a Gotenberg container according to the round-robin strategy.
|
||||
8
build/docs/08-links.md
Normal file
8
build/docs/08-links.md
Normal file
@@ -0,0 +1,8 @@
|
||||
---
|
||||
title: Links
|
||||
---
|
||||
|
||||
* Follow the progress on the [GitHub repository](https://github.com/thecodingmachine/gotenberg)
|
||||
* Follow [@gulnap](https://twitter.com/gulnap) on Twitter
|
||||
|
||||
Psst: TheCodingMachine is always looking for [talented coders](https://coders.thecodingmachine.com).
|
||||
37
build/lint/Dockerfile
Normal file
37
build/lint/Dockerfile
Normal file
@@ -0,0 +1,37 @@
|
||||
ARG GOLANG_VERSION
|
||||
|
||||
FROM golang:${GOLANG_VERSION}-stretch
|
||||
|
||||
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | GolangCI-Lint
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs GolangCI-Lint, a linters Runner for Go. 5x faster
|
||||
# | than gometalinter.
|
||||
# |
|
||||
|
||||
ENV GOLANGCI_LINT_VERSION 1.12.3
|
||||
|
||||
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
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Final touch
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Last instructions of this build.
|
||||
# |
|
||||
|
||||
# Define our workding outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /lint
|
||||
|
||||
# Copy our module dependencies definitions.
|
||||
COPY go.mod .
|
||||
COPY go.sum .
|
||||
|
||||
# Install module dependencies.
|
||||
RUN go mod download
|
||||
|
||||
CMD ["golangci-lint", "run" ,"--tests=false", "--enable-all", "--disable=dupl", "--disable=lll", "--disable=errcheck", "--disable=gosec", "--disable=gochecknoglobals", "--disable=gochecknoinits" ]
|
||||
43
build/package/Dockerfile
Normal file
43
build/package/Dockerfile
Normal file
@@ -0,0 +1,43 @@
|
||||
ARG GOLANG_VERSION
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Binary
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Buils Gotenberg binary.
|
||||
# |
|
||||
|
||||
FROM golang:${GOLANG_VERSION}-stretch AS golang
|
||||
|
||||
ARG VERSION
|
||||
|
||||
ENV GOOS=linux \
|
||||
GOARCH=amd64 \
|
||||
CGO_ENABLED=0
|
||||
|
||||
# Define our workding outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /gotenberg
|
||||
|
||||
# Copy our source code.
|
||||
COPY . .
|
||||
|
||||
# Build our binary.
|
||||
RUN go build -o /gotenberg/gotenberg -ldflags "-X main.version=${VERSION}" cmd/gotenberg/main.go
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Final touch
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Last instructions of this build.
|
||||
# |
|
||||
|
||||
FROM thecodingmachine/gotenberg:base
|
||||
|
||||
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
|
||||
|
||||
COPY --from=golang /gotenberg/gotenberg /usr/local/bin/
|
||||
|
||||
WORKDIR /gotenberg
|
||||
|
||||
EXPOSE 3000
|
||||
CMD [ "gotenberg" ]
|
||||
50
build/tests/Dockerfile
Normal file
50
build/tests/Dockerfile
Normal file
@@ -0,0 +1,50 @@
|
||||
ARG GOLANG_VERSION
|
||||
|
||||
FROM golang:${GOLANG_VERSION}-stretch AS golang
|
||||
|
||||
FROM thecodingmachine/gotenberg:base
|
||||
|
||||
LABEL authors="Julien Neuhart <j.neuhart@thecodingmachine.com>"
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Common libraries
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Libraries used in the build process of this image.
|
||||
# |
|
||||
|
||||
RUN apt-get install -y git gcc
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Golang
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Installs Golang.
|
||||
# |
|
||||
|
||||
COPY --from=golang /usr/local/go /usr/local/go
|
||||
|
||||
RUN export PATH="/usr/local/go/bin:$PATH" &&\
|
||||
go version
|
||||
|
||||
ENV GOPATH /go
|
||||
ENV PATH $GOPATH/bin:/usr/local/go/bin:$PATH
|
||||
|
||||
# |--------------------------------------------------------------------------
|
||||
# | Final touch
|
||||
# |--------------------------------------------------------------------------
|
||||
# |
|
||||
# | Last instructions of this build.
|
||||
# |
|
||||
|
||||
# Define our workding outside of $GOPATH (we're using go modules).
|
||||
WORKDIR /tests
|
||||
|
||||
# Copy our module dependencies definitions.
|
||||
COPY go.mod .
|
||||
COPY go.sum .
|
||||
|
||||
# Install module dependencies.
|
||||
RUN go mod download
|
||||
|
||||
ENTRYPOINT ["build/tests/docker-entrypoint.sh"]
|
||||
24
build/tests/docker-entrypoint.sh
Executable file
24
build/tests/docker-entrypoint.sh
Executable file
@@ -0,0 +1,24 @@
|
||||
#!/bin/bash
|
||||
|
||||
set -xe
|
||||
|
||||
# Testing PM2 processes launch separatly for avoiding
|
||||
# spending to much time on each tests depending on
|
||||
# them.
|
||||
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestChromeLaunch
|
||||
go test github.com/thecodingmachine/gotenberg/internal/pkg/pm2 -run TestUnoconvLaunch
|
||||
|
||||
# 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/pkg/printer
|
||||
go test -race -cover -covermode=atomic github.com/thecodingmachine/gotenberg/internal/pkg/rand
|
||||
|
||||
# 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 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
|
||||
21
cmd/gotenberg/main.go
Normal file
21
cmd/gotenberg/main.go
Normal file
@@ -0,0 +1,21 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/app/api"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
|
||||
)
|
||||
|
||||
// version will be set on build time.
|
||||
var version = "snapshot"
|
||||
|
||||
func main() {
|
||||
notify.Println(fmt.Sprintf("Gotenberg %s", version))
|
||||
if err := api.Start(); err != nil {
|
||||
notify.ErrPrintln(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
os.Exit(0)
|
||||
}
|
||||
836
docs/index.html
Executable file
836
docs/index.html
Executable file
@@ -0,0 +1,836 @@
|
||||
<!DOCTYPE html><html><head>
|
||||
<meta charset="utf-8"/>
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge"/>
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1"/>
|
||||
<title>Gotenberg · A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.</title>
|
||||
<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="stylesheet" href="theme/gotenberg/css/index.css"/>
|
||||
</head>
|
||||
<body>
|
||||
<div class="Wrapper">
|
||||
<div class="Container">
|
||||
<div class="Header">
|
||||
<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"/>
|
||||
<span class="text">Gotenberg</span>
|
||||
<span class="subtext">A Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="Content-wrapper">
|
||||
<div class="Sidebar">
|
||||
<div class="Menu">
|
||||
|
||||
<div class="item">
|
||||
<a href="#introduction">Introduction</a>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<a href="#install">Install</a>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<a href="#html">HTML</a>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<a href="#markdown">Markdown</a>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<a href="#office">Office</a>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<a href="#merge">Merge</a>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<a href="#webhook">Webhook</a>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<a href="#scalability">Scalability</a>
|
||||
</div>
|
||||
|
||||
<div class="item">
|
||||
<a href="#links">Links</a>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="Content">
|
||||
|
||||
<div class="Page" id="introduction">
|
||||
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="introduction" href="#introduction">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Introduction</h1>
|
||||
<p><a href="https://github.com/thecodingmachine/gotenberg/">Gotenberg</a> is a Docker-powered stateless API for converting HTML, Markdown and Office documents to PDF.</p>
|
||||
|
||||
<ul>
|
||||
<li>HTML and Markdown conversions using Google Chrome headless</li>
|
||||
<li>Office conversions (.docx, .doc, .odt, .pptx, .ppt, .odp and so on) using <a href="https://github.com/dagwieers/unoconv">unoconv</a></li>
|
||||
<li>Performance: Google Chrome and Libreoffice (unoconv) started once in the background thanks to PM2</li>
|
||||
<li>Failure prevention: PM2 automatically restarts previous processes if they fail</li>
|
||||
<li>Assets: send your header, footer, images, fonts, stylesheets and so on for converting your HTML and Markdown to beaufitul PDFs!</li>
|
||||
<li>Easily interact with the API using our <a href="https://github.com/thecodingmachine/gotenberg/pkg">Go</a> and <a href="https://github.com/thecodingmachine/gotenberg-php-client">PHP</a> libraries</li>
|
||||
</ul>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="Page" id="install">
|
||||
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="install" href="#install">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Install</h1>
|
||||
<p>Gotenberg is shipped within a Docker image.</p>
|
||||
|
||||
<p>You may start it with:</p>
|
||||
|
||||
<pre class="chroma">$ docker run --rm -p <span class="m">3000</span>:3000 thecodingmachine/gotenberg:3
|
||||
</pre>
|
||||
|
||||
<blockquote>
|
||||
<p>The API will be available at <a href="http://localhost:3000">http://localhost:3000</a>.</p>
|
||||
</blockquote>
|
||||
|
||||
<p>Or add it in your Docker Compose stack:</p>
|
||||
|
||||
<pre class="chroma">version<span class="p">:</span><span class="w"> </span><span class="s1">'3'</span><span class="w">
|
||||
</span><span class="w">
|
||||
</span><span class="w"></span>services<span class="p">:</span><span class="w">
|
||||
</span><span class="w">
|
||||
</span><span class="w"> </span><span class="c"># your others services</span><span class="w">
|
||||
</span><span class="w">
|
||||
</span><span class="w"> </span>gotenberg<span class="p">:</span><span class="w">
|
||||
</span><span class="w"> </span>image<span class="p">:</span><span class="w"> </span>thecodingmachine/gotenberg<span class="p">:</span><span class="m">3</span><span class="w">
|
||||
</span><span class="w"></span></pre>
|
||||
|
||||
<blockquote>
|
||||
<p>The API will be available under <code>gotenberg:3000</code> in your Docker Compose network.</p>
|
||||
</blockquote>
|
||||
|
||||
<p>It may also be deployed with Kubernetes.</p>
|
||||
|
||||
<p>In the following examples, we will assume your
|
||||
Gotenberg API is available at <a href="http://localhost:3000">http://localhost:3000</a>.</p>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="install.go_client" href="#install.go_client">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Go client</h2>
|
||||
|
||||
<pre class="chroma">$ go get -u github.com/thecodingmachine/gotenberg
|
||||
</pre>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="install.php_client" href="#install.php_client">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>PHP client</h2>
|
||||
|
||||
<p>Unless your project already has a PSR7 <code>HttpClient</code>, install <code>php-http/guzzle6-adapter</code>:</p>
|
||||
|
||||
<pre class="chroma">$ composer require php-http/guzzle6-adapter
|
||||
</pre>
|
||||
|
||||
<p>Then the PHP client:</p>
|
||||
|
||||
<pre class="chroma">$ composer require thecodingmachine/gotenberg-php-client
|
||||
</pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="Page" id="html">
|
||||
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="html" href="#html">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>HTML</h1>
|
||||
<p>Gotenberg provides the endpoint <code>/convert/html</code> for HTML conversions.</p>
|
||||
|
||||
<p>It accepts <code>POST</code> requests with a <code>multipart/form-data</code> Content-Type.</p>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="html.basic" href="#html.basic">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Basic</h2>
|
||||
|
||||
<p>The only requirement is to send a file named <code>index.html</code>: it is the file
|
||||
which will be converted to PDF.</p>
|
||||
|
||||
<p>For instance:</p>
|
||||
|
||||
<pre class="chroma"><span class="cp"><!doctype html></span>
|
||||
<span class="p"><</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">"en"</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">head</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">meta</span> <span class="na">charset</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">title</span><span class="p"></span><span class="p">></span>My PDF<span class="p"><</span><span class="p">/</span><span class="nt">title</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">head</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">body</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">h1</span><span class="p"></span><span class="p">></span>Hello world!<span class="p"><</span><span class="p">/</span><span class="nt">h1</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">body</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">html</span><span class="p">></span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.basic.c_url" href="#html.basic.c_url">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>cURL</h3>
|
||||
|
||||
<pre class="chroma">$ curl --request POST <span class="se">\
|
||||
</span><span class="se"></span> --url http://localhost:3000/convert/html <span class="se">\
|
||||
</span><span class="se"></span> --header <span class="s1">'Content-Type: multipart/form-data'</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
|
||||
</span><span class="se"></span> > result.pdf
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.basic.go" href="#html.basic.go">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Go</h3>
|
||||
|
||||
<pre class="chroma"><span class="kn">import</span> <span class="s">"github.com/thecodingmachine/gotenberg/pkg"</span>
|
||||
|
||||
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
|
||||
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">"http://localhost:3000"</span><span class="p">}</span>
|
||||
<span class="nx">req</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">HTMLRequest</span><span class="p">{</span>
|
||||
<span class="nx">IndexFilePath</span><span class="p">:</span> <span class="s">"index.html"</span><span class="p">,</span>
|
||||
<span class="p">}</span>
|
||||
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">"result.pdf"</span>
|
||||
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
|
||||
<span class="p">}</span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.basic.php" href="#html.basic.php">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>PHP</h3>
|
||||
|
||||
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\HTMLRequest;
|
||||
</span><span class="x">
|
||||
</span><span class="x">$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
|
||||
</span><span class="x">$index = DocumentFactory::makeFromPath('index.html', 'index.html');
|
||||
</span><span class="x">$request = new HTMLRequest($index);
|
||||
</span><span class="x">$dirPath = "/foo";
|
||||
</span><span class="x">$filename = $client->store($request, $dirPath);
|
||||
</span><span class="x"></span></pre>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="html.header_and_footer" href="#html.header_and_footer">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Header and footer</h2>
|
||||
|
||||
<p>You may also add a header and/or a footer in the resulting PDF.
|
||||
Respectively, a file named <code>header.html</code> and <code>footer.html</code>.</p>
|
||||
|
||||
<p>Each of them <strong>has to be a complete HTML document</strong>:</p>
|
||||
|
||||
<pre class="chroma"><span class="p"><</span><span class="nt">html</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">head</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">style</span><span class="p"></span><span class="p">></span>
|
||||
<span class="nt">body</span> <span class="p">{</span>
|
||||
<span class="k">font-size</span><span class="p">:</span> <span class="mi">8</span><span class="kt">rem</span><span class="p">;</span>
|
||||
<span class="k">margin</span><span class="p">:</span> <span class="mi">4</span><span class="kt">rem</span> <span class="kc">auto</span><span class="p">;</span>
|
||||
<span class="p">}</span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">style</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">head</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">body</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">p</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">"pageNumber"</span><span class="p"></span><span class="p">><</span><span class="p">/</span><span class="nt">span</span><span class="p">></span> of <span class="p"><</span><span class="nt">span</span> <span class="na">class</span><span class="o">=</span><span class="s">"totalPages"</span><span class="p"></span><span class="p">><</span><span class="p">/</span><span class="nt">span</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">p</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">body</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">html</span><span class="p">></span>
|
||||
</pre>
|
||||
|
||||
<p>The following classes allow you to inject printing values:</p>
|
||||
|
||||
<ul>
|
||||
<li><code>date</code>: formatted print date</li>
|
||||
<li><code>title</code>: document title</li>
|
||||
<li><code>pageNumber</code>: current page number</li>
|
||||
<li><code>totalPage</code>: total pages in the document</li>
|
||||
</ul>
|
||||
|
||||
<blockquote>
|
||||
<p><strong>Attention:</strong> the CSS properties are independant of the ones used in the <code>index.html</code> file.
|
||||
Also, <code>footer.html</code> CSS properties override the ones from <code>header.html</code>.</p>
|
||||
</blockquote>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.header_and_footer.c_url" href="#html.header_and_footer.c_url">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>cURL</h3>
|
||||
|
||||
<pre class="chroma">$ curl --request POST <span class="se">\
|
||||
</span><span class="se"></span> --url http://localhost:3000/convert/html <span class="se">\
|
||||
</span><span class="se"></span> --header <span class="s1">'Content-Type: multipart/form-data'</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@header.html <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@footer.html <span class="se">\
|
||||
</span><span class="se"></span> > result.pdf
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.header_and_footer.go" href="#html.header_and_footer.go">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Go</h3>
|
||||
|
||||
<pre class="chroma"><span class="kn">import</span> <span class="s">"github.com/thecodingmachine/gotenberg/pkg"</span>
|
||||
|
||||
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
|
||||
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">"http://localhost:3000"</span><span class="p">}</span>
|
||||
<span class="nx">req</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">HTMLRequest</span><span class="p">{</span>
|
||||
<span class="nx">IndexFilePath</span><span class="p">:</span> <span class="s">"index.html"</span><span class="p">,</span>
|
||||
<span class="nx">Options</span><span class="p">:</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">HTMLOptions</span><span class="p">{</span>
|
||||
<span class="nx">HeaderFilePath</span><span class="p">:</span> <span class="s">"header.html"</span><span class="p">,</span>
|
||||
<span class="nx">FooterFilePath</span><span class="p">:</span> <span class="s">"footer.html"</span><span class="p">,</span>
|
||||
<span class="p">},</span>
|
||||
<span class="p">}</span>
|
||||
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">"result.pdf"</span>
|
||||
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
|
||||
<span class="p">}</span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.header_and_footer.php" href="#html.header_and_footer.php">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>PHP</h3>
|
||||
|
||||
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\HTMLRequest;
|
||||
</span><span class="x">
|
||||
</span><span class="x">$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
|
||||
</span><span class="x">$index = DocumentFactory::makeFromPath('index.html', 'index.html');
|
||||
</span><span class="x">$header = DocumentFactory::makeFromPath('header.html', 'header.html');
|
||||
</span><span class="x">$footer = DocumentFactory::makeFromPath('footer.html', 'footer.html');
|
||||
</span><span class="x">$request = new HTMLRequest($index);
|
||||
</span><span class="x">$request->setHeader($header);
|
||||
</span><span class="x">$request->setFooter($footer);
|
||||
</span><span class="x">$dirPath = "/foo";
|
||||
</span><span class="x">$filename = $client->store($request, $dirPath);
|
||||
</span><span class="x"></span></pre>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="html.assets" href="#html.assets">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Assets</h2>
|
||||
|
||||
<p>You may also send additional files. For instance: images, fonts, stylesheets and so on.</p>
|
||||
|
||||
<p>The only requirement is to make sure that their paths
|
||||
are on the same level as the <code>index.html</code> file.</p>
|
||||
|
||||
<p>In others words, this will work:</p>
|
||||
|
||||
<pre class="chroma"><span class="cp"><!doctype html></span>
|
||||
<span class="p"><</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">"en"</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">head</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">meta</span> <span class="na">charset</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">title</span><span class="p"></span><span class="p">></span>My PDF<span class="p"><</span><span class="p">/</span><span class="nt">title</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">head</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">body</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">h1</span><span class="p"></span><span class="p">></span>Hello world!<span class="p"><</span><span class="p">/</span><span class="nt">h1</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">img</span> <span class="na">src</span><span class="o">=</span><span class="s">"img.png"</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">body</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">html</span><span class="p">></span>
|
||||
</pre>
|
||||
|
||||
<p>But this won’t:</p>
|
||||
|
||||
<pre class="chroma"><span class="cp"><!doctype html></span>
|
||||
<span class="p"><</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">"en"</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">head</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">meta</span> <span class="na">charset</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">title</span><span class="p"></span><span class="p">></span>My PDF<span class="p"><</span><span class="p">/</span><span class="nt">title</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">head</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">body</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">h1</span><span class="p"></span><span class="p">></span>Hello world!<span class="p"><</span><span class="p">/</span><span class="nt">h1</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">img</span> <span class="na">src</span><span class="o">=</span><span class="s">"/foo/img.png"</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">body</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">html</span><span class="p">></span>
|
||||
</pre>
|
||||
|
||||
<p>You may also use <em>remote</em> paths for Google fonts, images and so on.</p>
|
||||
|
||||
<blockquote>
|
||||
<p>If you want to install fonts directly in the Gotenberg Docker image,
|
||||
see to the <a href="#office.fonts">fonts section</a>.</p>
|
||||
</blockquote>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.assets.c_url" href="#html.assets.c_url">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>cURL</h3>
|
||||
|
||||
<pre class="chroma">$ curl --request POST <span class="se">\
|
||||
</span><span class="se"></span> --url http://localhost:3000/convert/html <span class="se">\
|
||||
</span><span class="se"></span> --header <span class="s1">'Content-Type: multipart/form-data'</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@style.css <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@img.png <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@font.woff <span class="se">\
|
||||
</span><span class="se"></span> > result.pdf
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.assets.go" href="#html.assets.go">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Go</h3>
|
||||
|
||||
<pre class="chroma"><span class="kn">import</span> <span class="s">"github.com/thecodingmachine/gotenberg/pkg"</span>
|
||||
|
||||
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
|
||||
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">"http://localhost:3000"</span><span class="p">}</span>
|
||||
<span class="nx">req</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">HTMLRequest</span><span class="p">{</span>
|
||||
<span class="nx">IndexFilePath</span><span class="p">:</span> <span class="s">"index.html"</span><span class="p">,</span>
|
||||
<span class="nx">AssetFilePaths</span><span class="p">:</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span>
|
||||
<span class="s">"style.css"</span><span class="p">,</span>
|
||||
<span class="s">"img.png"</span><span class="p">,</span>
|
||||
<span class="s">"font.woff"</span><span class="p">,</span>
|
||||
<span class="p">},</span>
|
||||
<span class="p">}</span>
|
||||
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">"result.pdf"</span>
|
||||
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
|
||||
<span class="p">}</span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.assets.php" href="#html.assets.php">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>PHP</h3>
|
||||
|
||||
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\HTMLRequest;
|
||||
</span><span class="x">
|
||||
</span><span class="x">$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
|
||||
</span><span class="x">$index = DocumentFactory::makeFromPath('index.html', 'index.html');
|
||||
</span><span class="x">$assets = [
|
||||
</span><span class="x"> DocumentFactory::makeFromPath('style.css', 'style.css'),
|
||||
</span><span class="x"> DocumentFactory::makeFromPath('img.png', 'img.png'),
|
||||
</span><span class="x"> DocumentFactory::makeFromPath('font.woff', 'font.woff'),
|
||||
</span><span class="x">];
|
||||
</span><span class="x">$request = new HTMLRequest($index);
|
||||
</span><span class="x">$request->setAssets($assets);
|
||||
</span><span class="x">$dirPath = "/foo";
|
||||
</span><span class="x">$filename = $client->store($request, $dirPath);
|
||||
</span><span class="x"></span></pre>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="html.paper_size_margins_orientation" href="#html.paper_size_margins_orientation">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Paper size, margins, orientation</h2>
|
||||
|
||||
<p>You may also customize the resulting PDF format.</p>
|
||||
|
||||
<p>By default, it will be rendered with <code>A4</code> size, <code>1 inch</code> margins and <code>portrait</code> orientation.</p>
|
||||
|
||||
<blockquote>
|
||||
<p>Paper size and margins have to be provided in <code>inches</code>.
|
||||
Also, you have to set both <code>paperWidth</code> and <code>paperHeight</code>. Same for margins.</p>
|
||||
</blockquote>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.paper_size_margins_orientation.c_url" href="#html.paper_size_margins_orientation.c_url">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>cURL</h3>
|
||||
|
||||
<pre class="chroma">$ curl --request POST <span class="se">\
|
||||
</span><span class="se"></span> --url http://localhost:3000/convert/html <span class="se">\
|
||||
</span><span class="se"></span> --header <span class="s1">'Content-Type: multipart/form-data'</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">paperWidth</span><span class="o">=</span><span class="m">8</span>.27 <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">paperHeight</span><span class="o">=</span><span class="m">11</span>.27 <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">marginTop</span><span class="o">=</span><span class="m">0</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">marginBottom</span><span class="o">=</span><span class="m">0</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">marginLeft</span><span class="o">=</span><span class="m">0</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">marginRight</span><span class="o">=</span><span class="m">0</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">landscape</span><span class="o">=</span><span class="nb">true</span> <span class="se">\
|
||||
</span><span class="se"></span> > result.pdf
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.paper_size_margins_orientation.go" href="#html.paper_size_margins_orientation.go">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Go</h3>
|
||||
|
||||
<pre class="chroma"><span class="kn">import</span> <span class="s">"github.com/thecodingmachine/gotenberg/pkg"</span>
|
||||
|
||||
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
|
||||
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">"http://localhost:3000"</span><span class="p">}</span>
|
||||
<span class="nx">req</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">HTMLRequest</span><span class="p">{</span>
|
||||
<span class="nx">IndexFilePath</span><span class="p">:</span> <span class="s">"index.html"</span><span class="p">,</span>
|
||||
<span class="nx">Options</span><span class="p">:</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">HTMLOptions</span><span class="p">{</span>
|
||||
<span class="nx">PaperSize</span><span class="p">:</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">A4</span><span class="p">,</span>
|
||||
<span class="nx">PaperMargins</span><span class="p">:</span> <span class="nx">gotenberg</span><span class="p">.</span><span class="nx">NoMargins</span><span class="p">,</span>
|
||||
<span class="nx">Landscape</span><span class="p">:</span> <span class="kc">true</span>
|
||||
<span class="p">},</span>
|
||||
<span class="p">}</span>
|
||||
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">"result.pdf"</span>
|
||||
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
|
||||
<span class="p">}</span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="html.paper_size_margins_orientation.php" href="#html.paper_size_margins_orientation.php">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>PHP</h3>
|
||||
|
||||
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\HTMLRequest;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\Request;
|
||||
</span><span class="x">
|
||||
</span><span class="x">$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
|
||||
</span><span class="x">$index = DocumentFactory::makeFromPath('index.html', 'index.html');
|
||||
</span><span class="x">$request = new HTMLRequest($index);
|
||||
</span><span class="x">$request->setPaperSize(Request::A4);
|
||||
</span><span class="x">$request->setMargins(Request::NO_MARGINS);
|
||||
</span><span class="x">$request->setLandscape(true);
|
||||
</span><span class="x">$dirPath = "/foo";
|
||||
</span><span class="x">$filename = $client->store($request, $dirPath);
|
||||
</span><span class="x"></span></pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="Page" id="markdown">
|
||||
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="markdown" href="#markdown">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Markdown</h1>
|
||||
<p>Gotenberg provides the endpoint <code>/convert/markdown</code> for Markdown conversions.</p>
|
||||
|
||||
<p>It accepts <code>POST</code> requests with a <code>multipart/form-data</code> Content-Type.</p>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="markdown.basic" href="#markdown.basic">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Basic</h2>
|
||||
|
||||
<p>Markdown conversions work the same as HTML conversions.</p>
|
||||
|
||||
<p>Only difference is that you have access to the Go template function <code>toHTML</code>
|
||||
in the file <code>index.html</code>. This function will convert a given markdown file to HTML.</p>
|
||||
|
||||
<p>For instance:</p>
|
||||
|
||||
<pre class="chroma"><span class="cp"><!doctype html></span>
|
||||
<span class="p"><</span><span class="nt">html</span> <span class="na">lang</span><span class="o">=</span><span class="s">"en"</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">head</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">meta</span> <span class="na">charset</span><span class="o">=</span><span class="s">"utf-8"</span><span class="p"></span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">title</span><span class="p"></span><span class="p">></span>My PDF<span class="p"><</span><span class="p">/</span><span class="nt">title</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">head</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="nt">body</span><span class="p"></span><span class="p">></span>
|
||||
{{ toHTML .DirPath "file.md" }}
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">body</span><span class="p">></span>
|
||||
<span class="p"><</span><span class="p">/</span><span class="nt">html</span><span class="p">></span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="markdown.basic.c_url" href="#markdown.basic.c_url">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>cURL</h3>
|
||||
|
||||
<pre class="chroma">$ curl --request POST <span class="se">\
|
||||
</span><span class="se"></span> --url http://localhost:3000/convert/markdown <span class="se">\
|
||||
</span><span class="se"></span> --header <span class="s1">'Content-Type: multipart/form-data'</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@file.md <span class="se">\
|
||||
</span><span class="se"></span> > result.pdf
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="markdown.basic.go" href="#markdown.basic.go">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Go</h3>
|
||||
|
||||
<pre class="chroma"><span class="kn">import</span> <span class="s">"github.com/thecodingmachine/gotenberg/pkg"</span>
|
||||
|
||||
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
|
||||
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">"http://localhost:3000"</span><span class="p">}</span>
|
||||
<span class="nx">req</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">MarkdownRequest</span><span class="p">{</span>
|
||||
<span class="nx">IndexFilePath</span><span class="p">:</span> <span class="s">"index.html"</span><span class="p">,</span>
|
||||
<span class="nx">MarkdownFilePaths</span><span class="p">:</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span>
|
||||
<span class="s">"file.md"</span><span class="p">,</span>
|
||||
<span class="p">},</span>
|
||||
<span class="p">}</span>
|
||||
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">"result.pdf"</span>
|
||||
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
|
||||
<span class="p">}</span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="markdown.basic.php" href="#markdown.basic.php">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>PHP</h3>
|
||||
|
||||
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\MarkdownRequest;
|
||||
</span><span class="x">
|
||||
</span><span class="x">$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
|
||||
</span><span class="x">$index = DocumentFactory::makeFromPath('index.html', 'index.html');
|
||||
</span><span class="x">$markdowns = [
|
||||
</span><span class="x"> DocumentFactory::makeFromPath('file.md', 'file.md'),
|
||||
</span><span class="x">];
|
||||
</span><span class="x">$request = new MarkdownRequest($index, $markdowns);
|
||||
</span><span class="x">$dirPath = "/foo";
|
||||
</span><span class="x">$filename = $client->store($request, $dirPath);
|
||||
</span><span class="x"></span></pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="Page" id="office">
|
||||
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="office" href="#office">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Office</h1>
|
||||
<p>Gotenberg provides the endpoint <code>/convert/office</code> for Office document conversions.</p>
|
||||
|
||||
<p>It accepts <code>POST</code> requests with a <code>multipart/form-data</code> Content-Type.</p>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="office.basic" href="#office.basic">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Basic</h2>
|
||||
|
||||
<p>You may send one or more Office documents. Following file extensions are accepted:</p>
|
||||
|
||||
<ul>
|
||||
<li><code>.doc</code></li>
|
||||
<li><code>.docx</code></li>
|
||||
<li><code>.odt</code></li>
|
||||
<li><code>.xls</code></li>
|
||||
<li><code>.xlsx</code></li>
|
||||
<li><code>.ods</code></li>
|
||||
<li><code>.ppt</code></li>
|
||||
<li><code>.pptx</code></li>
|
||||
<li><code>.odp</code></li>
|
||||
</ul>
|
||||
|
||||
<p>All files will be merged into a single resulting PDF.</p>
|
||||
|
||||
<blockquote>
|
||||
<p><strong>Attention:</strong> currently, <code>unoconv</code> cannot perform concurrent conversions.
|
||||
That’s why for Office conversions, the API does only one conversion at a time.
|
||||
See the <a href="#scalability">scalability section</a> to find how to mitigate this issue.</p>
|
||||
</blockquote>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="office.basic.c_url" href="#office.basic.c_url">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>cURL</h3>
|
||||
|
||||
<pre class="chroma">$ curl --request POST <span class="se">\
|
||||
</span><span class="se"></span> --url http://localhost:3000/convert/office <span class="se">\
|
||||
</span><span class="se"></span> --header <span class="s1">'Content-Type: multipart/form-data'</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@document.docx <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@document2.docx <span class="se">\
|
||||
</span><span class="se"></span> > result.pdf
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="office.basic.go" href="#office.basic.go">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Go</h3>
|
||||
|
||||
<pre class="chroma"><span class="kn">import</span> <span class="s">"github.com/thecodingmachine/gotenberg/pkg"</span>
|
||||
|
||||
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
|
||||
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">"http://localhost:3000"</span><span class="p">}</span>
|
||||
<span class="nx">req</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">OfficeRequest</span><span class="p">{</span>
|
||||
<span class="nx">FilePaths</span><span class="p">:</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span>
|
||||
<span class="s">"document.docx"</span><span class="p">,</span>
|
||||
<span class="s">"document2.docx"</span><span class="p">,</span>
|
||||
<span class="p">},</span>
|
||||
<span class="p">}</span>
|
||||
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">"result.pdf"</span>
|
||||
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
|
||||
<span class="p">}</span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="office.basic.php" href="#office.basic.php">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>PHP</h3>
|
||||
|
||||
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\OfficeRequest;
|
||||
</span><span class="x">
|
||||
</span><span class="x">$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
|
||||
</span><span class="x">$files = [
|
||||
</span><span class="x"> DocumentFactory::makeFromPath('document.docx', 'document.docx'),
|
||||
</span><span class="x"> DocumentFactory::makeFromPath('document2.docx', 'document2.docx'),
|
||||
</span><span class="x">];
|
||||
</span><span class="x">$request = new OfficeRequest($files);
|
||||
</span><span class="x">$dirPath = "/foo";
|
||||
</span><span class="x">$filename = $client->store($request, $dirPath);
|
||||
</span><span class="x"></span></pre>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="office.fonts" href="#office.fonts">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Fonts</h2>
|
||||
|
||||
<p>By default, only <code>ttf-mscorefonts</code> fonts are installed.</p>
|
||||
|
||||
<p>If you wish to use more fonts, you will have to create your own image:</p>
|
||||
|
||||
<pre class="chroma"><span class="k">FROM</span><span class="s"> thecodingmachine/gotenberg:3</span><span class="err">
|
||||
</span><span class="err">
|
||||
</span><span class="err"></span><span class="k">RUN</span> apt-get -y install yourfonts<span class="err">
|
||||
</span><span class="err"></span></pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="Page" id="merge">
|
||||
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="merge" href="#merge">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Merge</h1>
|
||||
<p>Gotenberg provides the endpoint <code>/merge</code> for merging PDFs.</p>
|
||||
|
||||
<p>It accepts <code>POST</code> requests with a <code>multipart/form-data</code> Content-Type.</p>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="merge.basic" href="#merge.basic">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Basic</h2>
|
||||
|
||||
<p>Nothing special here: you may send one or more PDF files and the API
|
||||
will merge them and return the resulting PDF file.</p>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="merge.basic.c_url" href="#merge.basic.c_url">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>cURL</h3>
|
||||
|
||||
<pre class="chroma">$ curl --request POST <span class="se">\
|
||||
</span><span class="se"></span> --url http://localhost:3000/merge <span class="se">\
|
||||
</span><span class="se"></span> --header <span class="s1">'Content-Type: multipart/form-data'</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@file.pdf <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@file2.pdf <span class="se">\
|
||||
</span><span class="se"></span> > result.pdf
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="merge.basic.go" href="#merge.basic.go">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Go</h3>
|
||||
|
||||
<pre class="chroma"><span class="kn">import</span> <span class="s">"github.com/thecodingmachine/gotenberg/pkg"</span>
|
||||
|
||||
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
|
||||
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">"http://localhost:3000"</span><span class="p">}</span>
|
||||
<span class="nx">req</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">MergeRequest</span><span class="p">{</span>
|
||||
<span class="nx">FilePaths</span><span class="p">:</span> <span class="p">[]</span><span class="kt">string</span><span class="p">{</span>
|
||||
<span class="s">"file.pdf"</span><span class="p">,</span>
|
||||
<span class="s">"file2.pdf"</span><span class="p">,</span>
|
||||
<span class="p">},</span>
|
||||
<span class="p">}</span>
|
||||
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">"result.pdf"</span>
|
||||
<span class="nx">c</span><span class="p">.</span><span class="nx">Store</span><span class="p">(</span><span class="nx">req</span><span class="p">,</span> <span class="nx">dest</span><span class="p">)</span>
|
||||
<span class="p">}</span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="merge.basic.php" href="#merge.basic.php">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>PHP</h3>
|
||||
|
||||
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\MergeRequest;
|
||||
</span><span class="x">
|
||||
</span><span class="x">$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
|
||||
</span><span class="x">$files = [
|
||||
</span><span class="x"> DocumentFactory::makeFromPath('file.pdf', 'file.pdf'),
|
||||
</span><span class="x"> DocumentFactory::makeFromPath('file2.pdf', 'file2.pdf'),
|
||||
</span><span class="x">];
|
||||
</span><span class="x">$request = new MergeRequest($files);
|
||||
</span><span class="x">$dirPath = "/foo";
|
||||
</span><span class="x">$filename = $client->store($request, $dirPath);
|
||||
</span><span class="x"></span></pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="Page" id="webhook">
|
||||
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="webhook" href="#webhook">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Webhook</h1>
|
||||
<p>All endpoints accept a form field named <code>webhookURL</code>.</p>
|
||||
|
||||
<p>If provided, the API will send the resulting PDF file in a <code>POST</code> request with the <code>application/pdf</code> Content-Type
|
||||
to given URL.</p>
|
||||
|
||||
<p>By doing so, your requests to the API will be over before the conversions are actually done!</p>
|
||||
|
||||
<h2 class="Heading"><a class="Anchor" aria-hidden="true" id="webhook.examples" href="#webhook.examples">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Examples</h2>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="webhook.examples.c_url" href="#webhook.examples.c_url">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>cURL</h3>
|
||||
|
||||
<pre class="chroma">$ curl --request POST <span class="se">\
|
||||
</span><span class="se"></span> --url http://localhost:3000/convert/html <span class="se">\
|
||||
</span><span class="se"></span> --header <span class="s1">'Content-Type: multipart/form-data'</span> <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">files</span><span class="o">=</span>@index.html <span class="se">\
|
||||
</span><span class="se"></span> --form <span class="nv">webhookURL</span><span class="o">=</span><span class="s1">'http://myapp.com/webhook/'</span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="webhook.examples.go" href="#webhook.examples.go">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Go</h3>
|
||||
|
||||
<pre class="chroma"><span class="kn">import</span> <span class="s">"github.com/thecodingmachine/gotenberg/pkg"</span>
|
||||
|
||||
<span class="kd">func</span> <span class="nx">main</span><span class="p">()</span> <span class="p">{</span>
|
||||
<span class="nx">c</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">Client</span><span class="p">{</span><span class="nx">Hostname</span><span class="p">:</span> <span class="s">"http://localhost:3000"</span><span class="p">}</span>
|
||||
<span class="nx">req</span> <span class="o">:=</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">HTMLRequest</span><span class="p">{</span>
|
||||
<span class="nx">IndexFilePath</span><span class="p">:</span> <span class="s">"index.html"</span><span class="p">,</span>
|
||||
<span class="nx">Options</span><span class="p">:</span> <span class="o">&</span><span class="nx">gotenberg</span><span class="p">.</span><span class="nx">HTMLOptions</span><span class="p">{</span>
|
||||
<span class="nx">WebHookURL</span><span class="p">:</span> <span class="s">"http://myapp.com/webhook/"</span><span class="p">,</span>
|
||||
<span class="p">},</span>
|
||||
<span class="p">}</span>
|
||||
<span class="nx">dest</span> <span class="o">:=</span> <span class="s">"result.pdf"</span>
|
||||
<span class="nx">resp</span><span class="p">,</span> <span class="nx">err</span> <span class="o">:=</span> <span class="nx">c</span><span class="p">.</span><span class="nx">Post</span><span class="p">(</span><span class="nx">req</span><span class="p">)</span>
|
||||
<span class="p">}</span>
|
||||
</pre>
|
||||
|
||||
<h3 class="Heading"><a class="Anchor" aria-hidden="true" id="webhook.examples.php" href="#webhook.examples.php">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>PHP</h3>
|
||||
|
||||
<pre class="chroma"><span class="x">use TheCodingMachine\Gotenberg\Client;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\DocumentFactory;
|
||||
</span><span class="x">use TheCodingMachine\Gotenberg\HTMLRequest;
|
||||
</span><span class="x">
|
||||
</span><span class="x">$client = new Client('http://localhost:3000', new \Http\Adapter\Guzzle6\Client());
|
||||
</span><span class="x">$index = DocumentFactory::makeFromPath('index.html', 'index.html');
|
||||
</span><span class="x">$request = new HTMLRequest($index);
|
||||
</span><span class="x">$request->setWebhookURL('http://myapp.com/webhook/');
|
||||
</span><span class="x">$resp = $client->post($request);
|
||||
</span><span class="x"></span></pre>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="Page" id="scalability">
|
||||
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="scalability" href="#scalability">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Scalability</h1>
|
||||
<p>The API being stateless, you may scale it as much as you want.</p>
|
||||
|
||||
<p>For instance, using the following Docker Compose file:</p>
|
||||
|
||||
<pre class="chroma">version<span class="p">:</span><span class="w"> </span><span class="s1">'3'</span><span class="w">
|
||||
</span><span class="w">
|
||||
</span><span class="w"></span>services<span class="p">:</span><span class="w">
|
||||
</span><span class="w">
|
||||
</span><span class="w"> </span><span class="c"># your others services</span><span class="w">
|
||||
</span><span class="w">
|
||||
</span><span class="w"> </span>gotenberg<span class="p">:</span><span class="w">
|
||||
</span><span class="w"> </span>image<span class="p">:</span><span class="w"> </span>thecodingmachine/gotenberg<span class="p">:</span><span class="m">3</span><span class="w">
|
||||
</span><span class="w"></span></pre>
|
||||
|
||||
<p>You may now launch your services using:</p>
|
||||
|
||||
<pre class="chroma">$ docker-compose up --scale <span class="nv">gotenberg</span><span class="o">=</span>your_number_of_instances
|
||||
</pre>
|
||||
|
||||
<p>When requesting the Gotenberg service with your client(s), Docker will automatically
|
||||
redirect a request to a Gotenberg container according to the round-robin strategy.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="Page" id="links">
|
||||
<h1 class="Heading"><a class="Anchor" aria-hidden="true" id="links" href="#links">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" aria-hidden="true" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="feather feather-link"><path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71"></path><path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71"></path></svg>
|
||||
</a>Links</h1>
|
||||
<ul>
|
||||
<li>Follow the progress on the <a href="https://github.com/thecodingmachine/gotenberg">GitHub repository</a></li>
|
||||
<li>Follow <a href="https://twitter.com/gulnap">@gulnap</a> on Twitter</li>
|
||||
</ul>
|
||||
|
||||
<p>Psst: TheCodingMachine is always looking for <a href="https://coders.thecodingmachine.com">talented coders</a>.</p>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="Footer"></div>
|
||||
<script src="theme/gotenberg/js/index.js"></script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
</div></body></html>
|
||||
342
docs/theme/gotenberg/css/index.css
vendored
Normal file
342
docs/theme/gotenberg/css/index.css
vendored
Normal file
@@ -0,0 +1,342 @@
|
||||
:root {
|
||||
--ease: cubic-bezier(.82, 0, .12, 1);
|
||||
--width: 800px;
|
||||
--header-height: 400px;
|
||||
|
||||
--tracking: 0.05rem;
|
||||
--tracking-medium: 0.5rem;
|
||||
--tracking-large: 0.8rem;
|
||||
|
||||
--dark: #000;
|
||||
--blue: #33A9FF;
|
||||
--light-gray: #fafafa;
|
||||
|
||||
--bg: #fff;
|
||||
--fg: #868E96;
|
||||
--fg-dark: #212529;
|
||||
|
||||
--selection-bg: var(--blue);
|
||||
--selection-fg: white;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
html, body {
|
||||
font-family: -apple-system, BlinkMacSystemFont,
|
||||
"Segoe UI", "Roboto", "Oxygen",
|
||||
"Ubuntu", "Cantarell", "Fira Sans",
|
||||
"Droid Sans", "Helvetica Neue", sans-serif;
|
||||
-webkit-font-smoothing: antialiased;
|
||||
font-size: 16px;
|
||||
font-weight: 300;
|
||||
background: var(--bg);
|
||||
color: var(--fg-dark);
|
||||
line-height: 1;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
::selection {
|
||||
background: var(--selection-bg);
|
||||
color: var(--selection-fg);
|
||||
}
|
||||
|
||||
h1, h2, h3, h4 {
|
||||
margin-top: 75px;
|
||||
margin-bottom: 0;
|
||||
font-size: 1.2rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.5rem;
|
||||
color: var(--fg-dark);
|
||||
}
|
||||
|
||||
h1 {
|
||||
margin-top: 0;
|
||||
font-size: 1.5rem;
|
||||
}
|
||||
|
||||
h1 + p {
|
||||
font-size: 1.5rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
h3 {
|
||||
font-size: 1.0rem;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
a {
|
||||
color: inherit;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
p {
|
||||
margin: 25px 0;
|
||||
line-height: 1.6;
|
||||
color: var(--fg-color-light);
|
||||
}
|
||||
|
||||
li strong,
|
||||
p strong {
|
||||
color: var(--fg-dark);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
li a,
|
||||
p a {
|
||||
color: var(--fg-dark);
|
||||
font-weight: 400;
|
||||
padding-bottom: 3px;
|
||||
border-bottom: 1px dotted #ddd;
|
||||
}
|
||||
|
||||
li a:hover,
|
||||
p a:hover {
|
||||
color: var(--blue);
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
p a:hover {
|
||||
border-bottom-color: var(--color);
|
||||
}
|
||||
|
||||
ul {
|
||||
margin: 50px 0 50px 30px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
ul ul {
|
||||
margin: 10px 0 10px 30px;
|
||||
}
|
||||
|
||||
ul li {
|
||||
margin: 5px 0;
|
||||
color: var(--fg-color-light);
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
ul li strong {
|
||||
color: var(--fg-color);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
pre {
|
||||
background: var(--light-gray);
|
||||
color: var(--dark);
|
||||
padding: 30px;
|
||||
border-radius: 2px;
|
||||
overflow: auto;
|
||||
font: "Source Code Pro", Menlo, monospace;
|
||||
font-size: .8em;
|
||||
line-height: 1.5em;
|
||||
}
|
||||
|
||||
blockquote {
|
||||
margin: 0;
|
||||
padding: 0 1em;
|
||||
color: var(--fg-dark);
|
||||
border-left: 0.25em solid var(--dark);
|
||||
}
|
||||
|
||||
li > code,
|
||||
p > code {
|
||||
border: 1px solid #DEE2E6;
|
||||
font-size: 0.75rem;
|
||||
padding: 3px 10px;
|
||||
border-radius: 3px;
|
||||
white-space: nowrap;
|
||||
font-weight: 600;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
details > summary {
|
||||
cursor: pointer;
|
||||
outline: none;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
details > p {
|
||||
border-left: 3px solid var(--dark);
|
||||
padding-left: 15px;
|
||||
}
|
||||
|
||||
.Wrapper {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.Container {
|
||||
width: var(--width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.Content-wrapper {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.Header {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: var(--header-height);
|
||||
}
|
||||
|
||||
.Sidebar {
|
||||
flex: 1 1 auto;
|
||||
}
|
||||
|
||||
.Content {
|
||||
width: 75%;
|
||||
}
|
||||
|
||||
.Content img {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.Menu {
|
||||
position: -webkit-sticky;
|
||||
position: sticky;
|
||||
top: 50px;
|
||||
color: var(--fg-dark);
|
||||
}
|
||||
|
||||
.Menu > .item {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
|
||||
.Menu > .item > a {
|
||||
position: relative;
|
||||
user-select: none;
|
||||
font-weight: 400;
|
||||
transition: color 200ms;
|
||||
color: var(--fg);
|
||||
}
|
||||
|
||||
.Menu > .item > a.active {
|
||||
color: var(--fg-dark);
|
||||
}
|
||||
|
||||
.Menu > .item > a:before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
width: 100%;
|
||||
height: 1px;
|
||||
bottom: -5px;
|
||||
left: 0;
|
||||
background-color: var(--fg-dark);
|
||||
visibility: hidden;
|
||||
transform: scaleX(0);
|
||||
transform-origin: left center;
|
||||
transition: all 250ms var(--ease);
|
||||
}
|
||||
|
||||
.Menu > .item > a:hover {
|
||||
color: var(--fg-dark);
|
||||
}
|
||||
|
||||
.Menu > .item > a:hover:before {
|
||||
visibility: visible;
|
||||
transform: scaleX(1);
|
||||
}
|
||||
|
||||
.Page {
|
||||
margin-top: 100px;
|
||||
padding-top: 50px;
|
||||
}
|
||||
|
||||
.Page:first-child {
|
||||
margin-top: 0;
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.Title {
|
||||
margin: 5px 0;
|
||||
line-height: 2.2em;
|
||||
}
|
||||
|
||||
.Title.center {
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
text-align: center;
|
||||
max-width: 500px;
|
||||
}
|
||||
|
||||
.Title.margin {
|
||||
margin-bottom: 80px;
|
||||
}
|
||||
|
||||
.Title > span {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.Title .subtext {
|
||||
color: var(--fg-color-light);
|
||||
font-size: 0.8rem;
|
||||
text-transform: uppercase;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.Title .text {
|
||||
letter-spacing: var(--tracking);
|
||||
text-transform: uppercase;
|
||||
font-weight: bold;
|
||||
font-size: 16px;
|
||||
}
|
||||
|
||||
.Title.small .text {
|
||||
font-size: 14px;
|
||||
text-transform: none;
|
||||
letter-spacing: normal;
|
||||
line-height: 2rem;
|
||||
}
|
||||
|
||||
.Anchor {
|
||||
position: relative;
|
||||
margin-left: -14px;
|
||||
opacity: 0.15;
|
||||
display: inline-block;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
}
|
||||
|
||||
.Anchor svg {
|
||||
position: absolute;
|
||||
right: 5px;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.Anchor svg path {
|
||||
pointer-events: all
|
||||
}
|
||||
|
||||
.Anchor:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.Footer {
|
||||
height: 100px;
|
||||
}
|
||||
|
||||
@media screen and (max-width: 850px) {
|
||||
html, body {
|
||||
--header-height: 300px;
|
||||
--width: 80%;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.Content-wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.Sidebar {
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.Content {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
BIN
docs/theme/gotenberg/img/favicon-16x16.png
vendored
Normal file
BIN
docs/theme/gotenberg/img/favicon-16x16.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 923 B |
BIN
docs/theme/gotenberg/img/favicon-32x32.png
vendored
Normal file
BIN
docs/theme/gotenberg/img/favicon-32x32.png
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.8 KiB |
BIN
docs/theme/gotenberg/img/favicon.ico
vendored
Normal file
BIN
docs/theme/gotenberg/img/favicon.ico
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
46
docs/theme/gotenberg/js/index.js
vendored
Normal file
46
docs/theme/gotenberg/js/index.js
vendored
Normal file
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Setup.
|
||||
*/
|
||||
|
||||
const items = document.querySelectorAll('.Page')
|
||||
const links = document.querySelectorAll('.Menu a')
|
||||
|
||||
/**
|
||||
* Check if `el` is out out of view.
|
||||
*/
|
||||
|
||||
function isBelowScroll(el) {
|
||||
return el.getBoundingClientRect().bottom > 0
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate item `i`.
|
||||
*/
|
||||
|
||||
function activateItem(i) {
|
||||
links.forEach(e => e.classList.remove('active'))
|
||||
links[i].classList.add('active')
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate the correct menu item for the
|
||||
* contents in the viewport.
|
||||
*/
|
||||
|
||||
function activate() {
|
||||
let i = 0
|
||||
|
||||
for (; i < items.length; i++) {
|
||||
if (isBelowScroll(items[i])) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
activateItem(i)
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate scroll spy thingy.
|
||||
*/
|
||||
|
||||
window.addEventListener('scroll', e => activate())
|
||||
47
docs/theme/gotenberg/views/index.html
vendored
Normal file
47
docs/theme/gotenberg/views/index.html
vendored
Normal file
@@ -0,0 +1,47 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta http-equiv="x-ua-compatible" content="ie=edge">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{{.Title}} · {{.Subtitle}}</title>
|
||||
<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="stylesheet" href="theme/gotenberg/css/index.css">
|
||||
</head>
|
||||
<body>
|
||||
<div class="Wrapper">
|
||||
<div class="Container">
|
||||
<div class="Header">
|
||||
<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" />
|
||||
<span class="text">{{.Title}}</span>
|
||||
<span class="subtext">{{.Subtitle}}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="Content-wrapper">
|
||||
<div class="Sidebar">
|
||||
<div class="Menu">
|
||||
{{range .Pages}}
|
||||
<div class="item">
|
||||
<a href="#{{.Slug}}">{{.Title}}</a>
|
||||
</div>
|
||||
{{end}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="Content">
|
||||
{{range .Pages}}
|
||||
<div class="Page" id="{{.Slug}}">
|
||||
<h1>{{.Title}}</h1>
|
||||
{{.Content}}
|
||||
</div>
|
||||
{{end}}
|
||||
<div class="Footer"></div>
|
||||
<script src="theme/gotenberg/js/index.js"></script>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
21
go.mod
Normal file
21
go.mod
Normal file
@@ -0,0 +1,21 @@
|
||||
module github.com/thecodingmachine/gotenberg
|
||||
|
||||
require (
|
||||
github.com/derekparker/delve v1.1.0
|
||||
github.com/google/go-cmp v0.2.0 // indirect
|
||||
github.com/gorilla/websocket v1.4.0 // indirect
|
||||
github.com/hhrutter/pdfcpu v0.1.18
|
||||
github.com/labstack/echo v0.0.0-20181123063703-c7eb8da9ec73
|
||||
github.com/labstack/gommon v0.2.8
|
||||
github.com/mafredri/cdp v0.20.0
|
||||
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/shurcooL/sanitized_anchor_name v0.0.0-20170918181015-86672fcb3f95 // indirect
|
||||
github.com/stretchr/objx v0.1.1 // indirect
|
||||
github.com/stretchr/testify v1.2.2
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 // indirect
|
||||
golang.org/x/net v0.0.0-20181201002055-351d144fa1fc
|
||||
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f // indirect
|
||||
golang.org/x/sys v0.0.0-20181128092732-4ed8d59d0b35 // indirect
|
||||
)
|
||||
53
go.sum
Normal file
53
go.sum
Normal file
@@ -0,0 +1,53 @@
|
||||
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/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/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/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||
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/hhrutter/pdfcpu v0.1.18 h1:GX3d6tZxmQlmB5X9VUyOtx7+HZZpR+8ISLqBineP6dc=
|
||||
github.com/hhrutter/pdfcpu v0.1.18/go.mod h1:iaCmXGnOXPIvoGLELZZc4m/GpIopVsvLQ00thrbu8LU=
|
||||
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/go.mod h1:/tj9csK2iPSBvn+3NLM9e52usepMtrd5ilFYA+wQNJ4=
|
||||
github.com/mafredri/cdp v0.20.0 h1:dRq16FwXCAAbolR4W2iyRNsL14Bp6jFHLbRp2EkICVs=
|
||||
github.com/mafredri/cdp v0.20.0/go.mod h1:hgdiA0yp1uqhSaDOHJWPgXpMbh+LAfUdD9vbN2AM8gE=
|
||||
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-isatty v0.0.4 h1:bnP0vzxcAdeI1zdubAl5PjU6zsERjGZb7raWodagDYs=
|
||||
github.com/mattn/go-isatty v0.0.4/go.mod h1:M+lRXTBqGeGNdLjl/ufCoiOlB5xdOkqRJdNxMWT7Zi4=
|
||||
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/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/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/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/go.mod h1:1NzhyTcUVG4SuEtjjoZeVRXNmyL/1OwPU0+IJeTBvfc=
|
||||
github.com/stretchr/objx v0.1.1 h1:2vfRuCMp5sSVIDSqO8oNnWJq7mPa6KVP3iPIwFBuy8A=
|
||||
github.com/stretchr/objx v0.1.1/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||
github.com/stretchr/testify v1.2.2 h1:bSDNvY7ZPG5RlJ8otE/7V6gMiyenm9RtJ7IUVIAoJ1w=
|
||||
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/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/go.mod h1:50wTf68f99/Zt14pr046Tgt3Lp2vLyFZKzbFXTOabXw=
|
||||
golang.org/x/crypto v0.0.0-20181106171534-e4dc69e5b2fd/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9 h1:mKdxBk7AujPs8kU4m80U72y/zjbZ3UcXC7dClwKbUI0=
|
||||
golang.org/x/crypto v0.0.0-20181203042331-505ab145d0a9/go.mod h1:6SG95UA2DQfeDnfUPMdvaQW0Q7yPrPDi9nlGo2tz2b4=
|
||||
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/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/sys v0.0.0-20181107165924-66b7b1311ac8/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-20181128092732-4ed8d59d0b35/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||
125
internal/app/api/api.go
Normal file
125
internal/app/api/api.go
Normal file
@@ -0,0 +1,125 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/labstack/echo/middleware"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/pm2"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
|
||||
)
|
||||
|
||||
// Start starts the API server on port 3000.
|
||||
func Start() error {
|
||||
e := setup()
|
||||
// start Chrome headless and
|
||||
// unoconv listener with PM2.
|
||||
chrome := &pm2.Chrome{}
|
||||
unoconv := &pm2.Unoconv{}
|
||||
if err := chrome.Launch(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := unoconv.Launch(); err != nil {
|
||||
return err
|
||||
}
|
||||
// run our API in a goroutine so that it doesn't block.
|
||||
go func() {
|
||||
notify.Println("http server started on port 3000")
|
||||
if err := e.Start(":3000"); err != nil {
|
||||
e.Logger.Fatalf("%v", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
}()
|
||||
quit := make(chan os.Signal, 1)
|
||||
// we'll accept graceful shutdowns when quit via SIGINT (Ctrl+C)
|
||||
// SIGKILL, SIGQUIT or SIGTERM (Ctrl+/) will not be caught.
|
||||
signal.Notify(quit, os.Interrupt)
|
||||
// block until we receive our signal.
|
||||
<-quit
|
||||
// create a deadline to wait for.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
defer cancel()
|
||||
// doesn't block if no connections, but will otherwise wait
|
||||
// until the timeout deadline.
|
||||
notify.Println("shutting down http server... (Ctrl+C to force)")
|
||||
return e.Shutdown(ctx)
|
||||
}
|
||||
|
||||
func setup() *echo.Echo {
|
||||
e := echo.New()
|
||||
e.HideBanner = true
|
||||
e.HidePort = true
|
||||
e.Use(middleware.Logger())
|
||||
e.Use(func(next echo.HandlerFunc) echo.HandlerFunc {
|
||||
return func(c echo.Context) error {
|
||||
if err := next(c); err != nil {
|
||||
// TODO should return a better HTTP status code
|
||||
// than 500 for some cases.
|
||||
return echo.NewHTTPError(http.StatusInternalServerError, fmt.Sprintf("%v", err))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
})
|
||||
e.POST("/merge", merge)
|
||||
g := e.Group("/convert")
|
||||
g.POST("/html", convertHTML)
|
||||
g.POST("/markdown", convertMarkdown)
|
||||
g.POST("/office", convertOffice)
|
||||
return e
|
||||
}
|
||||
|
||||
func newContext(r *resource) (context.Context, context.CancelFunc) {
|
||||
webhookURL := r.webhookURL()
|
||||
if webhookURL == "" {
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second)
|
||||
return ctx, cancel
|
||||
}
|
||||
return context.Background(), nil
|
||||
}
|
||||
|
||||
func print(c echo.Context, p printer.Printer, r *resource) error {
|
||||
baseFilename, err := rand.Get()
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting result file name: %v", err)
|
||||
}
|
||||
filename := fmt.Sprintf("%s.pdf", baseFilename)
|
||||
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
|
||||
if r.webhookURL() == "" {
|
||||
// if no webhook URL given, run conversion
|
||||
// and directly return the resulting PDF file
|
||||
// or and error.
|
||||
if err := p.Print(fpath); err != nil {
|
||||
return err
|
||||
}
|
||||
return c.Attachment(fpath, filename)
|
||||
}
|
||||
// as a webhook URL has been given, we
|
||||
// run the following lines in a goroutine so that
|
||||
// it doesn't block.
|
||||
go func() {
|
||||
if err := p.Print(fpath); err != nil {
|
||||
c.Logger().Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
f, err := os.Open(fpath)
|
||||
if err != nil {
|
||||
c.Logger().Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
defer f.Close()
|
||||
resp, err := http.Post(r.webhookURL(), "application/pdf", f)
|
||||
if err != nil {
|
||||
c.Logger().Errorf("%v", err)
|
||||
return
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
9
internal/app/api/doc.go
Normal file
9
internal/app/api/doc.go
Normal file
@@ -0,0 +1,9 @@
|
||||
/*
|
||||
Package api starts a HTTP server on port 3000.
|
||||
|
||||
It accepts POST requests with a multipart/form-data Content-Type
|
||||
for converting HTML, Markdown and Office documents to PDF.
|
||||
|
||||
It is also able to merge a list of PDF files.
|
||||
*/
|
||||
package api
|
||||
52
internal/app/api/html.go
Normal file
52
internal/app/api/html.go
Normal file
@@ -0,0 +1,52 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
|
||||
)
|
||||
|
||||
func convertHTML(c echo.Context) error {
|
||||
r, err := newResource(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.removeAll()
|
||||
ctx, cancel := newContext(r)
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
p := &printer.HTML{Context: ctx}
|
||||
indexPath, err := r.filePath("index.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.WithLocalURL(indexPath)
|
||||
headerPath, _ := r.filePath("header.html")
|
||||
if err := p.WithHeaderFile(headerPath); err != nil {
|
||||
return err
|
||||
}
|
||||
footerPath, _ := r.filePath("footer.html")
|
||||
if err := p.WithFooterFile(footerPath); err != nil {
|
||||
return err
|
||||
}
|
||||
paperSize, err := r.paperSize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.PaperWidth = paperSize[0]
|
||||
p.PaperHeight = paperSize[1]
|
||||
paperMargins, err := r.paperMargins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.MarginTop = paperMargins[0]
|
||||
p.MarginBottom = paperMargins[1]
|
||||
p.MarginLeft = paperMargins[2]
|
||||
p.MarginRight = paperMargins[3]
|
||||
landscape, err := r.landscape()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Landscape = landscape
|
||||
return print(c, p, r)
|
||||
}
|
||||
24
internal/app/api/html_test.go
Normal file
24
internal/app/api/html_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
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.HTMLTestMultipartForm(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)
|
||||
if assert.NoError(t, convertHTML(c)) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType))
|
||||
}
|
||||
}
|
||||
51
internal/app/api/markdown.go
Normal file
51
internal/app/api/markdown.go
Normal file
@@ -0,0 +1,51 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"github.com/labstack/echo"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
|
||||
)
|
||||
|
||||
func convertMarkdown(c echo.Context) error {
|
||||
r, err := newResource(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.removeAll()
|
||||
ctx, cancel := newContext(r)
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
indexPath, err := r.filePath("index.html")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p := &printer.Markdown{Context: ctx, TemplatePath: indexPath}
|
||||
headerPath, _ := r.filePath("header.html")
|
||||
if err := p.WithHeaderFile(headerPath); err != nil {
|
||||
return err
|
||||
}
|
||||
footerPath, _ := r.filePath("footer.html")
|
||||
if err := p.WithFooterFile(footerPath); err != nil {
|
||||
return err
|
||||
}
|
||||
paperSize, err := r.paperSize()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.PaperWidth = paperSize[0]
|
||||
p.PaperHeight = paperSize[1]
|
||||
paperMargins, err := r.paperMargins()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.MarginTop = paperMargins[0]
|
||||
p.MarginBottom = paperMargins[1]
|
||||
p.MarginLeft = paperMargins[2]
|
||||
p.MarginRight = paperMargins[3]
|
||||
landscape, err := r.landscape()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.Landscape = landscape
|
||||
return print(c, p, r)
|
||||
}
|
||||
24
internal/app/api/markdown_test.go
Normal file
24
internal/app/api/markdown_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
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.MarkdownTestMultipartForm(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)
|
||||
if assert.NoError(t, convertMarkdown(c)) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType))
|
||||
}
|
||||
}
|
||||
60
internal/app/api/merge.go
Normal file
60
internal/app/api/merge.go
Normal file
@@ -0,0 +1,60 @@
|
||||
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
|
||||
}
|
||||
24
internal/app/api/merge_test.go
Normal file
24
internal/app/api/merge_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
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.PDFTestMultipartForm(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)
|
||||
if assert.NoError(t, merge(c)) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType))
|
||||
}
|
||||
}
|
||||
41
internal/app/api/office.go
Normal file
41
internal/app/api/office.go
Normal file
@@ -0,0 +1,41 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/printer"
|
||||
)
|
||||
|
||||
var officeExts = []string{
|
||||
".doc",
|
||||
".docx",
|
||||
".odt",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ods",
|
||||
".ppt",
|
||||
".pptx",
|
||||
".odp",
|
||||
}
|
||||
|
||||
func convertOffice(c echo.Context) error {
|
||||
r, err := newResource(c)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer r.removeAll()
|
||||
ctx, cancel := newContext(r)
|
||||
if cancel != nil {
|
||||
defer cancel()
|
||||
}
|
||||
fpaths, err := r.filePaths(officeExts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(fpaths) == 0 {
|
||||
return errors.New("no suitable office documents to convert")
|
||||
}
|
||||
p := &printer.Office{Context: ctx, FilePaths: fpaths}
|
||||
return print(c, p, r)
|
||||
}
|
||||
24
internal/app/api/office_test.go
Normal file
24
internal/app/api/office_test.go
Normal file
@@ -0,0 +1,24 @@
|
||||
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.OfficeTestMultipartForm(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)
|
||||
if assert.NoError(t, convertOffice(c)) {
|
||||
assert.Equal(t, http.StatusOK, rec.Code)
|
||||
assert.Equal(t, "application/pdf", rec.Header().Get(echo.HeaderContentType))
|
||||
}
|
||||
}
|
||||
184
internal/app/api/resource.go
Normal file
184
internal/app/api/resource.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
|
||||
"github.com/labstack/echo"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
|
||||
)
|
||||
|
||||
const (
|
||||
webhookURL string = "webhookURL"
|
||||
paperWidth string = "paperWidth"
|
||||
paperHeight string = "paperHeight"
|
||||
marginTop string = "marginTop"
|
||||
marginBottom string = "marginBottom"
|
||||
marginLeft string = "marginLeft"
|
||||
marginRight string = "marginRight"
|
||||
landscape string = "landscape"
|
||||
)
|
||||
|
||||
// resource facilitates storing and accessing
|
||||
// data from a multipart/form-data request.
|
||||
type resource struct {
|
||||
values map[string]string
|
||||
dirPath string
|
||||
}
|
||||
|
||||
func newResource(c echo.Context) (*resource, error) {
|
||||
v := make(map[string]string)
|
||||
v[webhookURL] = c.FormValue(webhookURL)
|
||||
v[paperWidth] = c.FormValue(paperWidth)
|
||||
v[paperHeight] = c.FormValue(paperHeight)
|
||||
v[marginTop] = c.FormValue(marginTop)
|
||||
v[marginBottom] = c.FormValue(marginBottom)
|
||||
v[marginLeft] = c.FormValue(marginLeft)
|
||||
v[marginRight] = c.FormValue(marginRight)
|
||||
v[landscape] = c.FormValue(landscape)
|
||||
dirPath, err := rand.Get()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := os.MkdirAll(dirPath, 0755); err != nil {
|
||||
return nil, fmt.Errorf("%s: making directory: %v", dirPath, err)
|
||||
}
|
||||
r := &resource{values: v, dirPath: dirPath}
|
||||
form, err := c.MultipartForm()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("getting multipart form: %v", err)
|
||||
}
|
||||
for _, files := range form.File {
|
||||
for _, fh := range files {
|
||||
in, err := fh.Open()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("%s: opening file: %v", fh.Filename, err)
|
||||
}
|
||||
defer in.Close()
|
||||
if err := r.writeFile(fh.Filename, in); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
}
|
||||
return r, nil
|
||||
}
|
||||
|
||||
func (r *resource) writeFile(filename string, in io.Reader) error {
|
||||
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
|
||||
out, err := os.Create(fpath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: creating new file: %v", fpath, err)
|
||||
}
|
||||
defer out.Close()
|
||||
if err := out.Chmod(0644); err != nil {
|
||||
return fmt.Errorf("%s: changing file mode: %v", fpath, err)
|
||||
}
|
||||
if _, err := io.Copy(out, in); err != nil {
|
||||
return fmt.Errorf("%s: writing file: %v", fpath, err)
|
||||
}
|
||||
if _, err := out.Seek(0, 0); err != nil {
|
||||
return fmt.Errorf("%s: resetting read pointer: %v", fpath, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *resource) filePath(filename string) (string, error) {
|
||||
fpath := fmt.Sprintf("%s/%s", r.dirPath, filename)
|
||||
_, err := os.Stat(fpath)
|
||||
if os.IsNotExist(err) {
|
||||
return "", fmt.Errorf("%s: file does not exist", filename)
|
||||
}
|
||||
absPath, err := filepath.Abs(fpath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: getting absolute path: %v", fpath, err)
|
||||
}
|
||||
return absPath, nil
|
||||
}
|
||||
|
||||
func (r *resource) filePaths(exts []string) ([]string, error) {
|
||||
var fpaths []string
|
||||
err := filepath.Walk(r.dirPath, func(path string, info os.FileInfo, _ error) error {
|
||||
if info.IsDir() {
|
||||
return nil
|
||||
}
|
||||
fpath, err := r.filePath(info.Name())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, ext := range exts {
|
||||
if filepath.Ext(fpath) == ext {
|
||||
fpaths = append(fpaths, fpath)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return fpaths, nil
|
||||
}
|
||||
|
||||
func (r *resource) paperSize() ([2]float64, error) {
|
||||
defaultSize := [2]float64{8.27, 11.7}
|
||||
widthStr := r.values[paperWidth]
|
||||
heightStr := r.values[paperHeight]
|
||||
if widthStr == "" || heightStr == "" {
|
||||
return defaultSize, nil
|
||||
}
|
||||
width, err := strconv.ParseFloat(widthStr, 64)
|
||||
if err != nil {
|
||||
return defaultSize, fmt.Errorf("paper width: %v", err)
|
||||
}
|
||||
height, err := strconv.ParseFloat(heightStr, 64)
|
||||
if err != nil {
|
||||
return defaultSize, fmt.Errorf("paper height: %v", err)
|
||||
}
|
||||
return [2]float64{width, height}, nil
|
||||
}
|
||||
|
||||
func (r *resource) paperMargins() ([4]float64, error) {
|
||||
defaultMargins := [4]float64{1, 1, 1, 1}
|
||||
topStr := r.values[marginTop]
|
||||
bottomStr := r.values[marginBottom]
|
||||
leftStr := r.values[marginLeft]
|
||||
rightStr := r.values[marginRight]
|
||||
if topStr == "" || bottomStr == "" || leftStr == "" || rightStr == "" {
|
||||
return defaultMargins, nil
|
||||
}
|
||||
top, err := strconv.ParseFloat(topStr, 64)
|
||||
if err != nil {
|
||||
return defaultMargins, fmt.Errorf("margin top: %v", err)
|
||||
}
|
||||
bottom, err := strconv.ParseFloat(bottomStr, 64)
|
||||
if err != nil {
|
||||
return defaultMargins, fmt.Errorf("margin bottom: %v", err)
|
||||
}
|
||||
left, err := strconv.ParseFloat(leftStr, 64)
|
||||
if err != nil {
|
||||
return defaultMargins, fmt.Errorf("margin left: %v", err)
|
||||
}
|
||||
right, err := strconv.ParseFloat(rightStr, 64)
|
||||
if err != nil {
|
||||
return defaultMargins, fmt.Errorf("margin right: %v", err)
|
||||
}
|
||||
return [4]float64{top, bottom, left, right}, nil
|
||||
}
|
||||
|
||||
func (r *resource) landscape() (bool, error) {
|
||||
landscapeStr := r.values[landscape]
|
||||
if landscapeStr == "" {
|
||||
return false, nil
|
||||
}
|
||||
landscape, err := strconv.ParseBool(landscapeStr)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("landscape: %v", err)
|
||||
}
|
||||
return landscape, nil
|
||||
}
|
||||
|
||||
func (r *resource) webhookURL() string { return r.values[webhookURL] }
|
||||
func (r *resource) removeAll() error { return os.RemoveAll(r.dirPath) }
|
||||
5
internal/pkg/notify/doc.go
Normal file
5
internal/pkg/notify/doc.go
Normal file
@@ -0,0 +1,5 @@
|
||||
/*
|
||||
Package notify is used across the application
|
||||
to display nice outputs to the user.
|
||||
*/
|
||||
package notify
|
||||
30
internal/pkg/notify/notify.go
Normal file
30
internal/pkg/notify/notify.go
Normal file
@@ -0,0 +1,30 @@
|
||||
package notify
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
|
||||
"github.com/labstack/gommon/color"
|
||||
)
|
||||
|
||||
var (
|
||||
stdout *color.Color
|
||||
stderr *color.Color
|
||||
)
|
||||
|
||||
func init() {
|
||||
stdout = color.New()
|
||||
stdout.SetOutput(os.Stdout)
|
||||
stderr = color.New()
|
||||
stderr.SetOutput(os.Stderr)
|
||||
}
|
||||
|
||||
// Println prints a message to stdout.
|
||||
func Println(message string) {
|
||||
stdout.Printf("⇨ %s\n", message)
|
||||
}
|
||||
|
||||
// ErrPrintln prints an error to stderr.
|
||||
func ErrPrintln(err error) {
|
||||
stderr.Printf("%s\n", color.Red(fmt.Sprintf("⇨ error: %v", err)))
|
||||
}
|
||||
71
internal/pkg/pm2/chrome.go
Normal file
71
internal/pkg/pm2/chrome.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package pm2
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/mafredri/cdp/devtool"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
|
||||
)
|
||||
|
||||
// Chrome facilitates starting or shutting down
|
||||
// Chrome headless with PM2.
|
||||
type Chrome struct{}
|
||||
|
||||
// Launch starts Chrome headless with PM2.
|
||||
func (c *Chrome) Launch() error {
|
||||
return launch(c)
|
||||
}
|
||||
|
||||
// Shutdown stops Chrome headless and
|
||||
// removes it from the list of PM2
|
||||
// processes.
|
||||
func (c *Chrome) Shutdown() error {
|
||||
return shutdown(c)
|
||||
}
|
||||
|
||||
func (c *Chrome) getArgs() []string {
|
||||
return []string{
|
||||
"--no-sandbox",
|
||||
"--headless",
|
||||
"--remote-debugging-port=9222",
|
||||
"--disable-gpu",
|
||||
"--disable-translate",
|
||||
"--disable-extensions",
|
||||
"--disable-background-networking",
|
||||
"--safebrowsing-disable-auto-update",
|
||||
"--disable-sync",
|
||||
"--disable-default-apps",
|
||||
"--hide-scrollbars",
|
||||
"--metrics-recording-only",
|
||||
"--mute-audio",
|
||||
"--no-first-run",
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Chrome) getName() string {
|
||||
return "google-chrome-stable"
|
||||
}
|
||||
|
||||
func (c *Chrome) getFullname() string {
|
||||
return "Chrome headless"
|
||||
}
|
||||
|
||||
func (c *Chrome) isViable() bool {
|
||||
// check if Chrome is correctly running.
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
_, err := devtool.New("http://localhost:9222").Version(ctx)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func (c *Chrome) warmup() {
|
||||
notify.Println(fmt.Sprintf("warming-up %s", c.getFullname()))
|
||||
time.Sleep(5 * time.Second)
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Process(new(Chrome))
|
||||
)
|
||||
19
internal/pkg/pm2/chrome_test.go
Normal file
19
internal/pkg/pm2/chrome_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package pm2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestChromeLaunch(t *testing.T) {
|
||||
p := &Chrome{}
|
||||
err := p.Launch()
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestChromeShutdown(t *testing.T) {
|
||||
p := &Chrome{}
|
||||
err := p.Shutdown()
|
||||
require.Nil(t, err)
|
||||
}
|
||||
12
internal/pkg/pm2/doc.go
Normal file
12
internal/pkg/pm2/doc.go
Normal file
@@ -0,0 +1,12 @@
|
||||
/*
|
||||
Package pm2 facilitates starting external
|
||||
processes on which our API depends.
|
||||
|
||||
For instance, it starts Chrome headless and
|
||||
unoconv listener with PM2.
|
||||
|
||||
The PM2 process manager launch those processes and keep
|
||||
them running in the background. If for some reason they
|
||||
crash, it will also restart them.
|
||||
*/
|
||||
package pm2
|
||||
71
internal/pkg/pm2/pm2.go
Normal file
71
internal/pkg/pm2/pm2.go
Normal file
@@ -0,0 +1,71 @@
|
||||
package pm2
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os/exec"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/notify"
|
||||
)
|
||||
|
||||
// Process is a type that can launch or
|
||||
// shutdown a process with PM2.
|
||||
type Process interface {
|
||||
Launch() error
|
||||
Shutdown() error
|
||||
getArgs() []string
|
||||
getName() string
|
||||
getFullname() string
|
||||
isViable() bool
|
||||
warmup()
|
||||
}
|
||||
|
||||
const maxRestartAttempts int = 5
|
||||
|
||||
var humanNames = map[string]string{
|
||||
"start": "started",
|
||||
"restart": "restarted",
|
||||
"stop": "stopped",
|
||||
}
|
||||
|
||||
func launch(p Process) error {
|
||||
if err := run(p, "start"); err != nil {
|
||||
return err
|
||||
}
|
||||
p.warmup()
|
||||
if !p.isViable() {
|
||||
attempts := 0
|
||||
for attempts < maxRestartAttempts && !p.isViable() {
|
||||
run(p, "restart")
|
||||
p.warmup()
|
||||
attempts++
|
||||
}
|
||||
if !p.isViable() {
|
||||
return fmt.Errorf("failed to launch %s", p.getFullname())
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func shutdown(p Process) error {
|
||||
return run(p, "stop")
|
||||
}
|
||||
|
||||
func run(p Process, cmdName string) error {
|
||||
cmdArgs := []string{
|
||||
cmdName,
|
||||
p.getName(),
|
||||
}
|
||||
if cmdName == "start" {
|
||||
cmdArgs = append(cmdArgs, "--interpreter none", "--")
|
||||
cmdArgs = append(cmdArgs, p.getArgs()...)
|
||||
}
|
||||
cmd := exec.Command(
|
||||
"pm2",
|
||||
cmdArgs...,
|
||||
)
|
||||
if err := cmd.Start(); err != nil {
|
||||
return fmt.Errorf("%s %s with PM2: %v", cmdName, p.getFullname(), err)
|
||||
}
|
||||
notify.Println(fmt.Sprintf("%s %s with PM2", p.getFullname(), humanNames[cmdName]))
|
||||
return nil
|
||||
}
|
||||
47
internal/pkg/pm2/unoconv.go
Normal file
47
internal/pkg/pm2/unoconv.go
Normal file
@@ -0,0 +1,47 @@
|
||||
package pm2
|
||||
|
||||
// Unoconv facilitates starting or shutting down
|
||||
// unoconv listener with PM2.
|
||||
type Unoconv struct{}
|
||||
|
||||
// Launch starts unoconv listener with PM2.
|
||||
func (u *Unoconv) Launch() error {
|
||||
return launch(u)
|
||||
}
|
||||
|
||||
// Shutdown stops unoconv listener and
|
||||
// removes it from the list of PM2
|
||||
// processes.
|
||||
func (u *Unoconv) Shutdown() error {
|
||||
return shutdown(u)
|
||||
}
|
||||
|
||||
func (u *Unoconv) getArgs() []string {
|
||||
return []string{
|
||||
"--listener",
|
||||
"--verbose",
|
||||
}
|
||||
}
|
||||
|
||||
func (u *Unoconv) getName() string {
|
||||
return "unoconv"
|
||||
}
|
||||
|
||||
func (u *Unoconv) getFullname() string {
|
||||
return "unoconv listener"
|
||||
}
|
||||
|
||||
func (u *Unoconv) isViable() bool {
|
||||
// TODO find a way to check if
|
||||
// unoconv is correctly started?
|
||||
return true
|
||||
}
|
||||
|
||||
func (u *Unoconv) warmup() {
|
||||
// let's do nothing.
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Process(new(Unoconv))
|
||||
)
|
||||
19
internal/pkg/pm2/unoconv_test.go
Normal file
19
internal/pkg/pm2/unoconv_test.go
Normal file
@@ -0,0 +1,19 @@
|
||||
package pm2
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestUnoconvLaunch(t *testing.T) {
|
||||
p := &Unoconv{}
|
||||
err := p.Launch()
|
||||
require.Nil(t, err)
|
||||
}
|
||||
|
||||
func TestUnoconvShutdown(t *testing.T) {
|
||||
p := &Unoconv{}
|
||||
err := p.Shutdown()
|
||||
require.Nil(t, err)
|
||||
}
|
||||
52
internal/pkg/printer/doc.go
Normal file
52
internal/pkg/printer/doc.go
Normal file
@@ -0,0 +1,52 @@
|
||||
/*
|
||||
Package printer contains structs which convert
|
||||
a specific file type to PDF:
|
||||
|
||||
// converting HTML to PDF.
|
||||
p := &printer.HTML{
|
||||
Context: context.Background(),
|
||||
PaperWidth: 8.27,
|
||||
PaperHeight: 11.7,
|
||||
Landscape: false,
|
||||
}
|
||||
p.WithLocalURL("index.html")
|
||||
if err := p.Print("result.pdf"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// converting Markdown to PDF:
|
||||
// it assumes here that our template "index.html"
|
||||
// will call toHTML method to convert
|
||||
// markdown files to HTML.
|
||||
p := &printer.Markdown{
|
||||
Context: context.Background(),
|
||||
TemplatePath: "index.html",
|
||||
PaperWidth: 8.27,
|
||||
PaperHeight: 11.7,
|
||||
Landscape: false,
|
||||
}
|
||||
if err := p.Print("result.pdf"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// converting Office documents to PDF:
|
||||
// it converts each files independently and
|
||||
// then merge them.
|
||||
//
|
||||
// Also, as unoconv cannot perform
|
||||
// concurrent conversions, a lock is applied.
|
||||
p := &printer.Office{
|
||||
Context: ctx,
|
||||
FilePaths: []string{"document.docx", "presentation.pptx"}
|
||||
}
|
||||
if err := p.Print("result.pdf"); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
It is also able to merge a list of PDF files:
|
||||
|
||||
if err := printer.Merge([]string{"foo.pdf", "bar.pdf"}, "result.pdf"); err != nil {
|
||||
return err
|
||||
}
|
||||
*/
|
||||
package printer
|
||||
189
internal/pkg/printer/html.go
Normal file
189
internal/pkg/printer/html.go
Normal file
@@ -0,0 +1,189 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
"strings"
|
||||
|
||||
"github.com/mafredri/cdp"
|
||||
"github.com/mafredri/cdp/devtool"
|
||||
"github.com/mafredri/cdp/protocol/network"
|
||||
"github.com/mafredri/cdp/protocol/page"
|
||||
"github.com/mafredri/cdp/protocol/runtime"
|
||||
"github.com/mafredri/cdp/protocol/target"
|
||||
"github.com/mafredri/cdp/rpcc"
|
||||
)
|
||||
|
||||
// HTML facilitates HTML to PDF conversion.
|
||||
type HTML struct {
|
||||
Context context.Context
|
||||
URL string
|
||||
HeaderHTML string
|
||||
FooterHTML string
|
||||
PaperWidth float64
|
||||
PaperHeight float64
|
||||
MarginTop float64
|
||||
MarginBottom float64
|
||||
MarginLeft float64
|
||||
MarginRight float64
|
||||
Landscape bool
|
||||
}
|
||||
|
||||
const defaultHeaderFooterHTML string = "<html><head></head><body></body></html>"
|
||||
|
||||
// Print converts HTML to PDF.
|
||||
// Credits: https://medium.com/compass-true-north/go-service-to-convert-web-pages-to-pdf-using-headless-chrome-5fd9ffbae1af
|
||||
func (html *HTML) Print(destination string) error {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
// use the DevTools HTTP/JSON API to manage targets (e.g. pages, webworkers).
|
||||
devt, err := devtool.New("http://localhost:9222").Version(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating DevTools target: %v", err)
|
||||
}
|
||||
// open a new RPC connection to the Chrome Debugging Protocol target.
|
||||
conn, err := rpcc.DialContext(html.Context, devt.WebSocketDebuggerURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating RPC connection: %v", err)
|
||||
}
|
||||
defer conn.Close()
|
||||
// create new browser context.
|
||||
baseBrowser := cdp.NewClient(conn)
|
||||
newContextTarget, err := baseBrowser.Target.CreateBrowserContext(html.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating new browser context: %v", err)
|
||||
}
|
||||
// create a new blank target with the new browser context.
|
||||
newTargetArgs := target.NewCreateTargetArgs("about:blank").
|
||||
SetBrowserContextID(newContextTarget.BrowserContextID)
|
||||
newTarget, err := baseBrowser.Target.CreateTarget(html.Context, newTargetArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating new blank target: %v", err)
|
||||
}
|
||||
// connect the client to the new target.
|
||||
newTargetWsURL := fmt.Sprintf("ws://127.0.0.1:9222/devtools/page/%s", newTarget.TargetID)
|
||||
newContextConn, err := rpcc.DialContext(html.Context, newTargetWsURL)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connecting client to blank target: %v", err)
|
||||
}
|
||||
defer newContextConn.Close()
|
||||
// close the target when done.
|
||||
closeTargetArgs := target.NewCloseTargetArgs(newTarget.TargetID)
|
||||
defer baseBrowser.Target.CloseTarget(html.Context, closeTargetArgs)
|
||||
c := cdp.NewClient(newContextConn)
|
||||
// enable the runtime.
|
||||
if err := c.Runtime.Enable(html.Context); err != nil {
|
||||
return fmt.Errorf("enabling runtime: %v", err)
|
||||
}
|
||||
// enable the network.
|
||||
if err := c.Network.Enable(html.Context, network.NewEnableArgs()); err != nil {
|
||||
return fmt.Errorf("enabling network: %v", err)
|
||||
}
|
||||
// enable events on the page domain.
|
||||
if err := c.Page.Enable(html.Context); err != nil {
|
||||
return fmt.Errorf("enabling events on page domain: %v", err)
|
||||
}
|
||||
// create a client to listen for the load event to be fired.
|
||||
loadEventFiredClient, err := c.Page.LoadEventFired(html.Context)
|
||||
if err != nil {
|
||||
return fmt.Errorf("creating client listening for load event: %v", err)
|
||||
}
|
||||
defer loadEventFiredClient.Close()
|
||||
// tell the page to navigate to the URL.
|
||||
navArgs := page.NewNavigateArgs(html.URL)
|
||||
_, err = c.Page.Navigate(html.Context, navArgs)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: navigating to URL: %v", html.URL, err)
|
||||
}
|
||||
// wait for the page to finish loading.
|
||||
_, err = loadEventFiredClient.Recv()
|
||||
if err != nil {
|
||||
return fmt.Errorf("waiting for page loading: %v", err)
|
||||
}
|
||||
// inject a script to make sure web fonts are loaded.
|
||||
script := `new Promise((resolve, reject) => {
|
||||
document.fonts.ready.then(function () {
|
||||
resolve('fonts loaded');
|
||||
});
|
||||
setTimeout(resolve.bind(resolve, 'timeout'), %.0f);
|
||||
});`
|
||||
scriptArg := runtime.NewEvaluateArgs(script).SetAwaitPromise(true)
|
||||
returnObj, _ := c.Runtime.Evaluate(html.Context, scriptArg)
|
||||
loadFontsResult := string(returnObj.Result.Value)
|
||||
if strings.Contains(loadFontsResult, "timeout") {
|
||||
return errors.New("timed out loading fonts")
|
||||
}
|
||||
// if no header or footer, use the default template
|
||||
// for avoiding displaying default Chrome templates.
|
||||
if html.HeaderHTML == "" {
|
||||
html.HeaderHTML = defaultHeaderFooterHTML
|
||||
}
|
||||
if html.FooterHTML == "" {
|
||||
html.FooterHTML = defaultHeaderFooterHTML
|
||||
}
|
||||
print, err := c.Page.PrintToPDF(
|
||||
html.Context,
|
||||
page.NewPrintToPDFArgs().
|
||||
SetPaperWidth(html.PaperWidth).
|
||||
SetPaperHeight(html.PaperHeight).
|
||||
SetMarginTop(html.MarginTop).
|
||||
SetMarginBottom(html.MarginBottom).
|
||||
SetMarginLeft(html.MarginLeft).
|
||||
SetMarginRight(html.MarginRight).
|
||||
SetLandscape(html.Landscape).
|
||||
SetDisplayHeaderFooter(true).
|
||||
SetHeaderTemplate(html.HeaderHTML).
|
||||
SetFooterTemplate(html.FooterHTML).
|
||||
SetPrintBackground(true),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("printing page to PDF: %v", err)
|
||||
}
|
||||
return writeBytesToFile(destination, print.Data)
|
||||
}
|
||||
|
||||
// WithLocalURL sets a local URL from a file path.
|
||||
func (html *HTML) WithLocalURL(fpath string) {
|
||||
html.URL = fmt.Sprintf("file://%s", fpath)
|
||||
}
|
||||
|
||||
// WithHeaderFile sets header content from a file.
|
||||
func (html *HTML) WithHeaderFile(fpath string) error {
|
||||
if fpath == "" {
|
||||
return nil
|
||||
}
|
||||
contentHTML, err := fileContentToString(fpath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
html.HeaderHTML = contentHTML
|
||||
return nil
|
||||
}
|
||||
|
||||
// WithFooterFile sets footer content from a file.
|
||||
func (html *HTML) WithFooterFile(fpath string) error {
|
||||
if fpath == "" {
|
||||
return nil
|
||||
}
|
||||
contentHTML, err := fileContentToString(fpath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
html.FooterHTML = contentHTML
|
||||
return nil
|
||||
}
|
||||
|
||||
func fileContentToString(fpath string) (string, error) {
|
||||
b, err := ioutil.ReadFile(fpath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: reading file: %v", fpath, err)
|
||||
}
|
||||
return string(b), nil
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Printer(new(HTML))
|
||||
)
|
||||
39
internal/pkg/printer/html_test.go
Normal file
39
internal/pkg/printer/html_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/thecodingmachine/gotenberg/test"
|
||||
)
|
||||
|
||||
func TestHTML(t *testing.T) {
|
||||
dirPath := test.HTMLTestDirPath(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
html := &HTML{
|
||||
Context: ctx,
|
||||
PaperWidth: 8.27,
|
||||
PaperHeight: 11.7,
|
||||
MarginTop: 1,
|
||||
MarginBottom: 1,
|
||||
MarginLeft: 1,
|
||||
MarginRight: 1,
|
||||
}
|
||||
html.WithLocalURL(fmt.Sprintf("%s/%s", dirPath, "index.html"))
|
||||
err := html.WithHeaderFile(fmt.Sprintf("%s/%s", dirPath, "header.html"))
|
||||
require.Nil(t, err)
|
||||
err = html.WithFooterFile(fmt.Sprintf("%s/%s", dirPath, "footer.html"))
|
||||
require.Nil(t, err)
|
||||
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
|
||||
err = html.Print(dst)
|
||||
require.Nil(t, err)
|
||||
require.FileExists(t, dst)
|
||||
err = os.RemoveAll(dirPath)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
111
internal/pkg/printer/markdown.go
Normal file
111
internal/pkg/printer/markdown.go
Normal file
@@ -0,0 +1,111 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"fmt"
|
||||
"html/template"
|
||||
"io/ioutil"
|
||||
"path/filepath"
|
||||
|
||||
"github.com/microcosm-cc/bluemonday"
|
||||
"github.com/russross/blackfriday/v2"
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
|
||||
)
|
||||
|
||||
// Markdown facilitates Markdown to PDF conversion.
|
||||
type Markdown struct {
|
||||
Context context.Context
|
||||
TemplatePath string
|
||||
HeaderHTML string
|
||||
FooterHTML string
|
||||
PaperWidth float64
|
||||
PaperHeight float64
|
||||
MarginTop float64
|
||||
MarginBottom float64
|
||||
MarginLeft float64
|
||||
MarginRight float64
|
||||
Landscape bool
|
||||
|
||||
html *HTML
|
||||
}
|
||||
|
||||
type templateData struct {
|
||||
DirPath string
|
||||
}
|
||||
|
||||
// Print converts markdown to PDF.
|
||||
func (md *Markdown) Print(destination string) error {
|
||||
if md.html == nil {
|
||||
md.html = &HTML{Context: md.Context}
|
||||
}
|
||||
if md.HeaderHTML != "" {
|
||||
md.html.HeaderHTML = md.HeaderHTML
|
||||
}
|
||||
if md.FooterHTML != "" {
|
||||
md.html.FooterHTML = md.FooterHTML
|
||||
}
|
||||
md.html.PaperWidth = md.PaperWidth
|
||||
md.html.PaperHeight = md.PaperHeight
|
||||
md.html.MarginTop = md.MarginTop
|
||||
md.html.MarginBottom = md.MarginBottom
|
||||
md.html.MarginLeft = md.MarginLeft
|
||||
md.html.MarginRight = md.MarginRight
|
||||
md.html.Landscape = md.Landscape
|
||||
tmpl, err := template.
|
||||
New(filepath.Base(md.TemplatePath)).
|
||||
Funcs(template.FuncMap{"toHTML": toHTML}).
|
||||
ParseFiles(md.TemplatePath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("%s: parsing template: %v", md.TemplatePath, err)
|
||||
}
|
||||
dirPath := filepath.Dir(md.TemplatePath)
|
||||
data := &templateData{DirPath: dirPath}
|
||||
var buffer bytes.Buffer
|
||||
if err := tmpl.Execute(&buffer, data); err != nil {
|
||||
return fmt.Errorf("%s: executing template: %v", md.TemplatePath, err)
|
||||
}
|
||||
baseFilename, err := rand.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
dst := fmt.Sprintf("%s/%s.html", dirPath, baseFilename)
|
||||
if err := writeBytesToFile(dst, buffer.Bytes()); err != nil {
|
||||
return err
|
||||
}
|
||||
md.html.WithLocalURL(dst)
|
||||
return md.html.Print(destination)
|
||||
}
|
||||
|
||||
func toHTML(dirPath, filename string) (template.HTML, error) {
|
||||
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
|
||||
b, err := ioutil.ReadFile(fpath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("%s: reading file: %v", fpath, err)
|
||||
}
|
||||
unsafe := blackfriday.Run(b)
|
||||
contentHTML := bluemonday.UGCPolicy().SanitizeBytes(unsafe)
|
||||
return template.HTML(contentHTML), nil
|
||||
}
|
||||
|
||||
// WithHeaderFile sets header content from a file.
|
||||
func (md *Markdown) WithHeaderFile(fpath string) error {
|
||||
if md.html == nil {
|
||||
md.html = &HTML{Context: md.Context}
|
||||
}
|
||||
return md.html.WithHeaderFile(fpath)
|
||||
}
|
||||
|
||||
// WithFooterFile sets footer content from a file.
|
||||
func (md *Markdown) WithFooterFile(fpath string) error {
|
||||
if md.html == nil {
|
||||
md.html = &HTML{Context: md.Context}
|
||||
}
|
||||
return md.html.WithFooterFile(fpath)
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Printer(new(Markdown))
|
||||
)
|
||||
39
internal/pkg/printer/markdown_test.go
Normal file
39
internal/pkg/printer/markdown_test.go
Normal file
@@ -0,0 +1,39 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/thecodingmachine/gotenberg/test"
|
||||
)
|
||||
|
||||
func TestMarkdown(t *testing.T) {
|
||||
dirPath := test.MarkdownTestDirPath(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
markdown := &Markdown{
|
||||
Context: ctx,
|
||||
TemplatePath: fmt.Sprintf("%s/%s", dirPath, "index.html"),
|
||||
PaperWidth: 8.27,
|
||||
PaperHeight: 11.7,
|
||||
MarginTop: 1,
|
||||
MarginBottom: 1,
|
||||
MarginLeft: 1,
|
||||
MarginRight: 1,
|
||||
}
|
||||
err := markdown.WithHeaderFile(fmt.Sprintf("%s/%s", dirPath, "header.html"))
|
||||
require.Nil(t, err)
|
||||
err = markdown.WithFooterFile(fmt.Sprintf("%s/%s", dirPath, "footer.html"))
|
||||
require.Nil(t, err)
|
||||
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
|
||||
err = markdown.Print(dst)
|
||||
require.Nil(t, err)
|
||||
require.FileExists(t, dst)
|
||||
err = os.RemoveAll(dirPath)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
62
internal/pkg/printer/office.go
Normal file
62
internal/pkg/printer/office.go
Normal file
@@ -0,0 +1,62 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/internal/pkg/rand"
|
||||
)
|
||||
|
||||
var mu sync.Mutex
|
||||
|
||||
// Office facilitates Office documents to PDF conversion.
|
||||
type Office struct {
|
||||
Context context.Context
|
||||
FilePaths []string
|
||||
}
|
||||
|
||||
// Print converts Office documents to PDF.
|
||||
func (o *Office) Print(destination string) error {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
fpaths := make([]string, len(o.FilePaths))
|
||||
dirPath := filepath.Dir(destination)
|
||||
for i, fpath := range o.FilePaths {
|
||||
baseFilename, err := rand.Get()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
tmpDest := fmt.Sprintf("%s/%s.pdf", dirPath, baseFilename)
|
||||
cmd := exec.CommandContext(
|
||||
o.Context,
|
||||
"unoconv",
|
||||
"--format",
|
||||
"pdf",
|
||||
"--output",
|
||||
tmpDest,
|
||||
fpath,
|
||||
)
|
||||
_, err = cmd.Output()
|
||||
if o.Context.Err() == context.DeadlineExceeded {
|
||||
return errors.New("unoconv: command timed out")
|
||||
}
|
||||
if err != nil {
|
||||
return fmt.Errorf("unoconv: non-zero exit code: %v", err)
|
||||
}
|
||||
fpaths[i] = tmpDest
|
||||
}
|
||||
if len(fpaths) == 1 {
|
||||
return os.Rename(fpaths[0], destination)
|
||||
}
|
||||
return Merge(fpaths, destination)
|
||||
}
|
||||
|
||||
// Compile-time checks to ensure type implements desired interfaces.
|
||||
var (
|
||||
_ = Printer(new(Office))
|
||||
)
|
||||
31
internal/pkg/printer/office_test.go
Normal file
31
internal/pkg/printer/office_test.go
Normal file
@@ -0,0 +1,31 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/thecodingmachine/gotenberg/test"
|
||||
)
|
||||
|
||||
func TestOffice(t *testing.T) {
|
||||
dirPath := test.OfficeTestDirPath(t)
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
||||
defer cancel()
|
||||
office := &Office{
|
||||
Context: ctx,
|
||||
FilePaths: []string{
|
||||
fmt.Sprintf("%s/%s", dirPath, "document.docx"),
|
||||
},
|
||||
}
|
||||
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
|
||||
err := office.Print(dst)
|
||||
require.Nil(t, err)
|
||||
require.FileExists(t, dst)
|
||||
err = os.RemoveAll(dirPath)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
29
internal/pkg/printer/printer.go
Normal file
29
internal/pkg/printer/printer.go
Normal file
@@ -0,0 +1,29 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
|
||||
pdfcpuAPI "github.com/hhrutter/pdfcpu/pkg/api"
|
||||
pdfcpuConfig "github.com/hhrutter/pdfcpu/pkg/pdfcpu"
|
||||
)
|
||||
|
||||
// Printer is a type that can create a PDF file from a source.
|
||||
// The source is defined in the underlying implementation.
|
||||
type Printer interface {
|
||||
Print(destination string) error
|
||||
}
|
||||
|
||||
// Merge merges PDF files.
|
||||
func Merge(fpaths []string, destination string) error {
|
||||
cmd := pdfcpuAPI.MergeCommand(fpaths, destination, pdfcpuConfig.NewDefaultConfiguration())
|
||||
_, err := pdfcpuAPI.Merge(cmd)
|
||||
return err
|
||||
}
|
||||
|
||||
func writeBytesToFile(dst string, b []byte) error {
|
||||
if err := ioutil.WriteFile(dst, b, 0644); err != nil {
|
||||
return fmt.Errorf("%s: writing file: %v", dst, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
27
internal/pkg/printer/printer_test.go
Normal file
27
internal/pkg/printer/printer_test.go
Normal file
@@ -0,0 +1,27 @@
|
||||
package printer
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"github.com/thecodingmachine/gotenberg/test"
|
||||
)
|
||||
|
||||
func TestMerge(t *testing.T) {
|
||||
dirPath := test.PDFTestDirPath(t)
|
||||
dst := fmt.Sprintf("%s/%s", dirPath, "foo.pdf")
|
||||
err := Merge(
|
||||
[]string{
|
||||
fmt.Sprintf("%s/%s", dirPath, "gotenberg.pdf"),
|
||||
fmt.Sprintf("%s/%s", dirPath, "gotenberg.pdf"),
|
||||
},
|
||||
dst,
|
||||
)
|
||||
require.Nil(t, err)
|
||||
require.FileExists(t, dst)
|
||||
err = os.RemoveAll(dirPath)
|
||||
assert.Nil(t, err)
|
||||
}
|
||||
7
internal/pkg/rand/doc.go
Normal file
7
internal/pkg/rand/doc.go
Normal file
@@ -0,0 +1,7 @@
|
||||
/*
|
||||
Package rand helps generating a random string.
|
||||
|
||||
It should be used for creating directory and
|
||||
file names in order to avoid collision.
|
||||
*/
|
||||
package rand
|
||||
17
internal/pkg/rand/rand.go
Normal file
17
internal/pkg/rand/rand.go
Normal file
@@ -0,0 +1,17 @@
|
||||
package rand
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Get returns a random string.
|
||||
func Get() (string, error) {
|
||||
randBytes := make([]byte, 16)
|
||||
_, err := rand.Read(randBytes)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("creating random string: %v", err)
|
||||
}
|
||||
return hex.EncodeToString(randBytes), nil
|
||||
}
|
||||
16
internal/pkg/rand/rand_test.go
Normal file
16
internal/pkg/rand/rand_test.go
Normal file
@@ -0,0 +1,16 @@
|
||||
package rand
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestGet(t *testing.T) {
|
||||
rand1, err := Get()
|
||||
require.Nil(t, err)
|
||||
rand2, err := Get()
|
||||
require.Nil(t, err)
|
||||
assert.NotEqual(t, rand1, rand2)
|
||||
}
|
||||
90
main.go
90
main.go
@@ -1,90 +0,0 @@
|
||||
/*
|
||||
Package main handles the application startup and shutdown.
|
||||
|
||||
Gotenberg is a stateless API for converting Markdown files, HTML files and Office documents to PDF.
|
||||
|
||||
For more information, go to https://github.com/thecodingmachine/gotenberg.
|
||||
*/
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/signal"
|
||||
"time"
|
||||
|
||||
"github.com/thecodingmachine/gotenberg/app"
|
||||
"github.com/thecodingmachine/gotenberg/app/config"
|
||||
"github.com/thecodingmachine/gotenberg/app/converter/process"
|
||||
"github.com/thecodingmachine/gotenberg/app/logger"
|
||||
|
||||
"github.com/gorilla/mux"
|
||||
"github.com/sirupsen/logrus"
|
||||
)
|
||||
|
||||
// version will be set on build time.
|
||||
var version = "master"
|
||||
|
||||
// defaultConfigurationFilePath is our default configuration file to parse.
|
||||
const defaultConfigurationFilePath = "gotenberg.yml"
|
||||
|
||||
// main initializes the application, starts it, and handles
|
||||
// graceful shutdown.
|
||||
func main() {
|
||||
c, err := config.NewAppConfig(defaultConfigurationFilePath)
|
||||
if err != nil {
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.Fatal(err)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// defines our application logging.
|
||||
logger.SetLevel(c.Logs.Level)
|
||||
logger.SetFormatter(c.Logs.Formatter)
|
||||
|
||||
// defines our application router.
|
||||
r := mux.NewRouter()
|
||||
r.Handle("/", app.GetHandlersChain()).Methods(http.MethodPost)
|
||||
|
||||
// defines our server.
|
||||
s := &http.Server{
|
||||
Addr: fmt.Sprintf(":%s", c.Port),
|
||||
Handler: r,
|
||||
}
|
||||
|
||||
process.Load(c.CommandsConfig)
|
||||
logger.Infof("Starting Gotenberg version %s", version)
|
||||
logger.Infof("Listening on port %s", c.Port)
|
||||
|
||||
// runs our server in a goroutine so that it doesn't block.
|
||||
go func() {
|
||||
if err = s.ListenAndServe(); err != nil {
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.Panic(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)
|
||||
|
||||
// blocks until we receive our signal.
|
||||
<-quit
|
||||
|
||||
// creates a deadline to wait for.
|
||||
var wait time.Duration
|
||||
ctx, cancel := context.WithTimeout(context.Background(), wait)
|
||||
defer cancel()
|
||||
|
||||
// doesn't block if no connections, but will otherwise wait
|
||||
// until the timeout deadline.
|
||||
s.Shutdown(ctx)
|
||||
|
||||
logger.SetLevel(logrus.InfoLevel)
|
||||
logger.Info("Bye!")
|
||||
os.Exit(0)
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
payload:
|
||||
|
||||
- key: Version
|
||||
value: snapshot
|
||||
|
||||
- key: Latest
|
||||
value: 1.0.0
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user