Compare commits

...

63 Commits

Author SHA1 Message Date
Julien Neuhart
3b0eb06991 fix(agents): better context 2026-03-19 20:49:06 +01:00
Julien Neuhart
410c1dfd7f fix(pdfengines): single file for watermark/stamp, and add the feature to the downloadFrom one 2026-03-18 23:22:31 +01:00
Julien Neuhart
b98378311f feat(agents): add bruno section 2026-03-18 23:14:00 +01:00
Julien Neuhart
e6306e7c2f feat(bruno): add collection 2026-03-18 22:36:58 +01:00
Julien Neuhart
0663e5f92b feat(pdfengines): add rotate feature 2026-03-18 22:36:20 +01:00
Julien Neuhart
21e300fcec fix(pdfengines): better workflow when applying PDF/A or PDF/UA compliance 2026-03-18 15:24:31 +01:00
Julien Neuhart
7fb4c89832 chore(agents): better entrypoints 2026-03-18 13:57:56 +01:00
Julien Neuhart
1e26fdd35b fix(libreoffice): set EmbedStandardFonts to true when applying PDF/A or PDF/UA compliance 2026-03-18 13:45:01 +01:00
Julien Neuhart
19db80bc2e feat(pdfengines): add watermark and stamp feature 2026-03-18 04:46:12 +01:00
Julien Neuhart
4ac493250c fix(agents): update persona file when adding a new testing scenario [skip ci] 2026-03-17 19:27:30 +01:00
Julien Neuhart
ec9d960538 feat(agents): better personas 2026-03-17 19:24:11 +01:00
Julien Neuhart
5fa6603fa4 chore(deps): update Go dependencies 2026-03-17 18:32:46 +01:00
dependabot[bot]
d81517bf26 chore(deps): bump golang.org/x/net from 0.51.0 to 0.52.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.51.0 to 0.52.0.
- [Commits](https://github.com/golang/net/compare/v0.51.0...v0.52.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.52.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-16 10:26:35 +01:00
dependabot[bot]
9af0f2896c chore(deps): bump github.com/testcontainers/testcontainers-go
Bumps [github.com/testcontainers/testcontainers-go](https://github.com/testcontainers/testcontainers-go) from 0.40.0 to 0.41.0.
- [Release notes](https://github.com/testcontainers/testcontainers-go/releases)
- [Commits](https://github.com/testcontainers/testcontainers-go/compare/v0.40.0...v0.41.0)

---
updated-dependencies:
- dependency-name: github.com/testcontainers/testcontainers-go
  dependency-version: 0.41.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-16 10:26:26 +01:00
dependabot[bot]
f619ce09d6 chore(deps): bump golang.org/x/sync from 0.19.0 to 0.20.0
Bumps [golang.org/x/sync](https://github.com/golang/sync) from 0.19.0 to 0.20.0.
- [Commits](https://github.com/golang/sync/compare/v0.19.0...v0.20.0)

---
updated-dependencies:
- dependency-name: golang.org/x/sync
  dependency-version: 0.20.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 09:40:11 +01:00
dependabot[bot]
58c41dd01f chore(deps): bump golang.org/x/net from 0.50.0 to 0.51.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.50.0 to 0.51.0.
- [Commits](https://github.com/golang/net/compare/v0.50.0...v0.51.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.51.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 09:37:57 +01:00
dependabot[bot]
ec3e415bf0 chore(deps): bump github.com/shirou/gopsutil/v4 from 4.26.1 to 4.26.2
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.26.1 to 4.26.2.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.26.1...v4.26.2)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-13 09:37:50 +01:00
Romain Prévost
230cb8da2c fix(security): avoid shell interpolation 2026-03-13 09:37:33 +01:00
Julien Neuhart
59f96358c4 feat(pdfengines): new form field autoIndexBookmarks for the merge route 2026-03-13 09:36:47 +01:00
Julien Neuhart
874e78c6cd fix(pdfengines): correctly update the indexes if the bookmarks form field (map format) is given 2026-03-13 09:36:47 +01:00
Julien Neuhart
caea81501d test(integration): add bookmarks routes 2026-03-13 09:36:47 +01:00
Julien Neuhart
1578253fb1 feat(pdfengines): add read bookmarks pdfcpu implementation 2026-03-13 09:36:47 +01:00
Julien Neuhart
fec6437c5d feat(pdfengines): add bookmarks read route 2026-03-13 09:36:47 +01:00
Julien Neuhart
edff24913a feat(pdfengines): add bookmarks write pdfcpu implementation 2026-03-13 09:36:47 +01:00
Julien Neuhart
5120b49639 feat(pdfengines): add bookmarks write route and update the merge route with this feature 2026-03-13 09:36:47 +01:00
Julien Neuhart
0e13cc3ed8 docs(README): typo in sponsors [skip ci] 2026-02-24 21:36:53 +01:00
dependabot[bot]
cb49e8e4d1 chore(deps): bump github.com/labstack/echo/v4 from 4.15.0 to 4.15.1 (#1476)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.15.0 to 4.15.1.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.15.0...v4.15.1)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-version: 4.15.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-23 07:31:26 +01:00
Julien Neuhart
3113e034f6 fix(tests): update unconverter and add concurrency in debug output 2026-02-22 11:47:30 +01:00
Julien Neuhart
85291fdec3 fix(Dockerfile): bump go version to 1.26 2026-02-21 21:30:33 +01:00
Julien Neuhart
57e1b7efda chore(golangci-lint): bump golangci-lint to v2.10.1 2026-02-21 17:54:46 +01:00
Julien Neuhart
be78a71bb7 feat(Makefile): add go fix to fmt command 2026-02-20 21:22:34 +01:00
Julien Neuhart
2baa59cb3a refactor(gofix): modernize 2026-02-20 21:21:19 +01:00
Julien Neuhart
aea7c5952a chore(go): bump Go to 1.26.0 2026-02-20 21:19:26 +01:00
Julien Neuhart
cbde321d3b docs(README): still small improvements 2026-02-20 20:44:04 +01:00
Julien Neuhart
aa5de988cf docs(README): small improvements 2026-02-20 20:13:35 +01:00
dependabot[bot]
fd485a0d3e chore(deps): bump golang.org/x/net from 0.49.0 to 0.50.0 (#1471)
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.49.0 to 0.50.0.
- [Commits](https://github.com/golang/net/compare/v0.49.0...v0.50.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.50.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-19 11:06:42 +01:00
dependabot[bot]
aea06a98b7 chore(deps): bump golang.org/x/text from 0.33.0 to 0.34.0 (#1470)
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.33.0 to 0.34.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.33.0...v0.34.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.34.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-19 11:05:10 +01:00
Daniel Moran
82401bdfdd feat(chromium): add support for emulated media features in Chromium (#1474)
Closes https://github.com/gotenberg/gotenberg/issues/1460

It can be easier to print a "clean" PDF of some pages if you emulate
media features like `prefers-reduced-motion`. Add support for that
emulation.
2026-02-17 20:16:28 +01:00
Tom Brouws
12c25a2d21 feat(chromium): re-add concurrency support for Chromium (#1467)
* feat: add concurrency support to ProcessSupervisor

- Replace the single-slot mutex channel with a configurable semaphore to
allow multiple concurrent tasks.
- Add drain logic to ensure all active
tasks complete before process restarts.

* feat: add chromium-max-concurrency flag

- Add a --chromium-max-concurrency flag (1-6) to the Chromium module to
control how many conversions run in parallel.
- Update LibreOffice to pass maxConcurrency=1 as LibreOffice only supports
a single concurrent conversion.

* test: add integration tests for concurrent Chromium conversions

- Add concurrent request support to the integration test framework with
new step definitions for sending parallel requests and asserting on all
responses.
- Add a feature file for concurrent HTML to PDF conversions.
2026-02-13 10:00:58 +01:00
dependabot[bot]
241d5077c9 chore(deps): bump github.com/shirou/gopsutil/v4 from 4.25.12 to 4.26.1 (#1464)
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.25.12 to 4.26.1.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.25.12...v4.26.1)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.26.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-13 09:59:40 +01:00
David
37757315d0 feat(Dockerfile): manage system time zone (#1465)
* displays timezone in debug information

* fixes cs

* fixes debug_test.go
2026-02-13 09:59:23 +01:00
John W. Mendes
1195c37508 chore(go): bump Go to 1.25.7 2026-02-10 09:14:20 +01:00
Julien Neuhart
3d9b2deb59 fix(style): AGENTS.md 2026-02-08 19:22:36 +01:00
Julien Neuhart
32d948554f docs(ai): add AGENTS.md 2026-02-08 17:32:43 +01:00
Julien Neuhart
b7a6b7aba2 Revert "chore(.gitignore): add junie folder"
This reverts commit 05e15a1d06.
2026-02-08 17:31:58 +01:00
Julien Neuhart
05e15a1d06 chore(.gitignore): add junie folder 2026-02-08 12:52:14 +01:00
Julien Neuhart
f57ecb6ef2 fix(exiftool): remove derived tags to prevent side effects when writing metadata (fixes #1463) 2026-01-29 17:25:09 +01:00
Julien Neuhart
45318497d2 chore(deps): update Go dependencies 2026-01-27 11:20:29 +00:00
dependabot[bot]
b762143d75 chore(deps): bump golang.org/x/net from 0.48.0 to 0.49.0
Bumps [golang.org/x/net](https://github.com/golang/net) from 0.48.0 to 0.49.0.
- [Commits](https://github.com/golang/net/compare/v0.48.0...v0.49.0)

---
updated-dependencies:
- dependency-name: golang.org/x/net
  dependency-version: 0.49.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 09:23:09 +01:00
dependabot[bot]
ba1a730d3a chore(deps-dev): bump prettier from 3.7.4 to 3.8.1
Bumps [prettier](https://github.com/prettier/prettier) from 3.7.4 to 3.8.1.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.4...3.8.1)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.8.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-26 09:23:01 +01:00
Adam Romanek
463bb2f91c fix(webhook): detach context from request lifecycle to prevent cancellation in async mode 2026-01-26 09:11:48 +01:00
Adam Romanek
e14cab0c8b fix(api): propagate request context to stop processing on client disconnect 2026-01-26 09:11:48 +01:00
Daniel Moran
3220ca4140 feat(chromium): add waitForSelector option to Chromium conversions (#1446)
* Add `waitForSelector` option to Chromium conversions

Closes #960

As an alternative to waiting on an expression, this allows users to wait
for a specific node matching a selector to become visible in the HTML /
at the remote URL before converting to PDF.

* Fix style / prettify
2026-01-17 14:57:10 +01:00
David
92de0cf6fe feat(prometheus): add new flag --prometheus-metrics-path 2026-01-14 10:04:57 +01:00
dependabot[bot]
d111591c8b chore(deps): bump golang.org/x/term from 0.38.0 to 0.39.0
Bumps [golang.org/x/term](https://github.com/golang/term) from 0.38.0 to 0.39.0.
- [Commits](https://github.com/golang/term/compare/v0.38.0...v0.39.0)

---
updated-dependencies:
- dependency-name: golang.org/x/term
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 09:22:10 +01:00
dependabot[bot]
935686b9e3 chore(deps): bump golang.org/x/text from 0.32.0 to 0.33.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.32.0 to 0.33.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.32.0...v0.33.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-12 09:18:20 +01:00
John W. Mendes
b1ad21c7d3 chore(go): update Go version to 1.25.5 (#1440)
* Update Go to 1.25.5

* Update go version in go.mod
2026-01-05 15:42:47 +01:00
dependabot[bot]
4f800c8a76 chore(deps): bump github.com/shirou/gopsutil/v4 from 4.25.11 to 4.25.12 (#1438)
Bumps [github.com/shirou/gopsutil/v4](https://github.com/shirou/gopsutil) from 4.25.11 to 4.25.12.
- [Release notes](https://github.com/shirou/gopsutil/releases)
- [Commits](https://github.com/shirou/gopsutil/compare/v4.25.11...v4.25.12)

---
updated-dependencies:
- dependency-name: github.com/shirou/gopsutil/v4
  dependency-version: 4.25.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 13:19:58 +01:00
dependabot[bot]
5c498219ed chore(deps): bump github.com/labstack/echo/v4 from 4.14.0 to 4.15.0 (#1439)
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.14.0 to 4.15.0.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.14.0...v4.15.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-version: 4.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-05 13:19:43 +01:00
markfrost
0b211c39eb feat(chromium): add IgnoreResourceHttpStatusDomains option to filter out resources based on their hostnames (#1434)
* Introduced `IgnoreResourceHttpStatusDomains` option to filter out resources based on their hostnames

- Introduced `IgnoreResourceHttpStatusDomains` option to filter out resources based on their hostnames during HTTP status code checks.
- Updated relevant functions to handle domain normalization and matching.
- Enhanced the form data handling to include the new option.
- Added integration test scenario to verify the functionality of ignoring specified domains.

* Updated the `normalizeDomains` function to initialize the `normalized` slice with a predefined capacity based on the input `domains` slice length, improving memory allocation efficiency.
2025-12-25 15:36:45 +01:00
dependabot[bot]
77b9776d9c chore(deps): bump github.com/labstack/echo/v4 from 4.13.4 to 4.14.0
Bumps [github.com/labstack/echo/v4](https://github.com/labstack/echo) from 4.13.4 to 4.14.0.
- [Release notes](https://github.com/labstack/echo/releases)
- [Changelog](https://github.com/labstack/echo/blob/master/CHANGELOG.md)
- [Commits](https://github.com/labstack/echo/compare/v4.13.4...v4.14.0)

---
updated-dependencies:
- dependency-name: github.com/labstack/echo/v4
  dependency-version: 4.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-17 09:28:49 +01:00
dependabot[bot]
04e596b616 chore(deps): bump golang.org/x/text from 0.31.0 to 0.32.0
Bumps [golang.org/x/text](https://github.com/golang/text) from 0.31.0 to 0.32.0.
- [Release notes](https://github.com/golang/text/releases)
- [Commits](https://github.com/golang/text/compare/v0.31.0...v0.32.0)

---
updated-dependencies:
- dependency-name: golang.org/x/text
  dependency-version: 0.32.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-17 09:25:25 +01:00
dependabot[bot]
9fba76fe5c chore(deps-dev): bump prettier from 3.7.3 to 3.7.4
Bumps [prettier](https://github.com/prettier/prettier) from 3.7.3 to 3.7.4.
- [Release notes](https://github.com/prettier/prettier/releases)
- [Changelog](https://github.com/prettier/prettier/blob/main/CHANGELOG.md)
- [Commits](https://github.com/prettier/prettier/compare/3.7.3...3.7.4)

---
updated-dependencies:
- dependency-name: prettier
  dependency-version: 3.7.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-11 10:12:14 +01:00
106 changed files with 6199 additions and 1453 deletions

View File

@@ -0,0 +1,72 @@
meta {
name: HTML to PDF
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/chromium/convert/html
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-html/index.html)
~landscape: false
~printBackground: false
~scale: 1.0
~singlePage: false
~paperWidth: 8.5
~paperHeight: 11
~marginTop: 0.39
~marginBottom: 0.39
~marginLeft: 0.39
~marginRight: 0.39
~nativePageRanges: 1-2
~preferCssPageSize: false
~generateDocumentOutline: false
~generateTaggedPdf: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: print
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,73 @@
meta {
name: Markdown to PDF
type: http
seq: 3
}
post {
url: {{baseUrl}}/forms/chromium/convert/markdown
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-markdown/index.html)
files: @file(../../test/integration/testdata/page-1-markdown/page_1.md)
~landscape: false
~printBackground: false
~scale: 1.0
~singlePage: false
~paperWidth: 8.5
~paperHeight: 11
~marginTop: 0.39
~marginBottom: 0.39
~marginLeft: 0.39
~marginRight: 0.39
~nativePageRanges: 1-2
~preferCssPageSize: false
~generateDocumentOutline: false
~generateTaggedPdf: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: print
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,72 @@
meta {
name: URL to PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/chromium/convert/url
body: multipartForm
auth: none
}
body:multipart-form {
url: https://example.com
~landscape: false
~printBackground: false
~scale: 1.0
~singlePage: false
~paperWidth: 8.5
~paperHeight: 11
~marginTop: 0.39
~marginBottom: 0.39
~marginLeft: 0.39
~marginRight: 0.39
~nativePageRanges: 1-2
~preferCssPageSize: false
~generateDocumentOutline: false
~generateTaggedPdf: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: print
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,46 @@
meta {
name: HTML Screenshot
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/chromium/screenshot/html
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-html/index.html)
~width: 800
~height: 600
~clip: false
~format: png
~quality: 100
~optimizeForSpeed: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
}
headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,47 @@
meta {
name: Markdown Screenshot
type: http
seq: 3
}
post {
url: {{baseUrl}}/forms/chromium/screenshot/markdown
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-markdown/index.html)
files: @file(../../test/integration/testdata/page-1-markdown/page_1.md)
~width: 800
~height: 600
~clip: false
~format: png
~quality: 100
~optimizeForSpeed: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
}
headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,46 @@
meta {
name: URL Screenshot
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/chromium/screenshot/url
body: multipartForm
auth: none
}
body:multipart-form {
url: https://example.com
~width: 800
~height: 600
~clip: false
~format: png
~quality: 100
~optimizeForSpeed: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
}
headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,11 @@
meta {
name: Debug
type: http
seq: 3
}
get {
url: {{baseUrl}}/debug
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: Health
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: Prometheus Metrics
type: http
seq: 4
}
get {
url: {{baseUrl}}/prometheus/metrics
body: none
auth: none
}

View File

@@ -0,0 +1,11 @@
meta {
name: Version
type: http
seq: 2
}
get {
url: {{baseUrl}}/version
body: none
auth: none
}

View File

@@ -0,0 +1,74 @@
meta {
name: Convert to PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/libreoffice/convert
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.docx)
~password:
~landscape: false
~nativePageRanges:
~updateIndexes: false
~exportFormFields: true
~allowDuplicateFieldNames: false
~exportBookmarks: true
~exportBookmarksToPdfDestination: false
~exportPlaceholders: false
~exportNotes: false
~exportNotesPages: false
~exportOnlyNotesPages: false
~exportNotesInMargin: false
~convertOooTargetToPdfTarget: false
~exportLinksRelativeFsys: false
~exportHiddenSlides: false
~skipEmptyPages: false
~addOriginalDocumentAsStream: false
~singlePageSheets: false
~losslessImageCompression: false
~quality: 90
~reduceImageResolution: false
~maxImageResolution: 300
~nativeWatermarkText:
~nativeWatermarkColor: 0
~nativeWatermarkFontHeight: 0
~nativeWatermarkRotateAngle: 0
~nativeWatermarkFontName:
~nativeTiledWatermarkText:
~nativePdfFormats: true
~merge: false
~flatten: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,23 @@
meta {
name: Read Bookmarks
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/bookmarks/read
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
}
headers {
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,25 @@
meta {
name: Write Bookmarks
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/pdfengines/bookmarks/write
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
bookmarks: [{"title":"Chapter 1","page":1,"children":[{"title":"Section 1.1","page":1}]}]
}
headers {
~Gotenberg-Output-Filename: with-bookmarks
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,26 @@
meta {
name: Convert PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/convert
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
pdfa: PDF/A-1b
~pdfua: true
}
headers {
~Gotenberg-Output-Filename: converted
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,26 @@
meta {
name: Embed Files
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/embed
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
embeds: @file(../../test/integration/testdata/page_1.pdf)
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
}
headers {
~Gotenberg-Output-Filename: with-embeds
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,26 @@
meta {
name: Encrypt PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/encrypt
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
userPassword: secret123
~ownerPassword: owner456
}
headers {
~Gotenberg-Output-Filename: encrypted
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,24 @@
meta {
name: Flatten PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/flatten
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
}
headers {
~Gotenberg-Output-Filename: flattened
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,43 @@
meta {
name: Merge PDFs
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/merge
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
files: @file(../../test/integration/testdata/page_2.pdf)
~flatten: false
~autoIndexBookmarks: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: merged
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,23 @@
meta {
name: Read Metadata
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/metadata/read
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
}
headers {
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,25 @@
meta {
name: Write Metadata
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/pdfengines/metadata/write
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
metadata: {"Author":"Bruno","Copyright":"Bruno","Creator":"Gotenberg","Keywords":["test"],"Marked":true,"Producer":"Gotenberg","Subject":"Test","Title":"Test Document","Trapped":"Unknown"}
}
headers {
~Gotenberg-Output-Filename: with-metadata
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,26 @@
meta {
name: Rotate PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/rotate
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
rotateAngle: 90
~rotatePages: 1-2
}
headers {
~Gotenberg-Output-Filename: rotated
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,43 @@
meta {
name: Split PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/split
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/pages_3.pdf)
splitMode: intervals
splitSpan: 1
~splitUnify: false
~flatten: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: split
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,30 @@
meta {
name: Stamp PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/stamp
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
stampSource: text
stampExpression: APPROVED
~stampPages: 1-2
~stampOptions: {"font":"Helvetica","fontSize":"48","color":"#00FF00","opacity":"0.5","rotation":"0"}
~stamp: @file(../../test/integration/testdata/watermark.png)
~downloadFrom: [{"url":"https://example.com/stamp.png","field":"stamp"}]
}
headers {
~Gotenberg-Output-Filename: stamped
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

View File

@@ -0,0 +1,30 @@
meta {
name: Watermark PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/watermark
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
watermarkSource: text
watermarkExpression: CONFIDENTIAL
~watermarkPages: 1-2
~watermarkOptions: {"font":"Helvetica","fontSize":"48","color":"#FF0000","opacity":"0.3","rotation":"45"}
~watermark: @file(../../test/integration/testdata/watermark.png)
~downloadFrom: [{"url":"https://example.com/watermark.png","field":"watermark"}]
}
headers {
~Gotenberg-Output-Filename: watermarked
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}

6
.bruno/bruno.json Normal file
View File

@@ -0,0 +1,6 @@
{
"version": "1",
"name": "Gotenberg",
"type": "collection",
"ignore": ["node_modules", ".git"]
}

3
.bruno/collection.bru Normal file
View File

@@ -0,0 +1,3 @@
headers {
Gotenberg-Trace: {{traceId}}
}

View File

@@ -0,0 +1,4 @@
vars {
baseUrl: https://demo.gotenberg.dev
traceId: bruno-demo
}

View File

@@ -0,0 +1,4 @@
vars {
baseUrl: http://localhost:3000
traceId: bruno-local
}

View File

@@ -60,30 +60,43 @@ runs:
- name: Build ${{ inputs.platform }}
id: build
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
.github/actions/build-test-push/build.sh \
--version "${{ inputs.version }}" \
--platform "${{ inputs.platform }}" \
--alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "${{ inputs.dry_run }}"
--version "$INPUT_VERSION" \
--platform "$INPUT_PLATFORM" \
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
--dry-run "$INPUT_DRY_RUN"
- name: Run integration tests
if: inputs.skip_integrations_tests != 'true'
shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
.github/actions/build-test-push/test.sh \
--version "${{ inputs.version }}" \
--platform "${{ inputs.platform }}" \
--alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "${{ inputs.dry_run }}"
--version "$INPUT_VERSION" \
--platform "$INPUT_PLATFORM" \
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \
--dry-run "$INPUT_DRY_RUN"
- name: Push
if: inputs.docker_hub_username != ''
shell: bash
env:
INPUT_TAGS: ${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
.github/actions/build-test-push/push.sh \
--tags "${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}" \
--dry-run "${{ inputs.dry_run }}"
--tags "$INPUT_TAGS" \
--dry-run "$INPUT_DRY_RUN"
- name: Outputs
shell: bash

View File

@@ -23,9 +23,12 @@ runs:
env:
DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }}
DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }}
INPUT_TAGS: ${{ inputs.tags }}
INPUT_SNAPSHOT_VERSION: ${{ inputs.snapshot_version }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
shell: bash
run: |
.github/actions/clean/clean.sh \
--tags "${{ inputs.tags }}" \
--snapshot-version "${{ inputs.snapshot_version }}" \
--dry-run "${{ inputs.dry_run }}"
--tags "$INPUT_TAGS" \
--snapshot-version "$INPUT_SNAPSHOT_VERSION" \
--dry-run "$INPUT_DRY_RUN"

View File

@@ -41,8 +41,12 @@ runs:
- name: Merge
shell: bash
env:
INPUT_TAGS: ${{ inputs.tags }}
INPUT_ALTERNATE_REGISTRY: ${{ inputs.alternate_registry }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: |
.github/actions/merge/merge.sh \
--tags "${{ inputs.tags }}" \
--alternate-registry "${{ inputs.alternate_registry }}" \
--dry-run "${{ inputs.dry_run }}"
--tags "$INPUT_TAGS" \
--alternate-registry "$INPUT_ALTERNATE_REGISTRY" \
--dry-run "$INPUT_DRY_RUN"

View File

@@ -31,7 +31,7 @@ jobs:
- name: Run linters
uses: golangci/golangci-lint-action@v9
with:
version: v2.5.0
version: v2.10.1
lint-prettier:
name: Lint non-Golang codebase

336
AGENTS.md Normal file
View File

@@ -0,0 +1,336 @@
# Operational Guidelines for Gotenberg
You are working on **Gotenberg**, a Docker-based API for converting documents to PDF. It is a widely used production dependency. Stability and backward compatibility are paramount. When in doubt about whether a change is breaking, flag it rather than assuming it's safe.
## Mandatory Workflow
Every task MUST follow these five steps in order. Do not skip any step.
### Step 1 — Plan
Before writing any code, produce a plan that covers:
- **Problem statement**: What needs to change and why.
- **Proposed solution**: The recommended approach with enough detail to implement (files to modify, interface changes, pipeline positioning, form fields, etc.).
- **Alternatives considered**: At least one alternative approach when pertinent, with a brief explanation of why the proposed solution is preferred.
- **Scope**: List every file that will be created or modified.
- **Testing strategy**: Which integration test tags will be affected, what new scenarios are needed, and whether unit tests are required.
Present the plan to the user and wait for approval before proceeding to Step 2. If the user provides a plan, validate it against the codebase and flag any issues before implementing.
### Step 2 — Implement
Implement the approved plan following the coding standards and patterns described in this document. After implementation, verify the build compiles (`go build ./...`).
### Step 3 — Test
Write or update tests based on the plan's testing strategy:
- **Integration tests** (primary): Gherkin scenarios in `test/integration/features/`. See the [Integration Tests](#integration-tests) section.
- **Unit tests** (when applicable): Table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`.
### Step 4 — Review
Self-review the implementation against the [Review Checklist](#review-checklist). Fix any issues found before presenting the result to the user.
### Step 5 — Commit
Present the review to the user and **wait for explicit approval**. Do NOT commit until the user confirms. Once approved, create a commit following the [Conventional Commits](https://www.conventionalcommits.org/) specification:
```
<type>(<scope>): <description>
```
Stage only the files related to the change. Do not use `git add -A` or `git add .`.
---
## Core Principles
- **Backward compatibility is law.** Never modify existing CLI flags, environment variables, or API form fields unless explicitly instructed to perform a breaking change. Flag any breaking change immediately.
- **Defensive programming.** Assume input is malformed. Handle errors explicitly. Never panic.
- **Atomic commits.** One feature or fix per PR. Isolate refactoring from feature work.
- **Idiomatic Go.** Follow "Effective Go" principles. All exported symbols must have GoDoc comments starting with their name.
## Project Layout
```
cmd/gotenberg/ → Entry point only (wiring/startup). No business logic.
pkg/gotenberg/ → Core module system, interfaces, utilities, mocks.
pkg/modules/ → Feature modules (api, chromium, libreoffice, pdfengines, etc.).
pkg/standard/ → Wires all standard modules together via imports.
test/integration/ → Gherkin feature files + Go test infrastructure.
build/ → Dockerfile, fonts, Chromium config.
.bruno/ → Bruno API collection (mirrors every route).
```
Key interfaces live in `pkg/gotenberg/``Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers. When adding features, determine if they belong in an existing module or require a new one.
## Codebase Navigation
- Start with `pkg/gotenberg/` for core interfaces and `pkg/modules/` for feature implementations.
- The integration test infrastructure in `test/integration/scenario/` is well-structured — read `scenario.go` and `containers.go` to understand the Gherkin step definitions before writing new tests.
- Mocks for all major interfaces are in `pkg/gotenberg/mocks.go` — use them for unit tests rather than creating new ones.
- Import ordering is enforced: standard library, third-party, then `github.com/gotenberg/gotenberg/v8` — separated by blank lines.
- When making changes, run only the relevant integration test tag rather than the full suite (40min timeout).
---
## Makefile — the Only Build Interface
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
| Command | Purpose | When to use |
| ----------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `make build` | Build the Docker image | Before integration tests, or to verify compilation |
| `make run` | Run a Gotenberg container locally | Manual testing. Flags are configured via `.env` and Makefile variables |
| `make fmt` | Format Go code (`go fix`, `golangci-lint fmt`, `go mod tidy`) | Before every commit |
| `make lint` | Lint Go code (strict `.golangci.yml` config) | Before every commit. Zero errors permitted |
| `make lint-prettier` | Lint non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
| `make prettify` | Format non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
| `make test-unit` | Run unit tests (`go test -race ./...`) | After code changes to `pkg/` |
| `make test-integration` | Run integration tests (Gherkin/Godog, 40min timeout) | After any feature or route change |
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
## Module System
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module:
- Lives in `pkg/modules/<name>/`
- Implements the `gotenberg.Module` interface (at minimum `Descriptor()`)
- May also implement `gotenberg.Provisioner`, `gotenberg.Validator`, or `gotenberg.Debuggable`
- Self-registers via `init()` and is wired through `pkg/standard/`
When adding a feature, first determine if it belongs in an existing module. Only create a new module if the feature represents a genuinely separate concern.
## Commit Convention
Commits must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
```
<type>(<scope>): <description>
```
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope should match the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
## Coding Patterns
- **Error handling:** Always wrap errors with context using `fmt.Errorf("description: %w", err)`. Never swallow errors silently.
- **Import ordering:** Enforced by `gci` — standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
- **Mocks:** Comprehensive mock implementations for all major interfaces live in `pkg/gotenberg/mocks.go`. Use these for unit tests.
- **No business logic in `cmd/`:** The `cmd/gotenberg/` package is strictly for wiring and startup.
## Adding PDF Engine Features
When adding a new PDF engine capability (e.g., bookmarks, watermark, stamp, embed), you must update the Makefile to include the corresponding engine list variable and flag. Every `--pdfengines-*-engines` flag registered in `pkg/modules/pdfengines/pdfengines.go` must have a matching entry in the Makefile:
1. **Add a variable** in the Makefile's variable block (around line 60-70):
```makefile
PDFENGINES_<FEATURE>_ENGINES=<default engines>
```
2. **Add the flag** in the Makefile's command args block (around line 140-155):
```makefile
--pdfengines-<feature>-engines=$(PDFENGINES_<FEATURE>_ENGINES) \
```
The default value should match what is defined in `pdfengines.go`'s `fs.StringSlice(...)` call for that flag.
---
## Integration Tests
- **Framework:** Gherkin (BDD) via [Godog](https://github.com/cucumber/godog), with `testcontainers-go` for Docker orchestration.
- **Feature files:** `test/integration/features/*.feature` — one file per endpoint or capability.
- **Test infrastructure:** `test/integration/scenario/` — Go step definitions, container management, HTTP helpers, PDF validation.
- **Entry point:** `test/integration/main_test.go` (build tag: `integration`).
- **Test data:** `test/integration/testdata/`
### How It Works
Each scenario spins up a fresh Gotenberg Docker container via testcontainers. The step definitions in `scenario/scenario.go` map Gherkin steps to Go functions. An additional `gotenberg/integration-tools` container provides PDF validation tools (`verapdf`, `pdfinfo`, `pdftotext`).
**Important:** Integration tests require a Docker image. Run `make build` before `make test-integration`.
### Selective Test Runs
Use the `TAGS` variable to run only relevant scenarios:
```bash
make test-integration TAGS=health
make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
Available tags: `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `debug`, `health`, `libreoffice`, `libreoffice-convert`, `output-filename`, `pdfengines`, `pdfengines-convert`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-flatten`, `flatten`, `pdfengines-merge`, `merge`, `pdfengines-metadata`, `metadata`, `pdfengines-split`, `split`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-bookmarks`, `bookmarks`, `pdfengines-rotate`, `rotate`, `prometheus-metrics`, `root`, `version`, `webhook`, `download-from`.
Other useful flags:
```bash
make test-integration NO_CONCURRENCY=true # Disable parallel scenarios
make test-integration PLATFORM=linux/arm64 # Force a specific platform
```
### Writing a New Integration Test
1. Create or update a `.feature` file in `test/integration/features/`.
2. Tag it appropriately (e.g., `@chromium @chromium-convert-html`).
3. If the feature requires new tag(s), add them to both the `TAGS` comment block in the `Makefile` and the "Available tags" list in this file.
4. If you create a new step definition, add it to `scenario/scenario.go`, register it in `InitializeScenario`, and update the "Available Gherkin Steps" list below.
5. Test data goes in `test/integration/testdata/`.
### Available Gherkin Steps
**Given (setup):**
- `I have a default Gotenberg container`
- `I have a Gotenberg container with the following environment variable(s):` (table: key | value)
- `I have a (webhook|static) server`
**When (action):**
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint`
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint with the following header(s):` (table: name | value)
- `I make a "(POST)" request to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (table: name | value | kind — where kind is `file`, `field`, or `header`)
- `I make <N> concurrent "(POST)" requests to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (same table format)
- `I wait for the asynchronous request to the webhook`
**Then (assertions):**
- `the response status code should be <code>`
- `the (response|webhook request) header "<name>" should be "<value>"`
- `the (response|webhook request) cookie "<name>" should be "<value>"`
- `the (response|webhook request) body should match string:` (docstring)
- `the (response|webhook request) body should contain string:` (docstring)
- `the (response|webhook request) body should match JSON:` (docstring — use `"ignore"` for dynamic values like timestamps)
- `there should be <N> PDF(s) in the (response|webhook request)`
- `there should be the following file(s) in the (response|webhook request):` (table of filenames)
- `the "<name>" PDF should have <N> page(s)`
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
- `the (response|webhook request) PDF(s) (should|should NOT) be flatten`
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
- `all concurrent response status codes should be <code>`
- `all concurrent responses should have <N> PDF(s)`
---
## Review Checklist
### Backward Compatibility
- [ ] No existing CLI flags renamed or removed
- [ ] No existing environment variables renamed or removed
- [ ] No existing API form fields renamed or removed
- [ ] No existing HTTP endpoints changed or removed
- [ ] No changes to default values that alter existing behavior
If any of these are violated, the change **must** be flagged as a breaking change.
### Linting Standards
The `.golangci.yml` enforces strict rules including: `gosec`, `govet`, `errcheck`, `staticcheck`, `dupl`, `bodyclose`, `exhaustive`, `errname`, and more. Zero linting errors are permitted.
Formatters enforce `gci`, `gofmt`, `gofumpt`, `goimports` with import ordering:
1. Standard library
2. Third-party packages
3. `github.com/gotenberg/gotenberg/v8`
Three groups separated by blank lines.
### Code Quality
- Errors are wrapped with context: `fmt.Errorf("description: %w", err)`. No swallowed errors.
- No business logic in `cmd/`.
- No panics in production code paths.
- Input is validated defensively.
- New features belong in the correct module (or justify a new one).
### Documentation
- Every exported function, type, constant, and variable has a GoDoc comment starting with its name.
- New packages include a `doc.go` file.
- `README.md` is not modified unless explicitly requested.
### Definition of Done
A change is ready to merge only when:
1. Code compiles: `go build ./...`
2. Code is formatted: `make fmt`
3. All linters pass: `make lint` and `make lint-prettier`
4. Integration tests pass: `make test-integration` (at minimum, the relevant `TAGS`)
5. Unit tests pass: `make test-unit`
6. All exported symbols and new packages have compliant GoDoc
7. Bruno collection is updated (if routes were added or modified)
---
## Bruno API Collection
A [Bruno](https://www.usebruno.com/) collection lives in `.bruno/` and mirrors every Gotenberg route. When adding or updating a route, update the collection to match.
### Structure
```
.bruno/
├── bruno.json # Collection config
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
├── environments/
│ ├── Local.bru # baseUrl: http://localhost:3000
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
├── Health & Info/ # GET routes
├── Chromium/Convert/ # POST routes grouped by module
├── Chromium/Screenshot/
├── LibreOffice/
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, …)
```
### `.bru` file format
```bru
meta {
name: <Human-readable name>
type: http
seq: <order within folder>
}
post {
url: {{baseUrl}}/forms/<path>
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/<file>)
<mandatoryField>: <value>
~<optionalField>: <value>
}
headers {
~Gotenberg-Output-Filename: <name>
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}
```
### Conventions
- **Mandatory fields** are listed without prefix; **optional fields** are prefixed with `~` (disabled by default in Bruno).
- **File references** use relative paths to `test/integration/testdata/`.
- **Webhook and output filename headers** are included on every POST route as optional (`~`).
- **One `.bru` file per request**. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
### Checklist when adding/updating a route
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
2. Include all form fields from the route handler — check `FormData*` calls in the route function.
3. For file upload fields (`files`, `watermark`, `stamp`, `embeds`), use `@file(...)` with a suitable test file.
4. Verify the URL path matches the route's `Path` field exactly.
5. If you add a new module folder, keep the naming consistent (e.g., `PDF Engines/Rotate/`).

3
CLAUDE.md Normal file
View File

@@ -0,0 +1,3 @@
# Claude Code — Gotenberg
Read [AGENTS.md](AGENTS.md) first. It contains everything: core principles, project layout, coding standards, the mandatory 4-step workflow (Plan → Implement → Test → Review), integration test reference, review checklist, and Bruno collection guidelines.

63
CONTRIBUTING.md Normal file
View File

@@ -0,0 +1,63 @@
# Contributing to Gotenberg
Thank you for your interest in contributing to Gotenberg! This guide will help you get started.
## Before You Start
Please read the [AGENTS.md](AGENTS.md) file — it describes the core principles, project layout, development standards, integration test reference, review checklist, and Bruno collection guidelines that all contributions must follow.
## Getting Started
### Prerequisites
- Go (see version in `go.mod`)
- Docker
- Node.js (see version in `.node-version`) — for Prettier linting
- [golangci-lint](https://golangci-lint.run/) v2+
### Build and Run
```bash
make build # Build the Docker image
make run # Run a local Gotenberg container
```
### Development Loop
```bash
# Write your code, then:
make fmt # Format Go code
make prettify # Format non-Go files (Markdown, YAML, etc.)
make lint # Lint Go code (zero errors permitted)
make lint-prettier # Lint non-Go files
make test-unit # Run unit tests
make build # Build the Docker image (required before integration tests)
make test-integration # Run all integration tests
```
To run only the integration tests relevant to your change:
```bash
make test-integration TAGS=health
make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
## Submitting a Pull Request
Before opening a PR, verify:
1. Code compiles: `make build`
2. Code is formatted: `make fmt` and `make prettify`
3. All linters pass: `make lint` and `make lint-prettier`
4. Integration tests pass: `make test-integration` (at minimum, the relevant tags)
5. Unit tests pass: `make test-unit`
6. All exported symbols and new packages have GoDoc comments
### Guidelines
- **Conventional Commits.** Commit messages must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification (e.g., `feat(chromium): add screenshot endpoint`, `fix(api): handle empty body`).
- **One thing per PR.** Keep features, bug fixes, and refactoring in separate PRs.
- **Backward compatibility matters.** Do not rename or remove existing CLI flags, environment variables, or API form fields without discussion.
- **Integration tests first.** When adding a feature or route, start by writing the Gherkin scenario in `test/integration/features/`.
- **No business logic in `cmd/`.** All logic belongs in `pkg/`.

3
GEMINI.md Normal file
View File

@@ -0,0 +1,3 @@
# Gemini — Gotenberg
Read [AGENTS.md](AGENTS.md) first. It contains everything: core principles, project layout, coding standards, the mandatory 4-step workflow (Plan → Implement → Test → Review), integration test reference, review checklist, and Bruno collection guidelines.

View File

@@ -10,6 +10,7 @@ build: ## Build the Gotenberg's Docker image
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
TZ=UTC
GOTENBERG_HIDE_BANNER=false
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
GOTENBERG_BUILD_DEBUG_DATA=true
@@ -30,8 +31,9 @@ API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false
API_ENABLE_DEBUG_ROUTE=false
CHROMIUM_RESTART_AFTER=10
CHROMIUM_RESTART_AFTER=100
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_MAX_CONCURRENCY=6
CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s
CHROMIUM_ALLOW_INSECURE_LOCALHOST=false
@@ -55,19 +57,25 @@ LOG_LEVEL=info
LOG_FORMAT=auto
LOG_FIELDS_PREFIX=
LOG_ENABLE_GCP_FIELDS=false
PDFENGINES_DISABLE_ROUTES=false
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
PDFENGINES_FLATTEN_ENGINES=qpdf
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool
PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu
PDFENGINES_WRITE_BOOKMARKS_ENGINES=pdfcpu
PDFENGINES_WATERMARK_ENGINES=pdfcpu,pdftk
PDFENGINES_STAMP_ENGINES=pdfcpu,pdftk
PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_DISABLE_ROUTES=false
PDFENGINES_ROTATE_ENGINES=pdfcpu,pdftk
PDFENGINES_EMBED_ENGINES=pdfcpu
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
PROMETHEUS_DISABLE_COLLECT=false
PROMETHEUS_METRICS_PATH=/prometheus/metrics
WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST=
@@ -85,6 +93,7 @@ run: ## Start a Gotenberg container
-p $(API_PORT):$(API_PORT) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
-e TZ=$(TZ) \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
gotenberg \
--gotenberg-hide-banner=$(GOTENBERG_HIDE_BANNER) \
@@ -108,6 +117,7 @@ run: ## Start a Gotenberg container
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
--chromium-max-concurrency=$(CHROMIUM_MAX_CONCURRENCY) \
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
@@ -130,19 +140,25 @@ run: ## Start a Gotenberg container
--log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--log-enable-gcp-fields=$(LOG_ENABLE_GCP_FIELDS) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
--pdfengines-flatten-engines=$(PDFENGINES_FLATTEN_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
--pdfengines-read-bookmarks-engines=$(PDFENGINES_READ_BOOKMARKS_ENGINES) \
--pdfengines-write-bookmarks-engines=$(PDFENGINES_WRITE_BOOKMARKS_ENGINES) \
--pdfengines-watermark-engines=$(PDFENGINES_WATERMARK_ENGINES) \
--pdfengines-stamp-engines=$(PDFENGINES_STAMP_ENGINES) \
--pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--pdfengines-rotate-engines=$(PDFENGINES_ROTATE_ENGINES) \
--pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--prometheus-metrics-path=$(PROMETHEUS_METRICS_PATH) \
--webhook-enable-sync-mode="$(WEBHOOK_ENABLE_SYNC_MODE)" \
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
@@ -162,6 +178,7 @@ PLATFORM=
NO_CONCURRENCY=false
# Available tags:
# chromium
# chromium-concurrent
# chromium-convert-html
# chromium-convert-markdown
# chromium-convert-url
@@ -184,6 +201,14 @@ NO_CONCURRENCY=false
# metadata
# pdfengines-split
# split
# pdfengines-watermark
# watermark
# pdfengines-stamp
# stamp
# pdfengines-rotate
# rotate
# pdfengines-bookmarks
# bookmarks
# prometheus-metrics
# root
# version
@@ -214,6 +239,7 @@ lint-todo: ## Find TODOs in Golang codebase
.PHONY: fmt
fmt: ## Format Golang codebase and "optimize" the dependencies
go fix ./...
golangci-lint fmt
go mod tidy

View File

@@ -4,60 +4,46 @@
<p align="center">A containerized API for seamless PDF conversion</p>
<p align="center">
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
<a href="https://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml/badge.svg"></a>
<a href="https://pkg.go.dev/github.com/gotenberg/gotenberg/v8"><img alt="Go Reference" src="https://pkg.go.dev/badge/github.com/gotenberg/gotenberg.svg"></a>
</p>
<p align="center">
<a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Live Demo</a> 🔥</p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Read the Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Try the Live Demo</a> 🔥</p>
</p>
---
**Gotenberg** provides a developer-friendly API to interact with powerful tools like Chromium and LibreOffice for converting
numerous document formats (HTML, Markdown, Word, Excel, etc.) into PDF files, and more!
**Gotenberg** is a containerized API that abstracts the complexity of PDF conversion.
It provides a `multipart/form-data` interface for interacting with powerful engines like Chromium and LibreOffice.
Instead of managing heavy dependencies, browser versions, or fonts in your own backend, simply send your files to
Gotenberg and get a PDF in return.
## Quick Start
Open a terminal and run the following command:
```
```bash
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
```
Alternatively, using the historic Docker repository from our sponsor [TheCodingMachine](https://www.thecodingmachine.com):
```
docker run --rm -p 3000:3000 thecodingmachine/gotenberg:8
```
The API is now available on your host at http://localhost:3000.
Head to the [documentation](https://gotenberg.dev/docs/getting-started/introduction) to learn how to interact with it 🚀
With the API running at `http://localhost:3000`, you are now ready to head
to the **[Full Documentation](https://gotenberg.dev/docs/getting-started/introduction)** to discover how to convert URLs,
local files, inject custom CSS, merge PDFs, and more.
## Sponsors
<p align="center">
<a href="https://thecodingmachine.com">
<img src="https://user-images.githubusercontent.com/8983173/130324668-9d6e7b35-53a3-49c7-a574-38190d2bd6b0.png" alt="TheCodingMachine Logo" width="333" height="163" />
</a>
<a href="https://pdfme.com?utm_source=gotenberg_github&utm_medium=website" target="_blank">
<img src="https://github.com/user-attachments/assets/2a75dd40-ca18-4d34-acd5-5dd474595168" alt="pdfme Logo" width="333" height="163" />
</a>
</p>
Open-source development takes a significant amount of time, energy, and dedication. If Gotenberg helps streamline your
workflow or powers your business, please consider supporting its continuous improvement by [**becoming a sponsor**](https://github.com/sponsors/gulien)! ❤️
Sponsorships help maintain and improve Gotenberg - [become a sponsor](https://github.com/sponsors/gulien) ❤️
**Historic & GitHub Sponsors**
---
- [TheCodingMachine](https://thecodingmachine.com/)
- [pdfme](https://pdfme.com/)
<p align="center">
<strong>Powered by</strong>
</p>
**Powered By**
<p align="center">
<a href="https://jb.gg/OpenSource">
<img src="https://resources.jetbrains.com/storage/products/company/brand/logos/jetbrains.svg" alt="JetBrains logo" width="200"/>
</a>
</p>
- [Docker](https://docs.docker.com/docker-hub/repos/manage/trusted-content/dsos-program/)
- [JetBrains](https://www.jetbrains.com/community/opensource/)

View File

@@ -1,7 +1,7 @@
# ARG instructions do not create additional layers. Instead, next layers will
# concatenate them. Also, we have to repeat ARG instructions in each build
# stage that uses them.
ARG GOLANG_VERSION=1.25.4
ARG GOLANG_VERSION=1.26.0
# ----------------------------------------------
# pdfcpu binary build stage
@@ -79,6 +79,9 @@ RUN jlink \
# ----------------------------------------------
FROM debian:13-slim AS base-image-stage
ARG TIMEZONE=UTC
ENV TZ=$TIMEZONE
COPY --from=custom-jre-stage /custom-jre /opt/java
ENV PATH="/opt/java/bin:${PATH}"
@@ -212,7 +215,7 @@ RUN \
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports libreoffice &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.1.1/unoconv -o /usr/bin/unoconverter &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o /usr/bin/unoconverter &&\
chmod +x /usr/bin/unoconverter &&\
# unoconverter will look for the Python binary, which has to be at version 3.
ln -s /usr/bin/python3 /usr/bin/python &&\

View File

@@ -45,10 +45,10 @@ func Run() {
fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed")
descriptors := gotenberg.GetModuleDescriptors()
var modsInfo string
var modsInfo strings.Builder
for _, desc := range descriptors {
fs.AddFlagSet(desc.FlagSet)
modsInfo += desc.ID + " "
modsInfo.WriteString(desc.ID + " ")
}
// Parse the flags.
@@ -94,7 +94,7 @@ func Run() {
if !hideBanner {
fmt.Printf(banner, Version)
}
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo)
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo.String())
ctx := gotenberg.NewContext(parsedFlags, descriptors)

65
go.mod
View File

@@ -1,6 +1,6 @@
module github.com/gotenberg/gotenberg/v8
go 1.25.4
go 1.26.0
require (
github.com/alexliesenfeld/health v0.8.1
@@ -11,23 +11,23 @@ require (
github.com/dlclark/regexp2 v1.11.5
github.com/docker/docker v28.5.2+incompatible
github.com/docker/go-connections v0.6.0
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab
github.com/google/uuid v1.6.0
github.com/hashicorp/go-retryablehttp v0.7.8
github.com/labstack/echo/v4 v4.13.4
github.com/labstack/echo/v4 v4.15.1
github.com/labstack/gommon v0.4.2
github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2
github.com/shirou/gopsutil/v4 v4.25.11
github.com/shirou/gopsutil/v4 v4.26.2
github.com/spf13/pflag v1.0.10
github.com/testcontainers/testcontainers-go v0.40.0
github.com/testcontainers/testcontainers-go v0.41.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1
golang.org/x/net v0.47.0
golang.org/x/sync v0.18.0
golang.org/x/term v0.37.0
golang.org/x/text v0.31.0
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0
golang.org/x/text v0.35.0
)
require (
@@ -42,6 +42,7 @@ require (
github.com/bodgit/sevenzip v1.6.1 // indirect
github.com/bodgit/windows v1.0.1 // indirect
github.com/cenkalti/backoff/v4 v4.3.0 // indirect
github.com/cenkalti/backoff/v5 v5.0.3 // indirect
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect
github.com/containerd/errdefs v1.0.0 // indirect
@@ -55,9 +56,9 @@ require (
github.com/distribution/reference v0.6.0 // indirect
github.com/docker/go-units v0.5.0 // indirect
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect
github.com/ebitengine/purego v0.9.1 // indirect
github.com/ebitengine/purego v0.10.0 // indirect
github.com/felixge/httpsnoop v1.0.4 // indirect
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect
github.com/go-logr/logr v1.4.3 // indirect
github.com/go-logr/stdr v1.2.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
@@ -71,34 +72,34 @@ require (
github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.2 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.0.1 // indirect
github.com/minio/minlz v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.1.0 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
github.com/moby/term v0.5.2 // indirect
github.com/morikuni/aec v1.0.0 // indirect
github.com/morikuni/aec v1.1.0 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/nwaples/rardecode/v2 v2.2.1 // indirect
github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect
github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.4 // indirect
github.com/prometheus/procfs v0.19.2 // indirect
github.com/sirupsen/logrus v1.9.3 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
@@ -109,19 +110,17 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 // indirect
go.opentelemetry.io/otel v1.38.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.38.0 // indirect
go.opentelemetry.io/otel/trace v1.38.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go4.org v0.0.0-20230225012048-214862532bf5 // indirect
golang.org/x/crypto v0.45.0 // indirect
golang.org/x/sys v0.38.0 // indirect
golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 // indirect
google.golang.org/protobuf v1.36.10 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

376
go.sum
View File

@@ -1,28 +1,9 @@
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.34.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
cloud.google.com/go v0.38.0/go.mod h1:990N+gfupTy94rShfmMCWGDn0LpTmnzTp2qbd1dvSRU=
cloud.google.com/go v0.44.1/go.mod h1:iSa0KzasP4Uvy3f1mN/7PiObzGgflwredwwASm/v6AU=
cloud.google.com/go v0.44.2/go.mod h1:60680Gw3Yr4ikxnPRS/oxxkBccT6SA1yMk63TGekxKY=
cloud.google.com/go v0.45.1/go.mod h1:RpBamKRgapWJb87xiFSdk4g1CME7QZg3uwTez+TSTjc=
cloud.google.com/go v0.46.3/go.mod h1:a6bKKbmY7er1mI7TEI4lsAkts/mkhTSZK8w33B4RAg0=
cloud.google.com/go v0.50.0/go.mod h1:r9sluTvynVuxRIOHXQEHMFffphuXHOMZMycpNR5e6To=
cloud.google.com/go v0.53.0/go.mod h1:fp/UouUEsRkN6ryDKNW/Upv/JBKnv6WDthjR6+vze6M=
cloud.google.com/go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=
cloud.google.com/go/bigquery v1.3.0/go.mod h1:PjpwJnslEMmckchkHFfq+HTD2DmtT67aNFKH1/VBDHE=
cloud.google.com/go/datastore v1.0.0/go.mod h1:LXYbyblFSglQ5pkeyhO+Qmw7ukd3C+pD7TKLgZqpHYE=
cloud.google.com/go/pubsub v1.0.1/go.mod h1:R0Gpsv3s54REJCy4fxDixWD93lHJMoZTyQ2kNxGRt3I=
cloud.google.com/go/pubsub v1.1.0/go.mod h1:EwwdRX2sKPjnvnqCa270oGRyludottCI76h+R3AArQw=
cloud.google.com/go/storage v1.0.0/go.mod h1:IhtSnM/ZTZV8YYJWCY8RULGVqBDmpoyjwiyrjsg+URw=
cloud.google.com/go/storage v1.5.0/go.mod h1:tpKbwo567HUNpVclU5sGELwQWBDZ8gh0ZeosJ0Rtdos=
dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8=
dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA=
dmitri.shuralyov.com/gpu/mtl v0.0.0-20190408044501-666a987793e9/go.mod h1:H6x//7gZCb22OMCxBHrMx7a5I7Hp++hsVxbQ4BYO7hU=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6 h1:He8afgbRMd7mFxO99hRNu+6tazq8nFF9lIwo9JFroBk=
github.com/AdaLogics/go-fuzz-headers v0.0.0-20240806141605-e8a1dd7889d6/go.mod h1:8o94RPi1/7XTJvwPpRSzSUedZrtlirdB3r9Z20bi2f8=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c h1:udKWzYgxTojEKWjV8V+WSxDXJ4NFATAsZjh8iIbsQIg=
github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c/go.mod h1:xomTg63KZ2rFqZQzSB4Vz2SUXa1BpHTVz9L5PTmPC4E=
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
github.com/BurntSushi/xgb v0.0.0-20160522181843-27f122750802/go.mod h1:IVnqGOEym/WlBOVXweHU+Q+/VP0lqqI8lqeDx9IjBqo=
github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY=
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
github.com/STARRY-S/zip v0.2.3 h1:luE4dMvRPDOWQdeDdUxUoZkzUIpTccdKdhHHsQJ1fm4=
@@ -45,7 +26,8 @@ github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8=
github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE=
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM=
github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
@@ -54,10 +36,6 @@ github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZ
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/chzyer/logex v1.1.10/go.mod h1:+Ywpsq7O8HXn0nuIou7OrIPyXbp3wmkHB+jjWRnGsAI=
github.com/chzyer/readline v0.0.0-20180603132655-2972be24d48e/go.mod h1:nSuG5e5PlCu98SY8svDHJxuZscDgtXS6KTTbou5AhLI=
github.com/chzyer/test v0.0.0-20180213035817-a1ea475d72b1/go.mod h1:Q3SI9o4m/ZMnBNeIyt5eFwwo7qiLfzFZmjNmxjkiQlU=
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
github.com/containerd/errdefs v1.0.0/go.mod h1:+YBYIdtsnF4Iw6nWZhJcqGSg/dwvV7tyJ/kCkyJ2k+M=
github.com/containerd/errdefs/pkg v0.3.0 h1:9IKJ06FvyNlexW690DXuQNx2KA2cUJXx151Xdx3ZPPE=
@@ -94,18 +72,14 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4=
github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A=
github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU=
github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg=
github.com/felixge/httpsnoop v1.0.4/go.mod h1:m8KPJKqk1gH5J9DgRY2ASl2lWCfGKXixSwevea8zH2U=
github.com/go-gl/glfw v0.0.0-20190409004039-e6da0acd62b1/go.mod h1:vR7hzQXu2zJy9AVAgeJqvqgH9Q5CA+iKCZ2gyEVpxRU=
github.com/go-gl/glfw/v3.3/glfw v0.0.0-20191125211704-12ad95a8df72/go.mod h1:tQ2UAYgL5IevRw8kRxooKSPJfGvJ9fJQFa0TUsXzTg8=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 h1:vymEbVwYFP/L05h5TKQxvkXoKxNvTpjxYKdF1Nlwuao=
github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433/go.mod h1:tphK2c80bpPhMOI4v6bIc2xWywPfbqi1Z06+RcrMkDg=
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
@@ -124,38 +98,13 @@ github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.2.0/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
github.com/golang/mock v1.3.1/go.mod h1:sBzyDLLjw3U8JLTeZvSv8jJB+tU5PVekmnlKIyFUx0Y=
github.com/golang/mock v1.4.0/go.mod h1:UOMv5ysSaYNkG+OFQykRIcU/QvvxJf3p21QfJ2Bt3cw=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.3.3/go.mod h1:vzj43D7+SQXF/4pzW/hwtAqwc6iTitCiVSaWz5lYuqw=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a h1:l7A0loSszR5zHd/qK53ZIHMO8b3bBSmENnQ6eKnUT0A=
github.com/gomarkdown/markdown v0.0.0-20250810172220-2e2c11897d1a/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/btree v0.0.0-20180813153112-4030bb1f1f0c/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/btree v1.0.0/go.mod h1:lNA+9X1NB3Zf8V7Ke586lFgjr2dZNuvo3lPJSGZ5JPQ=
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/martian v2.1.0+incompatible/go.mod h1:9I4somxYTbIHy5NJKHRl3wXiIaQGbYVAs8BPL6v8lEs=
github.com/google/pprof v0.0.0-20181206194817-3ea8567a2e57/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20190515194954-54271f7e092f/go.mod h1:zfwlbNMJ+OItoe0UupaVj+oy1omPYYDuagoSzA8v9mc=
github.com/google/pprof v0.0.0-20200212024743-f11f1df84d12/go.mod h1:ZgVRPoUq/hfqzAqh7sHMqb3I9Rq5C59dIz2SbBwJ4eM=
github.com/google/renameio v0.1.0/go.mod h1:KWCgfxg9yswjAJkECMjeO8J8rahYeXnNhOm40UhjYkI=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+vpHVxEJEs9eg=
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
@@ -176,24 +125,18 @@ github.com/hashicorp/go-uuid v1.0.0/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/b
github.com/hashicorp/go-uuid v1.0.2 h1:cfejS+Tpcp13yd5nYHWDI6qVCny6wyX2Mt5SGur2IGE=
github.com/hashicorp/go-uuid v1.0.2/go.mod h1:6SBZvOh/SIDV7/2o3Jml5SYk/TvGqwFJ/bN7x4byOro=
github.com/hashicorp/golang-lru v0.5.0/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.1/go.mod h1:/m3WP610KZHVQ1SGc6re/UDhFvYD7pJ4Ao+sR/qLZy8=
github.com/hashicorp/golang-lru v0.5.4/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru v1.0.2 h1:dV3g9Z/unq5DpblPpw+Oqcv4dU/1omnb4Ok8iPY6p1c=
github.com/hashicorp/golang-lru v1.0.2/go.mod h1:iADmTwqILo4mZ8BN3D2Q6+9jd8WM5uGBxy+E8yxSoD4=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/ianlancetaylor/demangle v0.0.0-20181102032728-5e5cf60278f6/go.mod h1:aSSvb/t6k1mPoxDqO4vJh6VOCGPwU4O0C2/Eqndh1Sc=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jstemmer/go-junit-report v0.0.0-20190106144839-af01ea7f8024/go.mod h1:6v2b51hI/fHJwM22ozAgKL4VKDeJcHhJFhtBdhmNjmU=
github.com/jstemmer/go-junit-report v0.9.1/go.mod h1:Brl9GWCQeLvo8nXZwPNNblvFj/XSXhF0NWZEnDohbsk=
github.com/kisielk/gotool v1.0.0/go.mod h1:XhKaO+MFFWcvkIS/tQcRk01m1F5IRFswLeQ+oQHNcck=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.2 h1:iiPHWW0YrcFgpBYhsA6D1+fqHssJscY/Tm/y2Uqnapk=
github.com/klauspost/compress v1.18.2/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo=
github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -203,14 +146,14 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc=
github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.13.4 h1:oTZZW+T3s9gAu5L8vmzihV7/lkXGZuITzTQkTEhcXEA=
github.com/labstack/echo/v4 v4.13.4/go.mod h1:g63b33BZ5vZzcIUF8AtRH40DrTlXnx4UMC8rBdndmjQ=
github.com/labstack/echo/v4 v4.15.1 h1:S9keusg26gZpjMmPqB5hOEvNKnmd1lNmcHrbbH2lnFs=
github.com/labstack/echo/v4 v4.15.1/go.mod h1:xmw1clThob0BSVRX1CRQkGQ/vjwcpOMjQZSZa9fKA/c=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0=
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3 h1:PwQumkgq4/acIiZhtifTV5OUqqiP82UAl0h87xj/l9k=
github.com/lufia/plan9stats v0.0.0-20251013123823-9fd1530e3ec3/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -223,12 +166,12 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ=
github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.1.0 h1:Kk/5rdW/g+H8NHdJW2gsXyZ7UnzvJNOy6VKJqueWdcQ=
github.com/moby/go-archive v0.1.0/go.mod h1:G9B+YoujNohJmrIYFBpSd54GTUB4lt9S+xVQvsJyFuo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
@@ -241,20 +184,20 @@ github.com/moby/sys/userns v0.1.0 h1:tVLXkFOxVu9A64/yh59slHVv9ahO9UIev4JZusOLG/g
github.com/moby/sys/userns v0.1.0/go.mod h1:IHUYgu/kao6N8YZlp9Cf444ySSvCmDlmzUcYfDHOl28=
github.com/moby/term v0.5.2 h1:6qk3FJAFDs6i/q3W/pQ97SX192qKfZgGjCQqfCJkgzQ=
github.com/moby/term v0.5.2/go.mod h1:d3djjFCrjnB+fl8NJux+EJzu0msscUP+f8it8hPkFLc=
github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A=
github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc=
github.com/morikuni/aec v1.1.0 h1:vBBl0pUnvi/Je71dsRrhMBtreIqNMYErSAbEeb8jrXQ=
github.com/morikuni/aec v1.1.0/go.mod h1:xDRgiq/iw5l+zkao76YTKzKttOp2cwPEne25HDkJnBw=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA=
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ=
github.com/nwaples/rardecode/v2 v2.2.1 h1:DgHK/O/fkTQEKBJxBMC5d9IU8IgauifbpG78+rZJMnI=
github.com/nwaples/rardecode/v2 v2.2.1/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU=
github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw=
github.com/opencontainers/go-digest v1.0.0 h1:apOUWs51W5PlhuyGyz9FCeeBIOUDA/6nW8Oi/yOhh5U=
github.com/opencontainers/go-digest v1.0.0/go.mod h1:0JzlMkj0TRzQZfJkVvzbP0HBR3IKzErnv2BNG4W4MAM=
github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJwooC2xJA040=
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -263,22 +206,19 @@ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o=
github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg=
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk=
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.4 h1:yR3NqWO1/UyO1w2PhUvXlGQs/PtFmoveVO0KZ4+Lvsc=
github.com/prometheus/common v0.67.4/go.mod h1:gP0fq6YjjNCLssJCQp0yk4M8W6ikLURwkdd/YKtTbyI=
github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws=
github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw=
github.com/rogpeppe/go-internal v1.3.0/go.mod h1:M8bDsm7K2OlrFYOpmOWEs/qY81heoFRclV5y23lUDJ4=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/rwcarlsen/goexif v0.0.0-20190401172101-9e8deecbddbd/go.mod h1:hPqNNc0+uJM6H+SuU8sEs5K5IQeKccPqeSjfgcKGgPk=
github.com/shirou/gopsutil/v4 v4.25.11 h1:X53gB7muL9Gnwwo2evPSE+SfOrltMoR6V3xJAXZILTY=
github.com/shirou/gopsutil/v4 v4.25.11/go.mod h1:EivAfP5x2EhLp2ovdpKSozecVXn1TmuG7SMzs/Wh4PU=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
github.com/sorairolake/lzip-go v0.3.8/go.mod h1:JcBqGMV0frlxwrsE9sMWXDjqn3EeVf0/54YPsw66qkU=
github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I=
@@ -294,16 +234,14 @@ github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpE
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais=
github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
@@ -317,31 +255,26 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opencensus.io v0.21.0/go.mod h1:mSImk1erAIZhrmZN+AvHh14ztQfjbGwt4TtuofqLduU=
go.opencensus.io v0.22.0/go.mod h1:+kGneAE2xo2IficOXnaByMWTGM9T73dGwxeWcUqIpI8=
go.opencensus.io v0.22.2/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opencensus.io v0.22.3/go.mod h1:yxeiOL68Rb0Xd1ddK5vPZ/oVn4vY4Ynel7k9FzqtOIw=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0 h1:RbKq8BG0FI8OiXhBfcRtqqHcZcka+gU3cskNuf05R18=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.63.0/go.mod h1:h06DGIukJOevXaj/xrNjhi/2098RZzcLTbc0jDAUbsg=
go.opentelemetry.io/otel v1.38.0 h1:RkfdswUDRimDg0m2Az18RKOsnI8UDzppJAtj01/Ymk8=
go.opentelemetry.io/otel v1.38.0/go.mod h1:zcmtmQ1+YmQM9wrNsTGV/q/uyusom3P8RxwExxkZhjM=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU=
go.opentelemetry.io/otel/metric v1.38.0 h1:Kl6lzIYGAh5M159u9NgiRkmoMKjvbsKtYRwgfrA6WpA=
go.opentelemetry.io/otel/metric v1.38.0/go.mod h1:kB5n/QoRM8YwmUahxvI3bO34eVtQf2i4utNVLr9gEmI=
go.opentelemetry.io/otel/sdk v1.38.0 h1:l48sr5YbNf2hpCUj/FoGhW9yDkl+Ma+LrVl8qaM5b+E=
go.opentelemetry.io/otel/sdk v1.38.0/go.mod h1:ghmNdGlVemJI3+ZB5iDEuk4bWA3GkTpW+DOoZMYBVVg=
go.opentelemetry.io/otel/sdk/metric v1.38.0 h1:aSH66iL0aZqo//xXzQLYozmWrXxyFkBJ6qT5wthqPoM=
go.opentelemetry.io/otel/sdk/metric v1.38.0/go.mod h1:dg9PBnW9XdQ1Hd6ZnRz689CbtrUp0wMMs9iPcgT9EZA=
go.opentelemetry.io/otel/trace v1.38.0 h1:Fxk5bKrDZJUH+AMyyIXGcFAPah0oRcT+LuNtJrmcNLE=
go.opentelemetry.io/otel/trace v1.38.0/go.mod h1:j1P9ivuFsTceSWe1oY+EeW3sc+Pp42sO++GHkg4wwhs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
@@ -350,203 +283,44 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
go4.org v0.0.0-20230225012048-214862532bf5 h1:nifaUDeh+rPaBCMPMQHZmvJf+QdpLFnuQPwx+LxVmtc=
go4.org v0.0.0-20230225012048-214862532bf5/go.mod h1:F57wTi5Lrj6WLyswp5EYV1ncrEbFGHD4hhz6S1ZYeaU=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20190510104115-cbcb75029529/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20190605123033-f99c8df09eb5/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
golang.org/x/exp v0.0.0-20190829153037-c13cbed26979/go.mod h1:86+5VVa7VpoJ4kLfm080zCjGlMRFzhUhsZKEZO7MGek=
golang.org/x/exp v0.0.0-20191030013958-a1ab85dbe136/go.mod h1:JXzH8nQsPlswgeRAPE3MuO9GYsAcnJvJ4vnMwN/5qkY=
golang.org/x/exp v0.0.0-20191129062945-2f5052295587/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20191227195350-da58074b4299/go.mod h1:2RIsYlXP63K8oxa1u096TMicItID8zy7Y6sNkU49FU4=
golang.org/x/exp v0.0.0-20200207192155-f17229e696bd/go.mod h1:J/WKrq2StrnmMY6+EHIKF9dgMWnmCNThgcyBT1FY9mM=
golang.org/x/image v0.0.0-20190227222117-0694c2d4d067/go.mod h1:kZ7UVZpmo3dzQBMxlp+ypCbDeSB+sBbTgSJuh5dn5js=
golang.org/x/image v0.0.0-20190802002840-cff245a6509b/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0=
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
golang.org/x/lint v0.0.0-20190301231843-5614ed5bae6f/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190409202823-959b441ac422/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190909230951-414d861bb4ac/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20190930215403-16217165b5de/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
golang.org/x/lint v0.0.0-20191125180803-fdd1cda4f05f/go.mod h1:5qLYkcX4OjUUV8bRuDixDT3tpyyb+LUpUlRWLxfhWrs=
golang.org/x/lint v0.0.0-20200130185559-910be7a94367/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
golang.org/x/mobile v0.0.0-20190312151609-d3739f865fa6/go.mod h1:z+o9i4GpDbdi3rU15maQ/Ox0txvL9dWGYEHz965HBQE=
golang.org/x/mobile v0.0.0-20190719004257-d2bd2a29d028/go.mod h1:E/iHnbuqvinMTCcRqshq8CkpyQDoeVncDDYHnLhea+o=
golang.org/x/mod v0.0.0-20190513183733-4bf6d317e70e/go.mod h1:mXi4GBBbnImb6dmsKGUJ2LatrhH/nqhxcFungHvyanc=
golang.org/x/mod v0.1.0/go.mod h1:0QHyrYULN0/3qlju5TqG8bIK38QM8yzMo5ekMj3DlcY=
golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190108225652-1e06a53dbb7e/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190501004415-9ce7a6920f09/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190503192946-f4e77d36d62c/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20190724013045-ca1201d0de80/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20191209160850-c0dbc17a3553/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200202094626-16171245cfb2/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.7.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20191202225959-858c2ad4c8b6/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20200107190931-bf48bf16ab8d/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20181221193216-37e7f081c4d4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190227155943-e225da77a7e6/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190911185100-cd5d95a43a6e/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.18.0 h1:kr88TuHDroi+UVf+0hZnirlk8o8T+4MrK6mr60WkH/I=
golang.org/x/sync v0.18.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190312061237-fead79001313/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190502145724-3ef323f4f1fd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190507160741-ecd444e8653b/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190606165138-5da285871e9c/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190624142023-c5567b49c5d0/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190726091711-fc99dfbffb4e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191204072324-ce4227a45e2e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191228213918-04cbcbbfeed8/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200212091648-12a6c2dcc1e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.38.0 h1:3yZWxaJjBmCWXqhN1qh02AkOnCQ1poK6oF+a7xWL6Gc=
golang.org/x/sys v0.38.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.37.0 h1:8EGAD0qCmHYZg6J17DvsMy9/wJ7/D/4pV/wfnld5lTU=
golang.org/x/term v0.37.0/go.mod h1:5pB4lxRNYYVZuTLmy8oR2BH8dflOR+IbTYFD8fi3254=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.31.0 h1:aC8ghyu4JhP8VojJ2lEHBnochRno1sgL6nEi9WGFGMM=
golang.org/x/text v0.31.0/go.mod h1:tKRAlv61yKIjGGHX/4tP1LTbc13YSec1pxVEWXzfoeM=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312151545-0bb0c0a6e846/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190312170243-e65039ee4138/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190506145303-2d16b83fe98c/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
golang.org/x/tools v0.0.0-20190606124116-d0a3d012864b/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190621195816-6e04913cbbac/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190628153133-6cdbf07be9d0/go.mod h1:/rFqwRUd4F7ZHNgwSSTFct+R/Kf4OFW1sUzUTQQTgfc=
golang.org/x/tools v0.0.0-20190816200558-6889da9d5479/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20190911174233-4f2ddba30aff/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191012152004-8de300cfc20a/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191113191852-77e3bb0ad9e7/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191115202509-3a792d9c32b2/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191125144606-a911d9008d1f/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20191216173652-a0e659d51361/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20191227053925-7b8e75db28f4/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200130002326-2f3ba24bd6e7/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200207183749-b753a1ba74fa/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.0.0-20200212150539-ea181f53ac56/go.mod h1:TB2adYChydJhpapKDTa4BR/hXlZSLoq2Wpct/0txZ28=
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/api v0.4.0/go.mod h1:8k5glujaEP+g9n7WNsDg8QP6cUVNI86fCNMcbazEtwE=
google.golang.org/api v0.7.0/go.mod h1:WtwebWUNSVBH/HAw79HIFXZNqEvBhG+Ra+ax0hx3E3M=
google.golang.org/api v0.8.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.9.0/go.mod h1:o4eAsZoiT+ibD93RtjEohWalFOjRDx6CVaqeizhEnKg=
google.golang.org/api v0.13.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.14.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.15.0/go.mod h1:iLdEw5Ide6rF15KTC1Kkl0iskquN2gFfn9o9XIsbkAI=
google.golang.org/api v0.17.0/go.mod h1:BwFmGc8tA3vsd7r/7kR8DY7iEEGSU04BFxCo5jP/sfE=
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.5.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
google.golang.org/appengine v1.6.1/go.mod h1:i06prIuMbXzDqacNJfV5OdTW448YApPu5ww/cMBSeb0=
google.golang.org/appengine v1.6.5/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc=
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
google.golang.org/genproto v0.0.0-20190307195333-5fe7a883aa19/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190418145605-e7d98fc518a7/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190425155659-357c62f0e4bb/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190502173448-54afdca5d873/go.mod h1:VzzqZJRnGkLBvHegQrXjBqPurQTc5/KpmUdxsrq26oE=
google.golang.org/genproto v0.0.0-20190801165951-fa694d86fc64/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
google.golang.org/genproto v0.0.0-20190911173649-1774047e7e51/go.mod h1:IbNlFCBrqXvoKpeg0TB2l7cyZUmoaFKYIwrEpbDKLA8=
google.golang.org/genproto v0.0.0-20191108220845-16a3f7862a1a/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191115194625-c23dd37a84c9/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191216164720-4f79533eabd1/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20191230161307-f3c370f40bfb/go.mod h1:n3cpQtvxv34hfy77yVDNjmbRyujviMdxYliBSkLhpCc=
google.golang.org/genproto v0.0.0-20200212174721-66ed5ce911ce/go.mod h1:55QSHmfGQM9UVYDPBsyGGes0y52j32PQ3BqQfXhyH3c=
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0 h1:0UOBWO4dC+e51ui0NFKSPbkHHiQ4TmrEfEZMLDyRmY8=
google.golang.org/genproto/googleapis/api v0.0.0-20250728155136-f173205681a0/go.mod h1:8ytArBbtOy2xfht+y2fqKd5DRDJRUQhqbyEnQ4bDChs=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0 h1:MAKi5q709QWfnkkpNQ0M12hYJ1+e8qYVDyowc4U1XZM=
google.golang.org/genproto/googleapis/rpc v0.0.0-20250728155136-f173205681a0/go.mod h1:qQ0YXyHHx3XkvlzUtpXDkS29lDSafHMZBAZDc03LQ3A=
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
google.golang.org/grpc v1.20.1/go.mod h1:10oTOabMzJvdu6/UiuZezV6QK5dSlG84ov/aaiqXj38=
google.golang.org/grpc v1.21.1/go.mod h1:oYelfM1adQP15Ek0mdvEgi9Df8B9CZIaU1084ijfRaM=
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
google.golang.org/grpc v1.26.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.27.1/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4=
google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM=
google.golang.org/protobuf v1.36.10 h1:AYd7cD/uASjIL6Q9LiTjz8JLcrh/88q5UObnmY3aOOE=
google.golang.org/protobuf v1.36.10/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk=
gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q=
gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gotest.tools/v3 v3.5.2 h1:7koQfIKdy+I8UTetycgUqXWSDwpgv193Ka+qRsmBY8Q=
gotest.tools/v3 v3.5.2/go.mod h1:LtdLGcnqToBH83WByAAi/wiwSFCArdFIUV/xxN4pcjA=
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190106161140-3f1c8253044a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190418001031-e561f6794a2a/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
honnef.co/go/tools v0.0.1-2019.2.3/go.mod h1:a3bituU0lyd329TUQxRnasdCoJDkEUEAqEt0JzvZhAg=
rsc.io/binaryregexp v0.2.0/go.mod h1:qTv7/COck+e2FymRvadv62gMdZztPaShugOCi3I+8D8=
rsc.io/quote/v3 v3.1.0/go.mod h1:yEA65RcK8LyAZtP9Kv3t0HmxON59tX3rD+tICJqUlj0=
rsc.io/sampler v1.3.0/go.mod h1:T1hPZKmBbMNahiBKFy5HrXp6adAjACjK9JXDnKaTXpA=

9
package-lock.json generated
View File

@@ -5,7 +5,7 @@
"packages": {
"": {
"devDependencies": {
"prettier": "3.7.3",
"prettier": "3.8.1",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}
@@ -58,12 +58,11 @@
"license": "MIT"
},
"node_modules/prettier": {
"version": "3.7.3",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.7.3.tgz",
"integrity": "sha512-QgODejq9K3OzoBbuyobZlUhznP5SKwPqp+6Q6xw6o8gnhr4O85L2U915iM2IDcfF2NPXVaM9zlo9tdwipnYwzg==",
"version": "3.8.1",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz",
"integrity": "sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},

View File

@@ -1,6 +1,6 @@
{
"devDependencies": {
"prettier": "3.7.3",
"prettier": "3.8.1",
"prettier-plugin-gherkin": "^3.1.3",
"prettier-plugin-sh": "^0.18.0"
}

View File

@@ -10,7 +10,7 @@ import (
type Context struct {
flags ParsedFlags
descriptors []ModuleDescriptor
moduleInstances map[string]interface{}
moduleInstances map[string]any
}
// NewContext creates a [Context].
@@ -22,7 +22,7 @@ func NewContext(
return &Context{
flags: flags,
descriptors: descriptors,
moduleInstances: make(map[string]interface{}),
moduleInstances: make(map[string]any),
}
}
@@ -45,7 +45,7 @@ func (ctx *Context) ParsedFlags() ParsedFlags {
//
// If the module has not yet been initialized, this method
// initializes it. Otherwise, returns the already initialized instance.
func (ctx *Context) Module(kind interface{}) (interface{}, error) {
func (ctx *Context) Module(kind any) (any, error) {
mods, err := ctx.Modules(kind)
if err != nil {
return nil, fmt.Errorf("get module: %w", err)
@@ -70,10 +70,10 @@ func (ctx *Context) Module(kind interface{}) (interface{}, error) {
//
// If one or more modules have not yet been initialized, this method
// initializes them. Otherwise, returns the already initialized instances.
func (ctx *Context) Modules(kind interface{}) ([]interface{}, error) {
func (ctx *Context) Modules(kind any) ([]any, error) {
realKind := reflect.TypeOf(kind).Elem()
var mods []interface{}
var mods []any
for _, desc := range ctx.descriptors {
newInstance := desc.New()
@@ -101,7 +101,7 @@ func (ctx *Context) Modules(kind interface{}) ([]interface{}, error) {
// loadModule calls the Provision and/or Validate methods of the requested
// module if it satisfies the [Provisioner] and/or [Validator] interfaces.
func (ctx *Context) loadModule(id string, instance interface{}) error {
func (ctx *Context) loadModule(id string, instance any) error {
if prov, ok := instance.(Provisioner); ok {
// The instance can be provisioned.
err := prov.Provision(ctx)

View File

@@ -9,7 +9,7 @@ func TestContext_Module(t *testing.T) {
for _, tc := range []struct {
scenario string
mods []ModuleDescriptor
kind interface{}
kind any
expectError bool
}{
{
@@ -80,7 +80,7 @@ func TestContext_Modules(t *testing.T) {
for _, tc := range []struct {
scenario string
mods []ModuleDescriptor
kind interface{}
kind any
expectError bool
}{
{
@@ -151,12 +151,12 @@ func TestContext_Modules(t *testing.T) {
func TestContext_loadModule(t *testing.T) {
for _, tc := range []struct {
scenario string
instance interface{}
instance any
expectError bool
}{
{
scenario: "module with error on provision",
instance: func() interface{} {
instance: func() any {
mod := &struct {
ModuleMock
ProvisionerMock
@@ -171,7 +171,7 @@ func TestContext_loadModule(t *testing.T) {
},
{
scenario: "module with error on validation",
instance: func() interface{} {
instance: func() any {
mod := &struct {
ModuleMock
ValidatorMock
@@ -186,7 +186,7 @@ func TestContext_loadModule(t *testing.T) {
},
{
scenario: "success",
instance: func() interface{} {
instance: func() any {
mod := &struct {
ModuleMock
ValidatorMock

View File

@@ -4,17 +4,19 @@ import (
"runtime"
"sort"
"sync"
"time"
flag "github.com/spf13/pflag"
)
// DebugInfo gathers data for debugging.
type DebugInfo struct {
Version string `json:"version"`
Architecture string `json:"architecture"`
Modules []string `json:"modules"`
ModulesAdditionalData map[string]map[string]interface{} `json:"modules_additional_data"`
Flags map[string]interface{} `json:"flags"`
Version string `json:"version"`
Timezone string `json:"timezone"`
Architecture string `json:"architecture"`
Modules []string `json:"modules"`
ModulesAdditionalData map[string]map[string]any `json:"modules_additional_data"`
Flags map[string]any `json:"flags"`
}
// BuildDebug builds the debug data from modules.
@@ -24,10 +26,11 @@ func BuildDebug(ctx *Context) {
debug = &DebugInfo{
Version: Version,
Timezone: time.Now().Location().String(),
Architecture: runtime.GOARCH,
Modules: make([]string, len(ctx.moduleInstances)),
ModulesAdditionalData: make(map[string]map[string]interface{}),
Flags: make(map[string]interface{}),
ModulesAdditionalData: make(map[string]map[string]any),
Flags: make(map[string]any),
}
i := 0

View File

@@ -13,6 +13,8 @@ func TestBuildDebug(t *testing.T) {
t.Errorf("Debug() should return empty debug data")
}
t.Setenv("TZ", "UTC")
fs := flag.NewFlagSet("gotenberg", flag.ExitOnError)
fs.String("foo", "bar", "Set foo")
ctx := NewContext(ParsedFlags{
@@ -31,8 +33,8 @@ func TestBuildDebug(t *testing.T) {
mod2.DescriptorMock = func() ModuleDescriptor {
return ModuleDescriptor{ID: "bar", New: func() Module { return mod2 }}
}
mod2.DebugMock = func() map[string]interface{} {
return map[string]interface{}{
mod2.DebugMock = func() map[string]any {
return map[string]any{
"foo": "bar",
}
}
@@ -51,17 +53,18 @@ func TestBuildDebug(t *testing.T) {
expect := DebugInfo{
Version: Version,
Timezone: "UTC",
Architecture: runtime.GOARCH,
Modules: []string{
"bar",
"foo",
},
ModulesAdditionalData: map[string]map[string]interface{}{
ModulesAdditionalData: map[string]map[string]any{
"bar": {
"foo": "bar",
},
},
Flags: map[string]interface{}{
Flags: map[string]any{
"foo": "bar",
},
}

View File

@@ -32,22 +32,22 @@ func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
}
// Error logs a message at the error level using the wrapped zap.Logger.
func (leveled LeveledLogger) Error(msg string, keysAndValues ...interface{}) {
func (leveled LeveledLogger) Error(msg string, keysAndValues ...any) {
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Warn logs a message at the warning level using the wrapped zap.Logger.
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...interface{}) {
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...any) {
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Info logs a message at the info level using the wrapped zap.Logger.
func (leveled LeveledLogger) Info(msg string, keysAndValues ...interface{}) {
func (leveled LeveledLogger) Info(msg string, keysAndValues ...any) {
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Debug logs a message at the debug level using the wrapped zap.Logger.
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...interface{}) {
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...any) {
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}

View File

@@ -35,10 +35,10 @@ func (mod *ValidatorMock) Validate() error {
}
type DebuggableMock struct {
DebugMock func() map[string]interface{}
DebugMock func() map[string]any
}
func (mod *DebuggableMock) Debug() map[string]interface{} {
func (mod *DebuggableMock) Debug() map[string]any {
return mod.DebugMock()
}
@@ -46,14 +46,20 @@ func (mod *DebuggableMock) Debug() map[string]interface{} {
//
//nolint:dupl
type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *zap.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error
ReadBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error)
EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
WriteBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error
WatermarkMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
StampMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
RotateMock func(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error
}
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
@@ -72,14 +78,22 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, fo
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
}
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return engine.ReadMetadataMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
func (engine *PdfEngineMock) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
return engine.PageCountMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
}
func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error) {
return engine.ReadBookmarksMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
}
@@ -88,6 +102,22 @@ func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *zap.Logger,
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
}
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error {
return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks)
}
func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error {
return engine.WatermarkMock(ctx, logger, inputPath, stamp)
}
func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error {
return engine.StampMock(ctx, logger, inputPath, stamp)
}
func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
}
// PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface.
type PdfEngineProviderMock struct {
PdfEngineMock func() (PdfEngine, error)

View File

@@ -78,7 +78,7 @@ type SystemLogger interface {
// Debuggable is a module interface for modules which want to provide
// additional debug data.
type Debuggable interface {
Debug() map[string]interface{}
Debug() map[string]any
}
// MustRegisterModule registers a module.

View File

@@ -28,6 +28,14 @@ var (
// ErrPdfEncryptionNotSupported is returned when encryption
// is not supported by the PDF engine.
ErrPdfEncryptionNotSupported = errors.New("encryption not supported")
// ErrPdfStampSourceNotSupported is returned when a stamp source type
// is not supported by the PDF engine.
ErrPdfStampSourceNotSupported = errors.New("stamp source not supported")
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
// not supported.
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
)
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when
@@ -49,6 +57,33 @@ func NewPdfEngineInvalidArgs(engine, msg string) error {
return &PdfEngineInvalidArgsError{engine, msg}
}
const (
// StampSourceText represents a text-based stamp source.
StampSourceText string = "text"
// StampSourceImage represents an image-based stamp source.
StampSourceImage string = "image"
// StampSourcePDF represents a PDF-based stamp source.
StampSourcePDF string = "pdf"
)
// Stamp gathers the data required to apply a watermark or stamp to a PDF.
type Stamp struct {
// Source is one of "text", "image", or "pdf".
Source string
// Expression is the text content (for text source) or file path (for
// image/pdf source).
Expression string
// Pages is the optional page range to apply the stamp to.
Pages string
// Options holds engine-specific styling options.
Options map[string]string
}
const (
// SplitModeIntervals represents a mode where a PDF is split at specific
// intervals.
@@ -109,6 +144,14 @@ type PdfFormats struct {
PdfUa bool
}
// Bookmark represents a node in the PDF document's outline
// (table of contents).
type Bookmark struct {
Title string `json:"title"`
Page int `json:"page"`
Children []Bookmark `json:"children,omitempty"`
}
// PdfEngine provides an interface for operations on PDFs. Implementations
// can use various tools like PDFtk, or implement functionality directly in
// Go.
@@ -133,10 +176,20 @@ type PdfEngine interface {
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
// ReadMetadata extracts the metadata of a given PDF file.
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error)
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error)
// PageCount returns the number of pages in a PDF file.
PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error)
// WriteMetadata writes the metadata into a given PDF file.
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error
// ReadBookmarks reads the document outline (bookmarks) of a PDF file.
ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error)
// WriteBookmarks adds a document outline (bookmarks) to a PDF file.
// The bookmarks parameter represents the hierarchical tree of the outline.
WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error
// Encrypt adds password protection to a PDF file.
// The userPassword is required to open the document.
@@ -146,7 +199,18 @@ type PdfEngine interface {
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
// TODO: attachments instead? Rename the route?
EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
// Watermark applies a watermark (behind page content) to a PDF file.
Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
// Stamp applies a stamp (on top of page content) to a PDF file.
Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
// If pages is empty, all pages are rotated.
Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error
}
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"sync"
"sync/atomic"
"go.uber.org/zap"
@@ -79,27 +80,38 @@ type processSupervisor struct {
process Process
maxReqLimit int64
maxQueueSize int64
mutexChan chan struct{}
maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool
firstStartOnce sync.Once
firstStartErr error
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
activeTasks atomic.Int64
restartMutex sync.Mutex
}
// NewProcessSupervisor initializes a new [ProcessSupervisor].
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize int64) ProcessSupervisor {
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor {
if maxConcurrency < 1 {
maxConcurrency = 1
}
b := &processSupervisor{
logger: logger,
process: process,
mutexChan: make(chan struct{}, 1),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
logger: logger,
process: process,
semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
maxConcurrency: maxConcurrency,
}
b.reqCounter.Store(0)
b.reqQueueSize.Store(0)
b.restartsCounter.Store(0)
b.isRestarting.Store(false)
b.activeTasks.Store(0)
return b
}
@@ -130,15 +142,7 @@ func (s *processSupervisor) Shutdown() error {
}
func (s *processSupervisor) restart() error {
if s.isRestarting.Load() {
s.logger.Debug("process already restarting, skip restart")
return ErrProcessAlreadyRestarting
}
s.logger.Debug("restart process")
s.isRestarting.Store(true)
defer s.isRestarting.Store(false)
err := s.Shutdown()
if err != nil {
@@ -197,33 +201,43 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
for {
err := func() error {
select {
case s.mutexChan <- struct{}{}:
case s.semaphore <- struct{}{}:
logger.Debug("process lock acquired")
// If a restart drain is in progress, release the slot
// immediately so the drain can acquire it instead.
if s.isRestarting.Load() {
<-s.semaphore
return ErrProcessAlreadyRestarting
}
s.reqQueueSize.Add(-1)
s.reqCounter.Add(1)
releaseMutexChan := true
s.activeTasks.Add(1)
releaseSemaphore := true
defer func() {
if releaseMutexChan {
s.activeTasks.Add(-1)
if releaseSemaphore {
logger.Debug("process lock released")
<-s.mutexChan
<-s.semaphore
}
}()
if !s.firstStart.Load() {
err := s.runWithDeadline(ctx, func() error {
return s.Launch()
s.firstStartOnce.Do(func() {
s.firstStartErr = s.runWithDeadline(ctx, func() error {
return s.Launch()
})
})
if err != nil {
return fmt.Errorf("process first start: %w", err)
if s.firstStartErr != nil {
return fmt.Errorf("process first start: %w", s.firstStartErr)
}
}
if !s.Healthy() {
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
err := s.runWithDeadline(ctx, func() error {
return s.restart()
})
err := s.doRestart(ctx)
if err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
@@ -232,19 +246,21 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
err := s.runWithDeadline(ctx, task)
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
s.logger.Debug("max request limit reached, restarting eagerly...")
releaseMutexChan = false
// Only one goroutine should trigger the restart.
if s.restartMutex.TryLock() {
s.logger.Debug("max request limit reached, restarting eagerly...")
releaseSemaphore = false
go func() {
err := s.runWithDeadline(context.Background(), func() error {
return s.restart()
})
if err != nil {
s.logger.Error(fmt.Sprintf("process restart after task: %v", err))
}
logger.Debug("process lock released")
<-s.mutexChan
}()
go func() {
restartErr := s.doRestartLocked(context.Background())
s.restartMutex.Unlock()
if restartErr != nil {
s.logger.Error(fmt.Sprintf("process restart after task: %v", restartErr))
}
logger.Debug("process lock released")
<-s.semaphore
}()
}
}
// Note: no error wrapping because it leaks on Chromium console exceptions output.
@@ -259,7 +275,6 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
if errors.Is(err, ErrProcessAlreadyRestarting) {
logger.Debug("process is already restarting, trying to acquire process lock again...")
s.reqQueueSize.Add(1)
continue
}
@@ -268,6 +283,47 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
}
}
// doRestart coordinates a process restart, draining all active concurrent
// tasks before stopping and restarting the process.
func (s *processSupervisor) doRestart(ctx context.Context) error {
s.restartMutex.Lock()
defer s.restartMutex.Unlock()
return s.doRestartLocked(ctx)
}
// doRestartLocked performs the restart drain logic. The caller must hold restartMutex.
func (s *processSupervisor) doRestartLocked(ctx context.Context) error {
s.isRestarting.Store(true)
defer s.isRestarting.Store(false)
// Drain all other active semaphore slots so no other tasks are running during the restart.
slotsToAcquire := s.maxConcurrency - 1
acquired := make([]struct{}, 0, slotsToAcquire)
for range slotsToAcquire {
select {
case s.semaphore <- struct{}{}:
acquired = append(acquired, struct{}{})
case <-ctx.Done():
for range acquired {
<-s.semaphore
}
return fmt.Errorf("drain active tasks before restart: %w", ctx.Err())
}
}
err := s.runWithDeadline(ctx, func() error {
return s.restart()
})
for range acquired {
<-s.semaphore
}
return err
}
func (s *processSupervisor) runWithDeadline(ctx context.Context, task func() error) error {
runChan := make(chan error, 1)
go func() {

View File

@@ -46,7 +46,7 @@ func TestProcessSupervisor_Launch(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
if tc.firstStartSet {
ps.firstStart.Store(true)
}
@@ -94,7 +94,7 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0)
ps := NewProcessSupervisor(logger, process, 5, 0, 1)
err := ps.Shutdown()
if !tc.expectError && err != nil {
@@ -110,19 +110,11 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
func TestProcessSupervisor_restart(t *testing.T) {
for _, tc := range []struct {
scenario string
initiallyRestarting bool
startError error
stopError error
expectError bool
expectedError error
scenario string
startError error
stopError error
expectError bool
}{
{
scenario: "already restarting",
initiallyRestarting: true,
expectError: true,
expectedError: ErrProcessAlreadyRestarting,
},
{
scenario: "successful restart",
startError: nil,
@@ -154,10 +146,7 @@ func TestProcessSupervisor_restart(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
if tc.initiallyRestarting {
ps.isRestarting.Store(true)
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
err := ps.restart()
@@ -168,10 +157,6 @@ func TestProcessSupervisor_restart(t *testing.T) {
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
})
}
}
@@ -217,7 +202,7 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 5, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -402,7 +387,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -424,14 +409,12 @@ func TestProcessSupervisor_Run(t *testing.T) {
errorChan := make(chan error, tc.tasksToRun)
for i := 0; i < tc.tasksToRun; i++ {
wg.Add(1)
go func() {
defer wg.Done()
wg.Go(func() {
err := ps.Run(ctx, logger, task)
if err != nil {
errorChan <- err
}
}()
})
}
wg.Wait()
@@ -452,8 +435,8 @@ func TestProcessSupervisor_Run(t *testing.T) {
}
// Making sure restarts are finished.
ps.mutexChan <- struct{}{}
<-ps.mutexChan
ps.semaphore <- struct{}{}
<-ps.semaphore
if startCalls.Load() != tc.expectedStartCalls {
t.Errorf("expected %d process.Start calls, got %d", tc.expectedStartCalls, startCalls.Load())
@@ -488,7 +471,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0).(*processSupervisor)
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0, 1).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
if tc.ctxDone {
@@ -522,10 +505,10 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
// Simulating a lock.
ps.mutexChan <- struct{}{}
ps.semaphore <- struct{}{}
if ps.ReqQueueSize() != 0 {
t.Fatalf("expected queue size to be 0 but got %d", ps.ReqQueueSize())
@@ -537,17 +520,15 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
var wg sync.WaitGroup
errorChan := make(chan error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func() {
defer wg.Done()
for range 10 {
wg.Go(func() {
err := ps.Run(ctx, logger, func() error {
return nil
})
if err != nil {
errorChan <- err
}
}()
})
}
// We have to wait a little bit so that the request queue size may change.
@@ -623,7 +604,7 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
},
}
ps := NewProcessSupervisor(logger, process, 0, 0).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
ps.restartsCounter.Store(tc.initialRestartsCount)
for i := 0; i < tc.restartAttempts; i++ {
@@ -637,3 +618,122 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
})
}
}
func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
logger := zap.NewNop()
var startCalls atomic.Int64
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
startCalls.Add(1)
return nil
},
StopMock: func(logger *zap.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
return true
},
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var running atomic.Int64
var maxRunning atomic.Int64
var wg sync.WaitGroup
tasks := 6
for range tasks {
wg.Go(func() {
err := ps.Run(ctx, logger, func() error {
cur := running.Add(1)
for {
old := maxRunning.Load()
if cur <= old || maxRunning.CompareAndSwap(old, cur) {
break
}
}
time.Sleep(50 * time.Millisecond)
running.Add(-1)
return nil
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
wg.Wait()
observed := maxRunning.Load()
if observed > maxConcurrency {
t.Fatalf("expected at most %d concurrent tasks, but observed %d", maxConcurrency, observed)
}
if observed < 2 {
t.Fatalf("expected concurrent execution (at least 2 tasks running simultaneously), but observed max %d", observed)
}
if startCalls.Load() != 1 {
t.Errorf("expected 1 start call, got %d", startCalls.Load())
}
}
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
logger := zap.NewNop()
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
return nil
},
StopMock: func(logger *zap.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
return true
},
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency).(*processSupervisor)
ps.firstStart.Store(true)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
var wg sync.WaitGroup
tasks := 3
for range tasks {
wg.Go(func() {
err := ps.Run(ctx, logger, func() error {
time.Sleep(50 * time.Millisecond)
return nil
})
if err != nil {
t.Errorf("unexpected error: %v", err)
}
})
}
wg.Wait()
// Wait for the async restart goroutine to complete.
deadline := time.After(5 * time.Second)
for ps.RestartsCount() < 1 {
select {
case <-deadline:
t.Fatal("timed out waiting for restart to complete")
default:
time.Sleep(10 * time.Millisecond)
}
}
if ps.RestartsCount() != 1 {
t.Fatalf("expected 1 restart, got %d", ps.RestartsCount())
}
}

View File

@@ -81,13 +81,19 @@ type downloadFrom struct {
// ExtraHttpHeaders are the HTTP headers to send alongside.
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
// Download as embed file
// Embedded routes the downloaded file as an embed. Deprecated: use
// Field instead. Kept for backward compatibility.
Embedded bool `json:"embedded"`
// Field routes the downloaded file to a specific form field bucket.
// Supported values: "watermark", "stamp". For embeds, prefer the
// Embedded flag or set Field to "embedded".
Field string `json:"field"`
}
// newContext returns a [Context] by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(context.Background(), timeout)
processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout)
// We want to make sure the multipart/form-data does not exceed a given
// limit. We consider: form fields (keys, values, files) and files
@@ -323,8 +329,15 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
}
ctx.files[filename] = path
if dl.Embedded {
// Route the downloaded file to the appropriate field bucket.
switch {
case dl.Field == "embedded" || dl.Embedded:
ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
case dl.Field == "watermark":
ctx.filesByField[WatermarkFormField] = append(ctx.filesByField[WatermarkFormField], path)
case dl.Field == "stamp":
ctx.filesByField[StampFormField] = append(ctx.filesByField[StampFormField], path)
}
return nil

View File

@@ -0,0 +1,71 @@
package api
import (
"bytes"
"context"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"time"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// Propagate HTTP request context cancellation to processing modules to save resources
// https://github.com/gotenberg/gotenberg/issues/1455
func TestNewContext_Cancellation(t *testing.T) {
e := echo.New()
body := new(bytes.Buffer)
writer := multipart.NewWriter(body)
err := writer.Close()
if err != nil {
t.Fatalf("failed to close multipart writer: %v", err)
}
// Create a request with a cancellable context.
reqCtx, cancelReq := context.WithCancel(context.Background())
req := httptest.NewRequest(http.MethodPost, "/", body).WithContext(reqCtx)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
c := e.NewContext(req, rec)
logger := zap.NewNop()
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
timeout := time.Duration(10) * time.Second
downloadFromCfg := downloadFromConfig{
disable: true,
}
ctx, cancel, err := newContext(c, logger, fs, timeout, 0, downloadFromCfg, "trace", "trace")
if err != nil {
t.Fatalf("expected no error from newContext, got: %v", err)
}
defer cancel()
// Verify initial state: context SHOULD NOT be done yet.
select {
case <-ctx.Done():
t.Fatal("context should not be done immediately")
default:
}
// Simulate Client Disconnect
cancelReq()
// Verify Propagation
select {
case <-ctx.Done():
// Success! The context was cancelled.
if ctx.Err() != context.Canceled {
t.Errorf("expected context error to be 'context.Canceled', got: %v", ctx.Err())
}
case <-time.After(100 * time.Millisecond):
t.Fatal("expected context to be cancelled after request context cancellation, but it timed out")
}
}

View File

@@ -17,9 +17,15 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// EmbedsFormField represents the form field name for embedding files.
const (
// EmbedsFormField represents the form field name for embedding files.
EmbedsFormField string = "embeds"
// WatermarkFormField represents the form field name for the watermark file.
WatermarkFormField string = "watermark"
// StampFormField represents the form field name for the stamp file.
StampFormField string = "stamp"
)
// FormData is a helper for validating and hydrating values from a
@@ -406,17 +412,57 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For
return form
}
// Watermark binds the absolute path of the form data file that should be
// used as a watermark source. Only a file uploaded with the "watermark"
// field name will be included.
func (form *FormData) Watermark(target *string) *FormData {
if form.errors != nil {
return form
}
if paths, ok := form.filesByField[WatermarkFormField]; ok && len(paths) > 0 {
*target = paths[0]
}
return form
}
// Stamp binds the absolute path of the form data file that should be
// used as a stamp source. Only a file uploaded with the "stamp"
// field name will be included.
func (form *FormData) Stamp(target *string) *FormData {
if form.errors != nil {
return form
}
if paths, ok := form.filesByField[StampFormField]; ok && len(paths) > 0 {
*target = paths[0]
}
return form
}
// paths bind the absolute paths of form data files, according to a list of
// file extensions, to a string slice variable.
// embeds are excluded.
// embeds, watermark, and stamp files are excluded.
func (form *FormData) paths(extensions []string, target *[]string) *FormData {
embeds, ok := form.filesByField[EmbedsFormField]
watermarks, wmOk := form.filesByField[WatermarkFormField]
stamps, stOk := form.filesByField[StampFormField]
for filename, path := range form.files {
if ok && slices.Contains(embeds, path) {
continue
}
if wmOk && slices.Contains(watermarks, path) {
continue
}
if stOk && slices.Contains(stamps, path) {
continue
}
for _, ext := range extensions {
// See https://github.com/gotenberg/gotenberg/issues/228.
if strings.ToLower(filepath.Ext(filename)) == ext {
@@ -439,7 +485,7 @@ func (form *FormData) append(err error) {
// mustValue binds the target interface with a form field. If the value is
// empty or the "key" does not exist, it binds the default value. Currently,
// only the string, bool, int, float64 and time.Duration types are bindable.
func (form *FormData) mustValue(key string, target interface{}, defaultValue interface{}) *FormData {
func (form *FormData) mustValue(key string, target any, defaultValue any) *FormData {
val, ok := form.values[key]
if !ok || val[0] == "" {
@@ -468,7 +514,7 @@ func (form *FormData) mustValue(key string, target interface{}, defaultValue int
// populates an error if the value is empty or the "key" does not exist.
// Currently, only the string, bool, int, float64 and time.Duration types are
// bindable.
func (form *FormData) mustMandatoryField(key string, target interface{}) *FormData {
func (form *FormData) mustMandatoryField(key string, target any) *FormData {
val, ok := form.values[key]
if !ok || val[0] == "" {
@@ -487,7 +533,7 @@ func (form *FormData) mustMandatoryField(key string, target interface{}) *FormDa
// mustAssign parses the string value and tries to convert it to the target
// interface real type. Currently, only the string, bool, int, float64 and
// time.Duration types are bindable.
func (form *FormData) mustAssign(key, value string, target interface{}) *FormData {
func (form *FormData) mustAssign(key, value string, target any) *FormData {
var err error
switch t := (target).(type) {

View File

@@ -61,6 +61,14 @@ func ParseError(err error) (int, string) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested metadata, while others may have failed to convert due to different issues"
}
if errors.Is(err, gotenberg.ErrPdfStampSourceNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues"
}
if errors.Is(err, gotenberg.ErrPdfRotateAngleNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
}
var invalidArgsError *gotenberg.PdfEngineInvalidArgsError
if errors.As(err, &invalidArgsError) {
return http.StatusBadRequest, invalidArgsError.Error()

View File

@@ -288,8 +288,9 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(logger, options.PrintBackground),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
// PDF specific.
printToPdfActionFunc(logger, outputPath, options),
@@ -313,8 +314,9 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(logger, true),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
waitForExpressionBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitForExpression),
waitForSelectorVisibleBeforePrintActionFunc(logger, options.WaitForSelector),
waitDelayBeforePrintActionFunc(logger, b.arguments.disableJavaScript, options.WaitDelay),
// Screenshot specific.
setDeviceMetricsOverride(logger, options.Width, options.Height),
@@ -376,6 +378,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
invalidHttpStatusCode: &invalidHttpStatusCode,
invalidHttpStatusCodeMu: &invalidHttpStatusCodeMu,
failOnResourceOnHttpStatusCode: options.FailOnResourceHttpStatusCodes,
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
})

View File

@@ -33,6 +33,10 @@ var (
// returns an exception or undefined.
ErrInvalidEvaluationExpression = errors.New("invalid evaluation expression")
// ErrInvalidSelectorQuery happens if a selector query returns an exception
// or undefined.
ErrInvalidSelectorQuery = errors.New("invalid selector query")
// ErrRpccMessageTooLarge happens when the messages received by
// ChromeDevTools are larger than 100 MB.
ErrRpccMessageTooLarge = errors.New("rpcc message too large")
@@ -82,9 +86,10 @@ var (
// Chromium is a module that provides both an [Api] and routes for converting
// an HTML document to PDF.
type Chromium struct {
autoStart bool
disableRoutes bool
args browserArguments
autoStart bool
disableRoutes bool
maxConcurrency int64
args browserArguments
logger *zap.Logger
browser browser
@@ -108,6 +113,20 @@ type Options struct {
// status code from at least one resource matches with one if its entries.
FailOnResourceHttpStatusCodes []int64
// IgnoreResourceHttpStatusDomains excludes resources whose hostname matches
// one of these domains from the application of
// [Options.FailOnResourceHttpStatusCodes].
//
// A match happens if the hostname equals the domain or is a subdomain of it
// (e.g., "browser.sentry-cdn.com" matches "sentry-cdn.com").
//
// Values are normalized (trimmed, lowercased) and may be provided as:
// - "example.com"
// - "*.example.com" or ".example.com"
// - "example.com:443" (port is ignored)
// - "https://example.com/path" (scheme/path are ignored)
IgnoreResourceHttpStatusDomains []string
// FailOnResourceLoadingFailed sets if the conversion should fail like the
// main page if Chromium fails to load at least one resource.
FailOnResourceLoadingFailed bool
@@ -128,6 +147,10 @@ type Options struct {
// converting an HTML document until it returns true
WaitForExpression string
// WaitForSelector is the element query to wait until visible before
// converting an HTML document.
WaitForSelector string
// Cookies are the cookies to put in the Chromium cookies' jar.
Cookies []Cookie
@@ -142,27 +165,47 @@ type Options struct {
// "print".
EmulatedMediaType string
// EmulatedMediaFeatures are the media features to emulate, e.g.,
// [{"name": "prefers-color-scheme", "value": "dark"}].
EmulatedMediaFeatures []EmulatedMediaFeature
// OmitBackground hides the default white background and allows generating
// PDFs with transparency.
OmitBackground bool
}
// EmulatedMediaFeature gathers the available entries for emulating a media
// feature.
type EmulatedMediaFeature struct {
// Name is the media feature name (e.g., "prefers-color-scheme",
// "prefers-reduced-motion").
// Required.
Name string `json:"name"`
// Value is the media feature value (e.g., "dark", "reduce").
// Required.
Value string `json:"value"`
}
// DefaultOptions returns the default values for Options.
func DefaultOptions() Options {
return Options{
SkipNetworkIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599},
FailOnResourceHttpStatusCodes: nil,
FailOnResourceLoadingFailed: false,
FailOnConsoleExceptions: false,
WaitDelay: 0,
WaitWindowStatus: "",
WaitForExpression: "",
Cookies: nil,
UserAgent: "",
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
OmitBackground: false,
SkipNetworkIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599},
FailOnResourceHttpStatusCodes: nil,
IgnoreResourceHttpStatusDomains: nil,
FailOnResourceLoadingFailed: false,
FailOnConsoleExceptions: false,
WaitDelay: 0,
WaitWindowStatus: "",
WaitForExpression: "",
WaitForSelector: "",
Cookies: nil,
UserAgent: "",
ExtraHttpHeaders: nil,
EmulatedMediaType: "",
EmulatedMediaFeatures: nil,
OmitBackground: false,
}
}
@@ -367,8 +410,9 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
ID: "chromium",
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
fs.Int64("chromium-restart-after", 10, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
fs.Int64("chromium-restart-after", 100, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
fs.Int64("chromium-max-concurrency", 6, "Maximum number of concurrent conversions. Chromium supports up to 6")
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
fs.Bool("chromium-allow-insecure-localhost", false, "Ignore TLS/SSL errors on localhost")
@@ -402,6 +446,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.autoStart = flags.MustBool("chromium-auto-start")
mod.disableRoutes = flags.MustBool("chromium-disable-routes")
mod.maxConcurrency = flags.MustInt64("chromium-max-concurrency")
binPath, ok := os.LookupEnv("CHROMIUM_BIN_PATH")
if !ok {
@@ -444,7 +489,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
// Process.
mod.browser = newChromiumBrowser(mod.args)
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"))
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency)
// PDF Engine.
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
@@ -462,6 +507,10 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
// Validate validates the module properties.
func (mod *Chromium) Validate() error {
if mod.maxConcurrency < 1 || mod.maxConcurrency > 6 {
return fmt.Errorf("chromium-max-concurrency must be between 1 and 6, got %d", mod.maxConcurrency)
}
_, err := os.Stat(mod.args.binPath)
if os.IsNotExist(err) {
return fmt.Errorf("chromium binary path does not exist: %w", err)
@@ -516,8 +565,8 @@ func (mod *Chromium) Stop(ctx context.Context) error {
}
// Debug returns additional debug data.
func (mod *Chromium) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (mod *Chromium) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

View File

@@ -21,7 +21,7 @@ func (debug *debugLogger) Write(p []byte) (n int, err error) {
}
// Printf logs a debug message.
func (debug *debugLogger) Printf(format string, v ...interface{}) {
func (debug *debugLogger) Printf(format string, v ...any) {
debug.logger.Debug(fmt.Sprintf(format, v...))
}

View File

@@ -4,7 +4,9 @@ import (
"context"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"sync"
"github.com/chromedp/cdproto/cdp"
@@ -37,7 +39,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
}
chromedp.ListenTarget(ctx, func(ev interface{}) {
chromedp.ListenTarget(ctx, func(ev any) {
switch e := ev.(type) {
case *fetch.EventRequestPaused:
go func() {
@@ -143,6 +145,7 @@ type eventResponseReceivedOptions struct {
invalidHttpStatusCode *error
invalidHttpStatusCodeMu *sync.RWMutex
failOnResourceOnHttpStatusCode []int64
ignoreResourceHttpStatusDomains []string
invalidResourceHttpStatusCode *error
invalidResourceHttpStatusCodeMu *sync.RWMutex
}
@@ -157,6 +160,8 @@ func listenForEventResponseReceived(
logger *zap.Logger,
options eventResponseReceivedOptions,
) {
normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains)
for _, code := range []int64{199, 299, 399, 499, 599} {
if slices.Contains(options.failOnHttpStatusCodes, code) {
for i := code - 99; i <= code; i++ {
@@ -171,7 +176,7 @@ func listenForEventResponseReceived(
}
}
chromedp.ListenTarget(ctx, func(ev interface{}) {
chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) {
case *network.EventResponseReceived:
if ev.Response.URL == options.mainPageUrl {
@@ -190,6 +195,11 @@ func listenForEventResponseReceived(
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) {
logger.Debug(fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL))
return
}
options.invalidResourceHttpStatusCodeMu.Lock()
defer options.invalidResourceHttpStatusCodeMu.Unlock()
@@ -202,6 +212,79 @@ func listenForEventResponseReceived(
})
}
func shouldCheckResourceHttpStatusCode(rawURL string, ignoreDomains []string) bool {
host := hostnameFromURL(rawURL)
if len(ignoreDomains) > 0 && matchesAnyDomain(host, ignoreDomains) {
return false
}
return true
}
func hostnameFromURL(rawURL string) string {
u, err := url.Parse(rawURL)
if err != nil {
return ""
}
return strings.ToLower(u.Hostname())
}
func normalizeDomains(domains []string) []string {
normalized := make([]string, 0, len(domains))
for _, domain := range domains {
d := normalizeDomain(domain)
if d == "" {
continue
}
normalized = append(normalized, d)
}
return normalized
}
func normalizeDomain(domain string) string {
d := strings.ToLower(strings.TrimSpace(domain))
if d == "" {
return ""
}
// Accept "example.com", "*.example.com", ".example.com", "https://example.com/path",
// or "example.com:443".
if strings.Contains(d, "://") || strings.HasPrefix(d, "//") {
u, err := url.Parse(d)
if err == nil && u.Hostname() != "" {
d = strings.ToLower(u.Hostname())
}
} else {
// Make it parseable as a URL to extract the hostname and drop any port/path.
u, err := url.Parse("https://" + d)
if err == nil && u.Hostname() != "" {
d = strings.ToLower(u.Hostname())
}
}
d = strings.TrimPrefix(d, "*.")
d = strings.TrimPrefix(d, ".")
return d
}
func matchesAnyDomain(host string, domains []string) bool {
if host == "" || len(domains) == 0 {
return false
}
for _, domain := range domains {
if host == domain || strings.HasSuffix(host, "."+domain) {
return true
}
}
return false
}
type eventLoadingFailedOptions struct {
loadingFailed *error
loadingFailedMu *sync.RWMutex
@@ -216,7 +299,7 @@ type eventLoadingFailedOptions struct {
// https://github.com/gotenberg/gotenberg/issues/959.
// https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) {
case *network.EventLoadingFailed:
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
@@ -272,7 +355,7 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
// appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262.
func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
chromedp.ListenTarget(ctx, func(ev interface{}) {
chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) {
case *runtime.EventExceptionThrown:
logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
@@ -291,7 +374,7 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) {
chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) {
case *page.EventDomContentEventFired:
cancel()
@@ -315,7 +398,7 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func()
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) {
chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) {
case *page.EventLoadEventFired:
cancel()
@@ -339,7 +422,7 @@ func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() err
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) {
chromedp.ListenTarget(cctx, func(ev any) {
switch e := ev.(type) {
case *page.EventLifecycleEvent:
if e.Name == "networkIdle" {
@@ -365,7 +448,7 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev interface{}) {
chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) {
case *network.EventLoadingFinished:
cancel()

View File

@@ -0,0 +1,63 @@
package chromium
import "testing"
func TestNormalizeDomain(t *testing.T) {
tests := []struct {
name string
in string
want string
}{
{name: "empty", in: "", want: ""},
{name: "spaces", in: " ", want: ""},
{name: "simple", in: "example.com", want: "example.com"},
{name: "mixed case", in: "ExAmPlE.Com", want: "example.com"},
{name: "leading wildcard", in: "*.example.com", want: "example.com"},
{name: "leading dot", in: ".example.com", want: "example.com"},
{name: "with scheme and path", in: "https://example.com/foo/bar", want: "example.com"},
{name: "with port", in: "example.com:443", want: "example.com"},
{name: "with scheme and port", in: "https://example.com:443/foo", want: "example.com"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := normalizeDomain(tt.in); got != tt.want {
t.Fatalf("normalizeDomain(%q) = %q, want %q", tt.in, got, tt.want)
}
})
}
}
func TestMatchesAnyDomain(t *testing.T) {
host := "browser.sentry-cdn.com"
if !matchesAnyDomain(host, []string{"sentry-cdn.com"}) {
t.Fatalf("expected %q to match %q", host, "sentry-cdn.com")
}
if matchesAnyDomain("not-sentry-cdn.com", []string{"sentry-cdn.com"}) {
t.Fatalf("expected %q to not match %q", "not-sentry-cdn.com", "sentry-cdn.com")
}
}
func TestShouldCheckResourceHttpStatusCode_IgnoreDomains(t *testing.T) {
ignore := normalizeDomains([]string{"sentry.io"})
if shouldCheckResourceHttpStatusCode("https://sentry.io/api/123", ignore) {
t.Fatalf("expected ignored domain to be skipped")
}
if shouldCheckResourceHttpStatusCode("https://sub.sentry.io/api/123", ignore) {
t.Fatalf("expected ignored subdomain to be skipped")
}
if !shouldCheckResourceHttpStatusCode("https://other.com/api/123", ignore) {
t.Fatalf("expected non-ignored domain to be checked")
}
}
func TestShouldCheckResourceHttpStatusCode_NonHTTPURL(t *testing.T) {
if !shouldCheckResourceHttpStatusCode("data:text/plain,hello", nil) {
t.Fatalf("expected data: URL to be checked (no host filtering possible)")
}
}

View File

@@ -29,25 +29,40 @@ var sameSiteRegexp = regexp2.MustCompile(
regexp2.None,
)
// FormDataChromiumOptions creates [Options] from the form data. Fallback to
// the default value if the considered key is not present.
// FormDataChromiumOptions creates [Options] from the form data.
//
// It falls back to the default value if the considered key is not present.
//
// JSON-encoded fields:
// - failOnHttpStatusCodes: []int
// - failOnResourceHttpStatusCodes: []int
// - ignoreResourceHttpStatusDomains: []string
// - cookies: []Cookie
// - extraHttpHeaders: map[string]string
// - emulatedMediaFeatures: map[string]string
//
// Domain filtering only applies to resource checks triggered by
// "failOnResourceHttpStatusCodes".
func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
defaultOptions := DefaultOptions()
var (
skipNetworkIdleEvent bool
failOnHttpStatusCodes []int64
failOnResourceHttpStatusCodes []int64
failOnResourceLoadingFailed bool
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
cookies []Cookie
userAgent string
extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string
omitBackground bool
skipNetworkIdleEvent bool
failOnHttpStatusCodes []int64
failOnResourceHttpStatusCodes []int64
ignoreResourceHttpStatusDomains []string
failOnResourceLoadingFailed bool
failOnConsoleExceptions bool
waitDelay time.Duration
waitWindowStatus string
waitForExpression string
waitForSelector string
cookies []Cookie
userAgent string
extraHttpHeaders []ExtraHttpHeader
emulatedMediaType string
emulatedMediaFeatures []EmulatedMediaFeature
omitBackground bool
)
form := ctx.FormData().
@@ -78,11 +93,25 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil
}).
Custom("ignoreResourceHttpStatusDomains", func(value string) error {
if value == "" {
ignoreResourceHttpStatusDomains = defaultOptions.IgnoreResourceHttpStatusDomains
return nil
}
err := json.Unmarshal([]byte(value), &ignoreResourceHttpStatusDomains)
if err != nil {
return fmt.Errorf("unmarshal ignoreResourceHttpStatusDomains: %w", err)
}
return nil
}).
Bool("failOnResourceLoadingFailed", &failOnResourceLoadingFailed, defaultOptions.FailOnResourceLoadingFailed).
Bool("failOnConsoleExceptions", &failOnConsoleExceptions, defaultOptions.FailOnConsoleExceptions).
Duration("waitDelay", &waitDelay, defaultOptions.WaitDelay).
String("waitWindowStatus", &waitWindowStatus, defaultOptions.WaitWindowStatus).
String("waitForExpression", &waitForExpression, defaultOptions.WaitForExpression).
String("waitForSelector", &waitForSelector, defaultOptions.WaitForSelector).
Custom("cookies", func(value string) error {
if value == "" {
cookies = defaultOptions.Cookies
@@ -143,8 +172,8 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
var valueTokens []string
var invalidScopeToken bool
tokens := strings.Split(v, ";")
for _, token := range tokens {
tokens := strings.SplitSeq(v, ";")
for token := range tokens {
if strings.HasPrefix(strings.ToLower(strings.TrimSpace(token)), "scope") {
tokenNoSpaces := strings.Join(strings.Fields(token), "")
parts := strings.SplitN(tokenNoSpaces, "=", 2)
@@ -200,22 +229,46 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
return nil
}).
Custom("emulatedMediaFeatures", func(value string) error {
if value == "" {
emulatedMediaFeatures = defaultOptions.EmulatedMediaFeatures
return nil
}
var features map[string]string
err := json.Unmarshal([]byte(value), &features)
if err != nil {
return fmt.Errorf("unmarshal emulatedMediaFeatures: %w", err)
}
for k, v := range features {
emulatedMediaFeatures = append(emulatedMediaFeatures, EmulatedMediaFeature{
Name: k,
Value: v,
})
}
return err
}).
Bool("omitBackground", &omitBackground, defaultOptions.OmitBackground)
options := Options{
SkipNetworkIdleEvent: skipNetworkIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
Cookies: cookies,
UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
OmitBackground: omitBackground,
SkipNetworkIdleEvent: skipNetworkIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
IgnoreResourceHttpStatusDomains: ignoreResourceHttpStatusDomains,
FailOnResourceLoadingFailed: failOnResourceLoadingFailed,
FailOnConsoleExceptions: failOnConsoleExceptions,
WaitDelay: waitDelay,
WaitWindowStatus: waitWindowStatus,
WaitForExpression: waitForExpression,
WaitForSelector: waitForSelector,
Cookies: cookies,
UserAgent: userAgent,
ExtraHttpHeaders: extraHttpHeaders,
EmulatedMediaType: emulatedMediaType,
EmulatedMediaFeatures: emulatedMediaFeatures,
OmitBackground: omitBackground,
}
return form, options
@@ -362,6 +415,11 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
var url string
err := form.
@@ -371,7 +429,14 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err)
}
@@ -425,6 +490,11 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
var inputPath string
err := form.
@@ -434,8 +504,15 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
url := fmt.Sprintf("file://%s", inputPath)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
}
@@ -490,6 +567,11 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
var (
inputPath string
@@ -504,12 +586,19 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
url, err := markdownToHtml(ctx, inputPath, markdownPaths)
if err != nil {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
}
@@ -633,7 +722,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil
}
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}, userPassword, ownerPassword string, embedPaths []string) error {
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error {
outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath)
@@ -695,26 +784,48 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("convert to PDF: %w", err)
}
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
if err != nil {
return err
}
outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
if err != nil {
return fmt.Errorf("split PDF: %w", err)
}
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
err = pdfengines.RotateStub(ctx, engine, rotateAngle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF(s): %w", err)
}
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
// Metadata, embeds are written after Convert, as LibreOffice
// strips them during PDF/A conversion.
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
@@ -783,6 +894,22 @@ func handleChromiumError(err error, options Options) error {
)
}
if errors.Is(err, ErrInvalidSelectorQuery) {
if options.WaitForSelector == "" {
// We only expect to see this error if the user specified a selector.
// If they didn't and we still generated the error, return a 500.
return err
}
return api.WrapError(
err,
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("The selector '%s' (waitForSelector) returned an exception or undefined", options.WaitForSelector),
),
)
}
if errors.Is(err, ErrInvalidHttpStatusCode) {
return api.WrapError(
err,

View File

@@ -39,11 +39,9 @@ func (reader *streamReader) Read(p []byte) (n int, err error) {
// Chromium might have an off-by-one when deciding the maximum size (at
// least for base64 encoded data), usually it will overflow. We subtract
// one to make sure it fits into p.
size := len(p) - 1
if size < 1 {
size := max(len(p)-1,
// Safety-check to avoid crashing Chrome (e.g. via SetSize(-1)).
size = 1
}
1)
reply, err := reader.next(reader.pos, size)
if err != nil {

View File

@@ -423,26 +423,44 @@ func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chrome
}
}
func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string) chromedp.ActionFunc {
func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatures []EmulatedMediaFeature) chromedp.ActionFunc {
return func(ctx context.Context) error {
if mediaType == "" {
logger.Debug("no emulated media type")
if mediaType == "" && len(mediaFeatures) == 0 {
logger.Debug("no emulated media type or features")
return nil
}
if mediaType != "screen" && mediaType != "print" {
if mediaType != "" && mediaType != "screen" && mediaType != "print" {
return fmt.Errorf("validate emulated media type '%s': %w", mediaType, ErrInvalidEmulatedMediaType)
}
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
emulatedMedia := emulation.SetEmulatedMedia()
err := emulatedMedia.WithMedia(mediaType).Do(ctx)
if mediaType != "" {
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
emulatedMedia = emulatedMedia.WithMedia(mediaType)
}
if len(mediaFeatures) > 0 {
logger.Debug(fmt.Sprintf("emulate media features %+v", mediaFeatures))
features := make([]*emulation.MediaFeature, len(mediaFeatures))
for i, f := range mediaFeatures {
features[i] = &emulation.MediaFeature{
Name: f.Name,
Value: f.Value,
}
}
emulatedMedia = emulatedMedia.WithFeatures(features)
}
err := emulatedMedia.Do(ctx)
if err == nil {
return nil
}
return fmt.Errorf("emulate media type '%s': %w", mediaType, err)
return fmt.Errorf("emulate media: %w", err)
}
}
@@ -512,3 +530,19 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
}
}
}
func waitForSelectorVisibleBeforePrintActionFunc(logger *zap.Logger, selector string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if selector == "" {
logger.Debug("no wait selector")
return nil
}
logger.Debug(fmt.Sprintf("wait until '%s' is visible before print", selector))
err := chromedp.WaitVisible(selector, chromedp.ByQuery, chromedp.RetryInterval(time.Duration(100)*time.Millisecond)).Do(ctx)
if err != nil {
return fmt.Errorf("wait visible: %v: %w", err, ErrInvalidSelectorQuery)
}
return nil
}
}

View File

@@ -57,8 +57,8 @@ func (engine *ExifTool) Validate() error {
}
// Debug returns additional debug data.
func (engine *ExifTool) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (engine *ExifTool) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -94,7 +94,7 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats
}
// ReadMetadata extracts the metadata of a given PDF file.
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil {
return nil, fmt.Errorf("new ExifTool: %w", err)
@@ -116,7 +116,7 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, in
}
// WriteMetadata writes the metadata into a given PDF file.
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil {
return fmt.Errorf("new ExifTool: %w", err)
@@ -134,13 +134,40 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
return fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
}
// Define a list of derived, system, or computed tags that ExifTool
// extracts but should never be written back. Writing these can break PDF/A
// compliance (e.g., PageCount -> prism:pageCount) or cause side effects
// (e.g., FileModifyDate).
derivedTags := []string{
"PageCount", // Causes prism:pageCount injection
"Linearized", // Computed status; writing it may invalidate structure
"PDFVersion", // Header version; should not be manually forced via metadata
"MIMEType", // Read-only derived
"FileType", // Read-only derived
"FileTypeExtension", // Read-only derived
"FileSize", // System attribute
"FileModifyDate", // System attribute
"FileAccessDate", // System attribute
"FileInodeChangeDate", // System attribute
"FilePermissions", // System attribute
"FileName", // Writing this triggers a file rename in ExifTool
"Directory", // System attribute
"ExifToolVersion", // Tool metadata
"Error", // Extraction error messages
"Warning", // Extraction warning messages
}
for _, tag := range derivedTags {
delete(fileMetadata[0].Fields, tag)
}
for key, value := range metadata {
switch val := value.(type) {
case string:
fileMetadata[0].SetString(key, val)
case []string:
fileMetadata[0].SetStrings(key, val)
case []interface{}:
case []any:
// See https://github.com/gotenberg/gotenberg/issues/1048.
strs := make([]string, len(val))
for i, entry := range val {
@@ -148,7 +175,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
strs[i] = str
continue
}
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
fileMetadata[0].SetStrings(key, strs)
case bool:
@@ -176,6 +203,47 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
return nil
}
// PageCount returns the number of pages in a PDF file using ExifTool.
func (engine *ExifTool) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
if err != nil {
return 0, fmt.Errorf("read metadata with ExifTool: %w", err)
}
pageCountValue, ok := metadata["PageCount"]
if !ok {
return 0, errors.New("PageCount not found in metadata")
}
switch val := pageCountValue.(type) {
case int:
return val, nil
case int64:
return int(val), nil
case float64:
return int(val), nil
case string:
var res int
_, err := fmt.Sscanf(val, "%d", &res)
if err != nil {
return 0, fmt.Errorf("parse PageCount string '%s': %w", val, err)
}
return res, nil
default:
return 0, fmt.Errorf("unexpected PageCount type '%T'", pageCountValue)
}
}
// WriteBookmarks is not available in this implementation.
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return fmt.Errorf("write PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadBookmarks is not available in this implementation.
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return nil, fmt.Errorf("read PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Encrypt is not available in this implementation.
func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
@@ -186,6 +254,21 @@ func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *zap.Logger, file
return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Watermark is not available in this implementation.
func (engine *ExifTool) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("watermark PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Stamp is not available in this implementation.
func (engine *ExifTool) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("stamp PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Rotate is not available in this implementation.
func (engine *ExifTool) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
return fmt.Errorf("rotate PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards.
var (
_ gotenberg.Module = (*ExifTool)(nil)

View File

@@ -54,7 +54,7 @@ type Api struct {
// See: https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
type Options struct {
// Password specifies the password for opening the source file.
Password string
Password string // #nosec
// Landscape allows changing the orientation of the resulting PDF.
Landscape bool
@@ -147,6 +147,29 @@ type Options struct {
// Possible values are: 75, 150, 300, 600 and 1200.
MaxImageResolution int
// NativeWatermarkText specifies the text for a watermark to be drawn on
// every page of the exported PDF file.
// See https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
NativeWatermarkText string
// NativeWatermarkColor specifies the color for the watermark text as a
// decimal long value. Default is 8388223 (light green).
NativeWatermarkColor int
// NativeWatermarkFontHeight specifies the font size for the watermark text.
NativeWatermarkFontHeight int
// NativeWatermarkRotateAngle specifies the rotation angle for the watermark
// text in tenths of a degree (e.g., 450 = 45°).
NativeWatermarkRotateAngle int
// NativeWatermarkFontName specifies the font name for the watermark text.
// Default is "Helvetica".
NativeWatermarkFontName string
// NativeTiledWatermarkText specifies the tiled watermark text.
NativeTiledWatermarkText string
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA.
PdfFormats gotenberg.PdfFormats
@@ -178,6 +201,12 @@ func DefaultOptions() Options {
Quality: 90,
ReduceImageResolution: false,
MaxImageResolution: 300,
NativeWatermarkText: "",
NativeWatermarkColor: 8388223,
NativeWatermarkFontHeight: 0,
NativeWatermarkRotateAngle: 0,
NativeWatermarkFontName: "Helvetica",
NativeTiledWatermarkText: "",
PdfFormats: gotenberg.PdfFormats{
PdfA: "",
PdfUa: false,
@@ -253,7 +282,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
// Process.
a.libreOffice = newLibreOfficeProcess(a.args)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"))
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1)
return nil
}
@@ -316,8 +345,8 @@ func (a *Api) Stop(ctx context.Context) error {
}
// Debug returns additional debug data.
func (a *Api) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (a *Api) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}

View File

@@ -302,14 +302,38 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution))
args = append(args, "--export", fmt.Sprintf("MaxImageResolution=%d", options.MaxImageResolution))
if options.NativeWatermarkText != "" {
args = append(args, "--export", fmt.Sprintf("Watermark=%s", options.NativeWatermarkText))
}
if options.NativeWatermarkColor != 0 {
args = append(args, "--export", fmt.Sprintf("WatermarkColor=%d", options.NativeWatermarkColor))
}
if options.NativeWatermarkFontHeight > 0 {
args = append(args, "--export", fmt.Sprintf("WatermarkFontHeight=%d", options.NativeWatermarkFontHeight))
}
if options.NativeWatermarkRotateAngle != 0 {
args = append(args, "--export", fmt.Sprintf("WatermarkRotateAngle=%d", options.NativeWatermarkRotateAngle))
}
if options.NativeWatermarkFontName != "" && options.NativeWatermarkFontName != "Helvetica" {
args = append(args, "--export", fmt.Sprintf("WatermarkFontName=%s", options.NativeWatermarkFontName))
}
if options.NativeTiledWatermarkText != "" {
args = append(args, "--export", fmt.Sprintf("TiledWatermark=%s", options.NativeTiledWatermarkText))
}
switch options.PdfFormats.PdfA {
case "":
case gotenberg.PdfA1b:
args = append(args, "--export", "SelectPdfVersion=1")
args = append(args, "--export", "SelectPdfVersion=1", "--export", "EmbedStandardFonts=true")
case gotenberg.PdfA2b:
args = append(args, "--export", "SelectPdfVersion=2")
args = append(args, "--export", "SelectPdfVersion=2", "--export", "EmbedStandardFonts=true")
case gotenberg.PdfA3b:
args = append(args, "--export", "SelectPdfVersion=3")
args = append(args, "--export", "SelectPdfVersion=3", "--export", "EmbedStandardFonts=true")
default:
return ErrInvalidPdfFormats
}
@@ -320,6 +344,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
"--export", "PDFUACompliance=true",
"--export", "UseTaggedPDF=true",
"--export", "EnableTextAccessForAccessibilityTools=true",
"--export", "EmbedStandardFonts=true",
)
} else {
args = append(

View File

@@ -82,15 +82,30 @@ func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Log
}
// ReadMetadata is not available in this implementation.
func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteMetadata is not available in this implementation.
func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// PageCount is not available in this implementation.
func (engine *LibreOfficePdfEngine) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
return 0, fmt.Errorf("page count with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteBookmarks is not available in this implementation.
func (engine *LibreOfficePdfEngine) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return fmt.Errorf("write PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadBookmarks is not available in this implementation.
func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return nil, fmt.Errorf("read PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Encrypt is not available in this implementation.
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
@@ -101,6 +116,21 @@ func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *zap.
return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Watermark is not available in this implementation.
func (engine *LibreOfficePdfEngine) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("watermark PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Stamp is not available in this implementation.
func (engine *LibreOfficePdfEngine) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("stamp PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Rotate is not available in this implementation.
func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Interface guards.
var (
_ gotenberg.Module = (*LibreOfficePdfEngine)(nil)

View File

@@ -32,6 +32,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
zeroValuedSplitMode := gotenberg.SplitMode{}
@@ -60,6 +65,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
quality int
reduceImageResolution bool
maxImageResolution int
nativeWatermarkText string
nativeWatermarkColor int
nativeWatermarkFontHeight int
nativeWatermarkRotateAngle int
nativeWatermarkFontName string
nativeTiledWatermarkText string
nativePdfFormats bool
merge bool
flatten bool
@@ -128,6 +139,48 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
maxImageResolution = intValue
return nil
}).
String("nativeWatermarkText", &nativeWatermarkText, defaultOptions.NativeWatermarkText).
Custom("nativeWatermarkColor", func(value string) error {
if value == "" {
nativeWatermarkColor = defaultOptions.NativeWatermarkColor
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
nativeWatermarkColor = intValue
return nil
}).
Custom("nativeWatermarkFontHeight", func(value string) error {
if value == "" {
nativeWatermarkFontHeight = defaultOptions.NativeWatermarkFontHeight
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 0 {
return errors.New("value is inferior to 0")
}
nativeWatermarkFontHeight = intValue
return nil
}).
Custom("nativeWatermarkRotateAngle", func(value string) error {
if value == "" {
nativeWatermarkRotateAngle = defaultOptions.NativeWatermarkRotateAngle
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
nativeWatermarkRotateAngle = intValue
return nil
}).
String("nativeWatermarkFontName", &nativeWatermarkFontName, defaultOptions.NativeWatermarkFontName).
String("nativeTiledWatermarkText", &nativeTiledWatermarkText, defaultOptions.NativeTiledWatermarkText).
Bool("nativePdfFormats", &nativePdfFormats, true).
Bool("merge", &merge, false).
Bool("flatten", &flatten, false).
@@ -136,6 +189,21 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
if err != nil {
return err
}
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
len(embedPaths) > 0 || len(metadata) > 0 || flatten
outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf")
@@ -163,11 +231,18 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
Quality: quality,
ReduceImageResolution: reduceImageResolution,
MaxImageResolution: maxImageResolution,
NativeWatermarkText: nativeWatermarkText,
NativeWatermarkColor: nativeWatermarkColor,
NativeWatermarkFontHeight: nativeWatermarkFontHeight,
NativeWatermarkRotateAngle: nativeWatermarkRotateAngle,
NativeWatermarkFontName: nativeWatermarkFontName,
NativeTiledWatermarkText: nativeTiledWatermarkText,
}
if nativePdfFormats && splitMode == zeroValuedSplitMode {
if nativePdfFormats && splitMode == zeroValuedSplitMode && !hasPostProcessing {
// Only natively apply given PDF formats if we're not
// splitting the PDF later.
// splitting the PDF later and no post-processing features
// are enabled (as they would degrade compliance).
options.PdfFormats = pdfFormats
}
@@ -233,7 +308,32 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
}
}
if !nativePdfFormats || (nativePdfFormats && splitMode != zeroValuedSplitMode) {
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
err = pdfengines.RotateStub(ctx, engine, angle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
if flatten {
err = pdfengines.FlattenStub(ctx, engine, outputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
}
needsConvertStub := !nativePdfFormats ||
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
(nativePdfFormats && hasPostProcessing)
if needsConvertStub {
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
@@ -253,21 +353,16 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
}
}
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
// Metadata, embeds are written after Convert, as LibreOffice
// strips them during PDF/A conversion.
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
if flatten {
err = pdfengines.FlattenStub(ctx, engine, outputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)

View File

@@ -177,7 +177,7 @@ func newLogLevel(level string) (zapcore.Level, error) {
}
func newLogEncoder(format string, gcpFields bool) (zapcore.Encoder, error) {
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
isTerminal := term.IsTerminal(int(os.Stdout.Fd())) // #nosec
encCfg := zap.NewProductionEncoderConfig()
// Normalize the log format based on the output device.

View File

@@ -1,13 +1,16 @@
package pdfcpu
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strconv"
"strings"
"syscall"
@@ -26,6 +29,16 @@ type PdfCpu struct {
binPath string
}
type pdfcpuBookmark struct {
Title string `json:"title"`
Page int `json:"page"`
Children []pdfcpuBookmark `json:"kids,omitempty"`
}
type pdfcpuBookmarks struct {
Bookmarks []pdfcpuBookmark `json:"bookmarks"`
}
// Descriptor returns a [PdfCpu]'s module descriptor.
func (engine *PdfCpu) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{
@@ -57,8 +70,8 @@ func (engine *PdfCpu) Validate() error {
}
// Debug returns additional debug data.
func (engine *PdfCpu) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (engine *PdfCpu) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -71,10 +84,10 @@ func (engine *PdfCpu) Debug() map[string]interface{} {
debug["version"] = "Unable to determine pdfcpu version"
lines := strings.Split(string(output), "\n")
for _, line := range lines {
if strings.HasPrefix(line, "pdfcpu:") {
debug["version"] = strings.TrimSpace(strings.TrimPrefix(line, "pdfcpu:"))
lines := strings.SplitSeq(string(output), "\n")
for line := range lines {
if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok {
debug["version"] = strings.TrimSpace(after)
break
}
}
@@ -84,7 +97,7 @@ func (engine *PdfCpu) Debug() map[string]interface{} {
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
args := make([]string, 0, 2+len(inputPaths))
args = append(args, "merge", outputPath)
args = append(args, inputPaths...)
@@ -162,15 +175,154 @@ func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats g
}
// ReadMetadata is not available in this implementation.
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteMetadata is not available in this implementation.
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// PageCount is not available in this implementation.
func (engine *PdfCpu) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
return 0, fmt.Errorf("page count with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadBookmarks reads the document outline (bookmarks) of a PDF file using pdfcpu.
func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
tmpPath := fmt.Sprintf("%s.read.json", inputPath)
args := []string{"bookmarks", "export", inputPath, tmpPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
defer func() {
err := os.Remove(tmpPath)
if err != nil && !os.IsNotExist(err) {
logger.Error(fmt.Sprintf("remove temporary bookmarks JSON file: %v", err))
}
}()
_, cmdErr := cmd.Exec()
// Check file existence and size.
info, statErr := os.Stat(tmpPath)
if cmdErr != nil {
// If the file wasn't created, or it was created but is 0 bytes,
// it means pdfcpu had no bookmarks to write.
if os.IsNotExist(statErr) || (statErr == nil && info.Size() == 0) {
return make([]gotenberg.Bookmark, 0), nil
}
// Fallback: Check the error string just in case pdfcpu failed without
// touching the file.
if strings.Contains(strings.ToLower(cmdErr.Error()), "no bookmarks") {
return make([]gotenberg.Bookmark, 0), nil
}
return nil, fmt.Errorf("read bookmarks with pdfcpu: %w", cmdErr)
}
// If cmd succeeded, but output a 0-byte file anyway.
if info != nil && info.Size() == 0 {
return make([]gotenberg.Bookmark, 0), nil
}
// Read the file content.
jsonBytes, err := os.ReadFile(tmpPath)
if err != nil {
return nil, fmt.Errorf("read temporary bookmarks JSON file: %w", err)
}
// Check if the content is just empty whitespace.
if len(bytes.TrimSpace(jsonBytes)) == 0 {
return make([]gotenberg.Bookmark, 0), nil
}
var data pdfcpuBookmarks
err = json.Unmarshal(jsonBytes, &data)
if err != nil {
return nil, fmt.Errorf("unmarshal bookmarks: %w", err)
}
// Safety check: Does the parsed JSON actually contain bookmarks?
if len(data.Bookmarks) == 0 {
return make([]gotenberg.Bookmark, 0), nil
}
var mapBookmarks func(bookmarks []pdfcpuBookmark) []gotenberg.Bookmark
mapBookmarks = func(bookmarks []pdfcpuBookmark) []gotenberg.Bookmark {
res := make([]gotenberg.Bookmark, len(bookmarks))
for i, b := range bookmarks {
res[i] = gotenberg.Bookmark{
Title: b.Title,
Page: b.Page,
Children: mapBookmarks(b.Children),
}
}
return res
}
return mapBookmarks(data.Bookmarks), nil
}
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using pdfcpu.
func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
if len(bookmarks) == 0 {
return nil
}
var mapBookmarks func(bookmarks []gotenberg.Bookmark) []pdfcpuBookmark
mapBookmarks = func(bookmarks []gotenberg.Bookmark) []pdfcpuBookmark {
res := make([]pdfcpuBookmark, len(bookmarks))
for i, b := range bookmarks {
res[i] = pdfcpuBookmark{
Title: b.Title,
Page: b.Page,
Children: mapBookmarks(b.Children),
}
}
return res
}
data := pdfcpuBookmarks{
Bookmarks: mapBookmarks(bookmarks),
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal bookmarks: %w", err)
}
tmpPath := fmt.Sprintf("%s.json", inputPath)
err = os.WriteFile(tmpPath, jsonBytes, 0o600)
if err != nil {
return fmt.Errorf("write temporary bookmarks JSON file: %w", err)
}
defer func() {
err := os.Remove(tmpPath)
if err != nil {
logger.Error(fmt.Sprintf("remove temporary bookmarks JSON file: %v", err))
}
}()
args := []string{"bookmarks", "import", "-replace", inputPath, tmpPath, inputPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("write bookmarks with pdfcpu: %w", err)
}
return nil
}
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
@@ -180,10 +332,8 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePa
logger.Debug(fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths))
args := []string{
"attachments", "add",
inputPath,
}
args := make([]string, 0, 3+len(filePaths))
args = append(args, "attachments", "add", inputPath)
args = append(args, filePaths...)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
@@ -209,7 +359,7 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath
ownerPassword = userPassword
}
var args []string
args := make([]string, 0, 11)
args = append(args, "encrypt")
args = append(args, "-mode", "aes")
args = append(args, "-upw", userPassword)
@@ -230,6 +380,78 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath
return nil
}
// Watermark applies a watermark (behind page content) to a PDF file using pdfcpu.
func (engine *PdfCpu) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return engine.applyStampOrWatermark(ctx, logger, "watermark", inputPath, stamp)
}
// Stamp applies a stamp (on top of page content) to a PDF file using pdfcpu.
func (engine *PdfCpu) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return engine.applyStampOrWatermark(ctx, logger, "stamp", inputPath, stamp)
}
// Rotate rotates pages of a PDF file by the given angle using pdfcpu.
func (engine *PdfCpu) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
args := []string{"rotate"}
if pages != "" {
args = append(args, "-pages", pages)
}
args = append(args, "--", inputPath, strconv.Itoa(angle), inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("rotate PDF with pdfcpu: %w", err)
}
return nil
}
func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *zap.Logger, command string, inputPath string, stamp gotenberg.Stamp) error {
var mode string
switch stamp.Source {
case gotenberg.StampSourceText:
mode = "text"
case gotenberg.StampSourceImage:
mode = "image"
case gotenberg.StampSourcePDF:
mode = "pdf"
default:
return fmt.Errorf("%s PDF with pdfcpu: %w", command, gotenberg.ErrPdfStampSourceNotSupported)
}
// Build description from Options map.
var descParts []string
for k, v := range stamp.Options {
descParts = append(descParts, fmt.Sprintf("%s:%s", k, v))
}
description := strings.Join(descParts, ", ")
args := []string{command, "add", "-mode", mode}
if stamp.Pages != "" {
args = append(args, "-pages", stamp.Pages)
}
args = append(args, "--", stamp.Expression, description, inputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("%s PDF with pdfcpu: %w", command, err)
}
return nil
}
// Interface guards.
var (
_ gotenberg.Module = (*PdfCpu)(nil)

View File

@@ -12,14 +12,19 @@ import (
)
type multiPdfEngines struct {
mergeEngines []gotenberg.PdfEngine
splitEngines []gotenberg.PdfEngine
flattenEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine
readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine
passwordEngines []gotenberg.PdfEngine
embedEngines []gotenberg.PdfEngine
mergeEngines []gotenberg.PdfEngine
splitEngines []gotenberg.PdfEngine
flattenEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine
readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine
passwordEngines []gotenberg.PdfEngine
embedEngines []gotenberg.PdfEngine
readBookmarksEngines []gotenberg.PdfEngine
writeBookmarksEngines []gotenberg.PdfEngine
watermarkEngines []gotenberg.PdfEngine
stampEngines []gotenberg.PdfEngine
rotateEngines []gotenberg.PdfEngine
}
func newMultiPdfEngines(
@@ -30,17 +35,27 @@ func newMultiPdfEngines(
readMetadataEngines,
writeMetadataEngines,
passwordEngines,
embedEngines []gotenberg.PdfEngine,
embedEngines,
readBookmarksEngines,
writeBookmarksEngines,
watermarkEngines,
stampEngines,
rotateEngines []gotenberg.PdfEngine,
) *multiPdfEngines {
return &multiPdfEngines{
mergeEngines: mergeEngines,
splitEngines: splitEngines,
flattenEngines: flattenEngines,
convertEngines: convertEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines,
embedEngines: embedEngines,
mergeEngines: mergeEngines,
splitEngines: splitEngines,
flattenEngines: flattenEngines,
convertEngines: convertEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines,
embedEngines: embedEngines,
readBookmarksEngines: readBookmarksEngines,
writeBookmarksEngines: writeBookmarksEngines,
watermarkEngines: watermarkEngines,
stampEngines: stampEngines,
rotateEngines: rotateEngines,
}
}
@@ -156,13 +171,13 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
}
type readMetadataResult struct {
metadata map[string]interface{}
metadata map[string]any
err error
}
// ReadMetadata extracts metadata from a PDF file using the first available
// engine that supports metadata reading.
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
var err error
var mu sync.Mutex // to safely append errors.
@@ -193,7 +208,7 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
// WriteMetadata embeds metadata into a PDF file using the first available
// engine that supports metadata writing.
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
var err error
errChan := make(chan error, 1)
@@ -216,6 +231,103 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
}
type pageCountResult struct {
pageCount int
err error
}
// PageCount returns the number of pages in a PDF file using the first available
// engine that supports metadata reading.
func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
var err error
var mu sync.Mutex // to safely append errors.
for _, engine := range multi.readMetadataEngines {
resultChan := make(chan pageCountResult, 1)
go func(engine gotenberg.PdfEngine) {
pageCount, err := engine.PageCount(ctx, logger, inputPath)
resultChan <- pageCountResult{pageCount: pageCount, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
mu.Unlock()
} else {
return result.pageCount, nil
}
case <-ctx.Done():
return 0, ctx.Err()
}
}
return 0, fmt.Errorf("page count with multi PDF engines: %w", err)
}
type readBookmarksResult struct {
bookmarks []gotenberg.Bookmark
err error
}
// ReadBookmarks reads bookmarks from a PDF file using the first available
// engine that supports bookmarks reading.
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
var err error
var mu sync.Mutex // to safely append errors.
for _, engine := range multi.readBookmarksEngines {
resultChan := make(chan readBookmarksResult, 1)
go func(engine gotenberg.PdfEngine) {
bookmarks, err := engine.ReadBookmarks(ctx, logger, inputPath)
resultChan <- readBookmarksResult{bookmarks: bookmarks, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
mu.Unlock()
} else {
return result.bookmarks, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err)
}
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
// first available engine that supports bookmarks writing.
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.writeBookmarksEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
}(engine)
select {
case writeBookmarksErr := <-errChan:
errored := multierr.AppendInto(&err, writeBookmarksErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err)
}
// Encrypt adds password protection to a PDF file using the first available
// engine that supports password protection.
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
@@ -266,6 +378,81 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger
return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err)
}
// Watermark applies a watermark (behind page content) to a PDF file using the
// first available engine that supports watermarking.
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.watermarkEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Watermark(ctx, logger, inputPath, stamp)
}(engine)
select {
case watermarkErr := <-errChan:
errored := multierr.AppendInto(&err, watermarkErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("watermark PDF with multi PDF engines: %w", err)
}
// Stamp applies a stamp (on top of page content) to a PDF file using the
// first available engine that supports stamping.
func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.stampEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Stamp(ctx, logger, inputPath, stamp)
}(engine)
select {
case stampErr := <-errChan:
errored := multierr.AppendInto(&err, stampErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("stamp PDF with multi PDF engines: %w", err)
}
// Rotate rotates pages of a PDF file using the first available engine that
// supports rotation.
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.rotateEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Rotate(ctx, logger, inputPath, angle, pages)
}(engine)
select {
case rotateErr := <-errChan:
errored := multierr.AppendInto(&err, rotateErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("rotate PDF with multi PDF engines: %w", err)
}
// Interface guards.
var (
_ gotenberg.PdfEngine = (*multiPdfEngines)(nil)

View File

@@ -1,650 +0,0 @@
package pdfengines
import (
"context"
"errors"
"testing"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestMultiPdfEngines_Merge(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
expectError: false,
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
expectError: false,
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Merge(tc.ctx, zap.NewNop(), nil, "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Encrypt(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Encrypt(tc.ctx, zap.NewNop(), "", "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
_, err := tc.engine.Split(tc.ctx, zap.NewNop(), gotenberg.SplitMode{}, "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Flatten(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Flatten(tc.ctx, zap.NewNop(), "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Convert(tc.ctx, zap.NewNop(), gotenberg.PdfFormats{}, "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
_, err := tc.engine.ReadMetadata(tc.ctx, zap.NewNop(), "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.WriteMetadata(tc.ctx, zap.NewNop(), nil, "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}

View File

@@ -3,6 +3,7 @@ package pdfengines
import (
"errors"
"fmt"
"slices"
"strings"
flag "github.com/spf13/pflag"
@@ -27,16 +28,21 @@ func init() {
// the [api.Router] interface to expose relevant PDF processing routes if
// enabled.
type PdfEngines struct {
mergeNames []string
splitNames []string
flattenNames []string
convertNames []string
readMetadataNames []string
writeMetadataNames []string
encryptNames []string
embedNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
mergeNames []string
splitNames []string
flattenNames []string
convertNames []string
readMetadataNames []string
writeMetadataNames []string
encryptNames []string
embedNames []string
readBookmarksNames []string
writeBookmarksNames []string
watermarkNames []string
stampNames []string
rotateNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
}
// Descriptor returns a PdfEngines' module descriptor.
@@ -53,6 +59,11 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all")
fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all")
fs.StringSlice("pdfengines-read-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the read bookmarks feature - empty means all")
fs.StringSlice("pdfengines-write-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the write bookmarks feature - empty means all")
fs.StringSlice("pdfengines-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all")
fs.StringSlice("pdfengines-stamp-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the stamp feature - empty means all")
fs.StringSlice("pdfengines-rotate-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the rotate feature - empty means all")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
// Deprecated flags.
@@ -80,6 +91,11 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
embedNames := flags.MustStringSlice("pdfengines-embed-engines")
readBookmarksNames := flags.MustStringSlice("pdfengines-read-bookmarks-engines")
writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines")
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -146,6 +162,31 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.embedNames = embedNames
}
mod.readBookmarksNames = defaultNames
if len(readBookmarksNames) > 0 {
mod.readBookmarksNames = readBookmarksNames
}
mod.writeBookmarksNames = defaultNames
if len(writeBookmarksNames) > 0 {
mod.writeBookmarksNames = writeBookmarksNames
}
mod.watermarkNames = defaultNames
if len(watermarkNames) > 0 {
mod.watermarkNames = watermarkNames
}
mod.stampNames = defaultNames
if len(stampNames) > 0 {
mod.stampNames = stampNames
}
mod.rotateNames = defaultNames
if len(rotateNames) > 0 {
mod.rotateNames = rotateNames
}
return nil
}
@@ -179,13 +220,7 @@ func (mod *PdfEngines) Validate() error {
continue
}
alreadyInSlice := false
for _, engine := range nonExistingEngines {
if engine == name {
alreadyInSlice = true
break
}
}
alreadyInSlice := slices.Contains(nonExistingEngines, name)
if !alreadyInSlice {
nonExistingEngines = append(nonExistingEngines, name)
@@ -201,6 +236,11 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.writeMetadataNames)
findNonExistingEngines(mod.encryptNames)
findNonExistingEngines(mod.embedNames)
findNonExistingEngines(mod.readBookmarksNames)
findNonExistingEngines(mod.writeBookmarksNames)
findNonExistingEngines(mod.watermarkNames)
findNonExistingEngines(mod.stampNames)
findNonExistingEngines(mod.rotateNames)
if len(nonExistingEngines) == 0 {
return nil
@@ -220,6 +260,12 @@ func (mod *PdfEngines) SystemMessages() []string {
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")),
fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames[:], " ")),
fmt.Sprintf("read bookmarks engines - %s", strings.Join(mod.readBookmarksNames[:], " ")),
fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames[:], " ")),
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames[:], " ")),
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames[:], " ")),
fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames[:], " ")),
}
}
@@ -248,6 +294,11 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.writeMetadataNames),
engines(mod.encryptNames),
engines(mod.embedNames),
engines(mod.readBookmarksNames),
engines(mod.writeBookmarksNames),
engines(mod.watermarkNames),
engines(mod.stampNames),
engines(mod.rotateNames),
), nil
}
@@ -271,8 +322,13 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
convertRoute(engine),
readMetadataRoute(engine),
writeMetadataRoute(engine),
readBookmarksRoute(engine),
writeBookmarksRoute(engine),
encryptRoute(engine),
embedRoute(engine),
watermarkRoute(engine),
stampRoute(engine),
rotateRoute(engine),
}, nil
}

View File

@@ -84,7 +84,7 @@ func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMod
}
// FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data.
// Fallback to default value if the considered key is not present.
// Fallback to the default value if the considered key is not present.
func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
var (
pdfa string
@@ -102,8 +102,8 @@ func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
}
// FormDataPdfMetadata creates metadata object from the form data.
func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interface{} {
var metadata map[string]interface{}
func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]any {
var metadata map[string]any
metadataFunc := func(value string) error {
if len(value) > 0 {
@@ -128,6 +128,129 @@ func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interfac
return metadata
}
// FormDataPdfBookmarks creates bookmarks from the form data.
func FormDataPdfBookmarks(form *api.FormData, mandatory bool) any {
var bookmarks any
bookmarksFunc := func(value string) error {
if len(value) > 0 {
var list []gotenberg.Bookmark
err := json.Unmarshal([]byte(value), &list)
if err == nil {
bookmarks = list
return nil
}
var m map[string][]gotenberg.Bookmark
err = json.Unmarshal([]byte(value), &m)
if err == nil {
bookmarks = m
return nil
}
return fmt.Errorf("unmarshal bookmarks: %w", err)
}
return nil
}
if mandatory {
form.MandatoryCustom("bookmarks", func(value string) error {
return bookmarksFunc(value)
})
} else {
form.Custom("bookmarks", func(value string) error {
return bookmarksFunc(value)
})
}
return bookmarks
}
// FormDataPdfRotate creates rotation parameters from the form data.
func FormDataPdfRotate(form *api.FormData, mandatory bool) (int, string) {
var angle int
var pages string
angleFunc := func(value string) error {
if value == "" {
return nil
}
v, err := strconv.Atoi(value)
if err != nil {
return err
}
if v != 90 && v != 180 && v != 270 {
return errors.New("wrong value, expected 90, 180, or 270")
}
angle = v
return nil
}
if mandatory {
form.MandatoryCustom("rotateAngle", func(value string) error {
return angleFunc(value)
})
} else {
form.Custom("rotateAngle", func(value string) error {
return angleFunc(value)
})
}
form.String("rotatePages", &pages, "")
return angle, pages
}
// RotateStub rotates pages of PDF files. If angle is 0, it does nothing.
func RotateStub(ctx *api.Context, engine gotenberg.PdfEngine, angle int, pages string, inputPaths []string) error {
if angle == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Rotate(ctx, ctx.Log(), inputPath, angle, pages)
if err != nil {
return fmt.Errorf("rotate '%s': %w", inputPath, err)
}
}
return nil
}
// ValidatePdfFormatsCompat checks for incompatible combinations of PDF formats
// with other features and returns an appropriate error if found.
func ValidatePdfFormatsCompat(pdfFormats gotenberg.PdfFormats, userPassword string, embedPaths []string) error {
zeroValued := gotenberg.PdfFormats{}
if pdfFormats == zeroValued {
return nil
}
// PDF/A forbids encryption per the standard.
if pdfFormats.PdfA != "" && userPassword != "" {
return api.WrapError(
errors.New("PDF/A format is incompatible with encryption"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: PDF/A format is incompatible with encryption",
),
)
}
// Only PDF/A-3 variants allow embedded file attachments.
if pdfFormats.PdfA != "" && len(embedPaths) > 0 {
if pdfFormats.PdfA != gotenberg.PdfA3a && pdfFormats.PdfA != gotenberg.PdfA3b && pdfFormats.PdfA != gotenberg.PdfA3u {
return api.WrapError(
fmt.Errorf("PDF format '%s' does not support embedded files", pdfFormats.PdfA),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Invalid form data: PDF format '%s' does not support embedded files; only PDF/A-3 variants allow attachments", pdfFormats.PdfA),
),
)
}
}
return nil
}
// MergeStub merges given PDFs. If only one input PDF, it does nothing and
// returns the corresponding input path.
func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) (string, error) {
@@ -239,7 +362,7 @@ func ConvertStub(ctx *api.Context, engine gotenberg.PdfEngine, formats gotenberg
// WriteMetadataStub writes the metadata into PDF files. If no metadata, it
// does nothing.
func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata map[string]interface{}, inputPaths []string) error {
func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata map[string]any, inputPaths []string) error {
if len(metadata) == 0 {
return nil
}
@@ -254,6 +377,58 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
return nil
}
func shiftBookmarks(bookmarks []gotenberg.Bookmark, offset int) []gotenberg.Bookmark {
if offset == 0 {
return bookmarks
}
shifted := make([]gotenberg.Bookmark, len(bookmarks))
for i, b := range bookmarks {
shifted[i] = gotenberg.Bookmark{
Title: b.Title,
Page: b.Page + offset,
Children: shiftBookmarks(b.Children, offset),
}
}
return shifted
}
// WriteBookmarksStub writes the bookmarks into PDF files. If no bookmarks, it
// does nothing.
func WriteBookmarksStub(ctx *api.Context, engine gotenberg.PdfEngine, bookmarks any, inputPaths []string) error {
if bookmarks == nil {
return nil
}
switch b := bookmarks.(type) {
case []gotenberg.Bookmark:
if len(b) == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, b)
if err != nil {
return fmt.Errorf("write bookmarks into '%s': %w", inputPath, err)
}
}
case map[string][]gotenberg.Bookmark:
for _, inputPath := range inputPaths {
filename := filepath.Base(inputPath)
if specificBookmarks, ok := b[filename]; ok {
err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, specificBookmarks)
if err != nil {
return fmt.Errorf("write bookmarks into '%s': %w", inputPath, err)
}
}
}
default:
// Should not happen.
return fmt.Errorf("bookmarks type '%T' not supported", bookmarks)
}
return nil
}
// FormDataPdfEmbeds extracts embedded file paths from form data.
// Only files uploaded with the "embeds" field name are included.
func FormDataPdfEmbeds(form *api.FormData) []string {
@@ -301,6 +476,122 @@ func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []s
return nil
}
// FormDataPdfWatermark creates a [gotenberg.Stamp] for watermarking from the
// form data.
func FormDataPdfWatermark(form *api.FormData, mandatory bool) gotenberg.Stamp {
return formDataPdfStampOrWatermark(form, "watermark", mandatory)
}
// FormDataPdfStamp creates a [gotenberg.Stamp] for stamping from the form data.
func FormDataPdfStamp(form *api.FormData, mandatory bool) gotenberg.Stamp {
return formDataPdfStampOrWatermark(form, "stamp", mandatory)
}
func formDataPdfStampOrWatermark(form *api.FormData, prefix string, mandatory bool) gotenberg.Stamp {
var (
source string
expression string
pages string
options map[string]string
)
sourceFunc := func(value string) error {
if value != "" && value != gotenberg.StampSourceText && value != gotenberg.StampSourceImage && value != gotenberg.StampSourcePDF {
return fmt.Errorf("wrong value, expected either '%s', '%s' or '%s'", gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF)
}
source = value
return nil
}
optionsFunc := func(value string) error {
if value == "" {
return nil
}
err := json.Unmarshal([]byte(value), &options)
if err != nil {
return fmt.Errorf("unmarshal %s options: %w", prefix, err)
}
return nil
}
if mandatory {
form.
MandatoryCustom(prefix+"Source", func(value string) error {
return sourceFunc(value)
}).
String(prefix+"Expression", &expression, "").
String(prefix+"Pages", &pages, "").
Custom(prefix+"Options", func(value string) error {
return optionsFunc(value)
})
} else {
form.
Custom(prefix+"Source", func(value string) error {
return sourceFunc(value)
}).
String(prefix+"Expression", &expression, "").
String(prefix+"Pages", &pages, "").
Custom(prefix+"Options", func(value string) error {
return optionsFunc(value)
})
}
return gotenberg.Stamp{
Source: source,
Expression: expression,
Pages: pages,
Options: options,
}
}
// FormDataPdfWatermarkFile extracts the watermark file path from form data.
func FormDataPdfWatermarkFile(form *api.FormData) string {
var path string
form.Watermark(&path)
return path
}
// FormDataPdfStampFile extracts the stamp file path from form data.
func FormDataPdfStampFile(form *api.FormData) string {
var path string
form.Stamp(&path)
return path
}
// WatermarkStub applies a watermark to a list of PDF files. If the stamp has
// no source, it does nothing.
func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
if stamp.Source == "" {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Watermark(ctx, ctx.Log(), inputPath, stamp)
if err != nil {
return fmt.Errorf("watermark '%s': %w", inputPath, err)
}
}
return nil
}
// StampStub applies a stamp to a list of PDF files. If the stamp has
// no source, it does nothing.
func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
if stamp.Source == "" {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp)
if err != nil {
return fmt.Errorf("stamp '%s': %w", inputPath, err)
}
}
return nil
}
// mergeRoute returns an [api.Route] which can merge PDFs.
func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
@@ -313,38 +604,60 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
form := ctx.FormData()
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
bookmarks := FormDataPdfBookmarks(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
stamp := FormDataPdfStamp(form, false)
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
var inputPaths []string
var flatten bool
var autoIndexBookmarks bool
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Bool("flatten", &flatten, false).
Bool("autoIndexBookmarks", &autoIndexBookmarks, false).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
if err != nil {
return err
}
outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
if err != nil {
return fmt.Errorf("merge PDFs: %w", err)
}
outputPaths, err := ConvertStub(ctx, engine, pdfFormats, []string{outputPath})
outputPaths := []string{outputPath}
err = WatermarkStub(ctx, engine, watermark, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF: %w", err)
return fmt.Errorf("watermark PDFs: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
err = StampStub(ctx, engine, stamp, outputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
return fmt.Errorf("stamp PDFs: %w", err)
}
err = WriteMetadataStub(ctx, engine, metadata, outputPaths)
err = RotateStub(ctx, engine, angle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
return fmt.Errorf("rotate PDFs: %w", err)
}
if flatten {
@@ -354,6 +667,67 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
}
}
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF: %w", err)
}
// Bookmarks, metadata, and embeds are written after Convert,
// as LibreOffice strips them during PDF/A conversion.
var finalBookmarks []gotenberg.Bookmark
if b, ok := bookmarks.([]gotenberg.Bookmark); ok {
finalBookmarks = b
} else {
bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark)
if bMap != nil || autoIndexBookmarks {
offset := 0
for _, inputPath := range inputPaths {
filename := filepath.Base(inputPath)
var fileBookmarks []gotenberg.Bookmark
if bMap != nil {
fileBookmarks = bMap[filename]
}
if len(fileBookmarks) == 0 && autoIndexBookmarks {
fb, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
if err != nil {
return fmt.Errorf("read bookmarks of '%s': %w", filename, err)
}
fileBookmarks = fb
}
if len(fileBookmarks) > 0 {
finalBookmarks = append(finalBookmarks, shiftBookmarks(fileBookmarks, offset)...)
}
pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath)
if err != nil {
return fmt.Errorf("get page count of '%s': %w", filename, err)
}
offset += pageCount
}
}
}
if len(finalBookmarks) > 0 {
err = WriteBookmarksStub(ctx, engine, finalBookmarks, outputPaths)
if err != nil {
return fmt.Errorf("write bookmarks: %w", err)
}
}
err = WriteMetadataStub(ctx, engine, metadata, outputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err)
@@ -384,6 +758,11 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
metadata := FormDataPdfMetadata(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
stamp := FormDataPdfStamp(form, false)
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
var inputPaths []string
var flatten bool
@@ -395,31 +774,60 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
if err != nil {
return err
}
outputPaths, err := SplitPdfStub(ctx, engine, mode, inputPaths)
if err != nil {
return fmt.Errorf("split PDFs: %w", err)
}
err = WatermarkStub(ctx, engine, watermark, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = StampStub(ctx, engine, stamp, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
err = RotateStub(ctx, engine, angle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
if flatten {
err = FlattenStub(ctx, engine, outputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
}
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
// Metadata, embeds are written after Convert, as LibreOffice
// strips them during PDF/A conversion.
err = WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
if flatten {
err = FlattenStub(ctx, engine, convertOutputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
@@ -558,7 +966,7 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err)
}
res := make(map[string]map[string]interface{}, len(inputPaths))
res := make(map[string]map[string]any, len(inputPaths))
for _, inputPath := range inputPaths {
metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath)
if err != nil {
@@ -619,6 +1027,83 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
}
}
// readBookmarksRoute returns an [api.Route] which returns the bookmarks of PDFs.
func readBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/bookmarks/read",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
var inputPaths []string
err := ctx.FormData().
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
res := make(map[string][]gotenberg.Bookmark, len(inputPaths))
for _, inputPath := range inputPaths {
bookmarks, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
if err != nil {
return fmt.Errorf("read bookmarks: %w", err)
}
res[filepath.Base(inputPath)] = bookmarks
}
err = c.JSON(http.StatusOK, res)
if err != nil {
if strings.Contains(err.Error(), "request method or response status code does not allow body") {
// High probability that the user is using the webhook
// feature. It does not make sense for this route.
return api.ErrNoOutputFile
}
return fmt.Errorf("return JSON response: %w", err)
}
return api.ErrNoOutputFile
},
}
}
// writeBookmarksRoute returns an [api.Route] which can write bookmarks into PDFs.
func writeBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/bookmarks/write",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
bookmarks := FormDataPdfBookmarks(form, true)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = WriteBookmarksStub(ctx, engine, bookmarks, inputPaths)
if err != nil {
return fmt.Errorf("write bookmarks: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// encryptRoute returns an [api.Route] which can add password protection to PDFs.
func encryptRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
@@ -658,6 +1143,7 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
}
// embedRoute returns an [api.Route] which can add embedded files to PDFs.
// TODO: attachments instead?
func embedRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
@@ -676,7 +1162,6 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
@@ -691,3 +1176,140 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
},
}
}
// watermarkRoute returns an [api.Route] which can add watermarks to PDFs.
//
//nolint:dupl
func watermarkRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/watermark",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
stamp := FormDataPdfWatermark(form, true)
watermarkFile := FormDataPdfWatermarkFile(form)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF {
if watermarkFile == "" {
return api.WrapError(
errors.New("no watermark file provided"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a watermark file is required for image or pdf source",
),
)
}
stamp.Expression = watermarkFile
}
err = WatermarkStub(ctx, engine, stamp, inputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// stampRoute returns an [api.Route] which can add stamps to PDFs.
//
//nolint:dupl
func stampRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/stamp",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
stamp := FormDataPdfStamp(form, true)
stampFile := FormDataPdfStampFile(form)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF {
if stampFile == "" {
return api.WrapError(
errors.New("no stamp file provided"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a stamp file is required for image or pdf source",
),
)
}
stamp.Expression = stampFile
}
err = StampStub(ctx, engine, stamp, inputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// rotateRoute returns an [api.Route] which can rotate pages of PDFs.
func rotateRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/rotate",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
angle, pages := FormDataPdfRotate(form, true)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = RotateStub(ctx, engine, angle, pages, inputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}

View File

@@ -56,8 +56,8 @@ func (engine *PdfTk) Validate() error {
}
// Debug returns additional debug data.
func (engine *PdfTk) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (engine *PdfTk) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -108,7 +108,7 @@ func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenbe
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
args := make([]string, 0, 3+len(inputPaths))
args = append(args, inputPaths...)
args = append(args, "cat", "output", outputPath)
@@ -136,15 +136,30 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats go
}
// ReadMetadata is not available in this implementation.
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteMetadata is not available in this implementation.
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// PageCount is not available in this implementation.
func (engine *PdfTk) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
return 0, fmt.Errorf("page count with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteBookmarks is not available in this implementation.
func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return fmt.Errorf("write PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadBookmarks is not available in this implementation.
func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return nil, fmt.Errorf("read PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Encrypt adds password protection to a PDF file using PDFtk.
func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
if userPassword == "" {
@@ -158,7 +173,7 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
// Create a temp output file in the same directory.
tmpPath := inputPath + ".tmp"
var args []string
args := make([]string, 0, 8)
args = append(args, inputPath)
args = append(args, "output", tmpPath)
args = append(args, "encrypt_128bit")
@@ -188,6 +203,105 @@ func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *zap.Logger, filePat
return fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Watermark applies a watermark (behind page content) to a PDF file using PDFtk.
// Only PDF source is supported.
func (engine *PdfTk) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
if stamp.Source != gotenberg.StampSourcePDF {
return fmt.Errorf("watermark PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported)
}
tmpPath := inputPath + ".tmp"
args := []string{inputPath, "background", stamp.Expression, "output", tmpPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("watermark PDF with PDFtk: %w", err)
}
err = os.Rename(tmpPath, inputPath)
if err != nil {
return fmt.Errorf("rename temporary output file with input file: %w", err)
}
return nil
}
// Stamp applies a stamp (on top of page content) to a PDF file using PDFtk.
// Only PDF source is supported.
func (engine *PdfTk) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
if stamp.Source != gotenberg.StampSourcePDF {
return fmt.Errorf("stamp PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported)
}
tmpPath := inputPath + ".tmp"
args := []string{inputPath, "stamp", stamp.Expression, "output", tmpPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("stamp PDF with PDFtk: %w", err)
}
err = os.Rename(tmpPath, inputPath)
if err != nil {
return fmt.Errorf("rename temporary output file with input file: %w", err)
}
return nil
}
// Rotate rotates all pages of a PDF file by the given angle using PDFtk.
// Page-specific rotation is not supported; if pages is non-empty,
// ErrPdfEngineMethodNotSupported is returned.
func (engine *PdfTk) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
if pages != "" {
return fmt.Errorf("rotate PDF with PDFtk (page-specific rotation): %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
var direction string
switch angle {
case 90:
direction = "east"
case 180:
direction = "south"
case 270:
direction = "west"
default:
return fmt.Errorf("rotate PDF with PDFtk: %w", gotenberg.ErrPdfRotateAngleNotSupported)
}
tmpPath := inputPath + ".tmp"
args := []string{inputPath, "cat", "1-end" + direction, "output", tmpPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("rotate PDF with PDFtk: %w", err)
}
err = os.Rename(tmpPath, inputPath)
if err != nil {
return fmt.Errorf("rename temporary output file with input file: %w", err)
}
return nil
}
// Interface guards.
var (
_ gotenberg.Module = (*PdfTk)(nil)

View File

@@ -27,6 +27,7 @@ type Prometheus struct {
interval time.Duration
disableRouteLogging bool
disableCollect bool
metricsPath string
metrics []gotenberg.Metric
registry *prometheus.Registry
@@ -42,6 +43,7 @@ func (mod *Prometheus) Descriptor() gotenberg.ModuleDescriptor {
fs.Duration("prometheus-collect-interval", time.Duration(1)*time.Second, "Set the interval for collecting modules' metrics")
fs.Bool("prometheus-disable-route-logging", false, "Disable the route logging")
fs.Bool("prometheus-disable-collect", false, "Disable the collect of metrics")
fs.String("prometheus-metrics-path", "/prometheus/metrics", "Path for Prometheus metrics endpoint")
return fs
}(),
@@ -56,6 +58,7 @@ func (mod *Prometheus) Provision(ctx *gotenberg.Context) error {
mod.interval = flags.MustDuration("prometheus-collect-interval")
mod.disableRouteLogging = flags.MustBool("prometheus-disable-route-logging")
mod.disableCollect = flags.MustBool("prometheus-disable-collect")
mod.metricsPath = flags.MustString("prometheus-metrics-path")
if mod.disableCollect {
// Exit early.
@@ -98,6 +101,10 @@ func (mod *Prometheus) Validate() error {
return errors.New("namespace must not be empty")
}
if mod.metricsPath == "" {
return errors.New("metrics path cannot be empty")
}
metricsMap := make(map[string]string, len(mod.metrics))
for _, metric := range mod.metrics {
@@ -171,7 +178,7 @@ func (mod *Prometheus) Routes() ([]api.Route, error) {
return []api.Route{
{
Method: http.MethodGet,
Path: "/prometheus/metrics",
Path: mod.metricsPath,
DisableLogging: mod.disableRouteLogging,
Handler: echo.WrapHandler(
promhttp.HandlerFor(mod.registry, promhttp.HandlerOpts{}),

View File

@@ -59,8 +59,8 @@ func (engine *QPdf) Validate() error {
}
// Debug returns additional debug data.
func (engine *QPdf) Debug() map[string]interface{} {
debug := make(map[string]interface{})
func (engine *QPdf) Debug() map[string]any {
debug := make(map[string]any)
cmd := exec.Command(engine.binPath, "--version") //nolint:gosec
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
@@ -114,7 +114,7 @@ func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenber
// Merge combines multiple PDFs into a single PDF.
func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string
args := make([]string, 0, 4+len(engine.globalArgs)+len(inputPaths))
args = append(args, "--empty")
args = append(args, engine.globalArgs...)
args = append(args, "--pages")
@@ -137,7 +137,7 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
// Flatten merges annotation appearances with page content, deleting the
// original annotations.
func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
var args []string
args := make([]string, 0, 4+len(engine.globalArgs))
args = append(args, inputPath)
args = append(args, "--generate-appearances")
args = append(args, "--flatten-annotations=all")
@@ -163,15 +163,30 @@ func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats got
}
// ReadMetadata is not available in this implementation.
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteMetadata is not available in this implementation.
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// PageCount is not available in this implementation.
func (engine *QPdf) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
return 0, fmt.Errorf("page count with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteBookmarks is not available in this implementation.
func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return fmt.Errorf("write PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadBookmarks is not available in this implementation.
func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return nil, fmt.Errorf("read PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Encrypt adds password protection to a PDF file using QPDF.
func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
if userPassword == "" {
@@ -182,7 +197,7 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
ownerPassword = userPassword
}
var args []string
args := make([]string, 0, 7+len(engine.globalArgs))
args = append(args, inputPath)
args = append(args, engine.globalArgs...)
args = append(args, "--replace-input")
@@ -206,6 +221,21 @@ func (engine *QPdf) EmbedFiles(ctx context.Context, logger *zap.Logger, filePath
return fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Watermark is not available in this implementation.
func (engine *QPdf) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("watermark PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Stamp is not available in this implementation.
func (engine *QPdf) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("stamp PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Rotate is not available in this implementation.
func (engine *QPdf) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
return fmt.Errorf("rotate PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
var (
_ gotenberg.Module = (*QPdf)(nil)
_ gotenberg.Provisioner = (*QPdf)(nil)

View File

@@ -272,6 +272,29 @@ func webhookMiddleware(w *Webhook) api.Middleware {
})
return c.NoContent(http.StatusNoContent)
}
if deadline, ok := ctx.Deadline(); ok {
// Create a new context derived from Background (detached from Request)
// but with the same deadline as the original context.
detachedCtx, detachedCancel := context.WithDeadline(context.Background(), deadline)
// Replace the embedded context in the api.Context struct.
// The modules downstream will now use this detached context.
ctx.Context = detachedCtx
// We must wrap the cancel function.
// 1. detachedCancel() cleans up our new detached context.
// 2. originalCancel() (captured from c.Get("cancel")) cleans up the working directory.
originalCancel := cancel
cancel = func() {
detachedCancel()
originalCancel()
}
} else {
// Fallback if no deadline was set (rare, as newContext enforces it).
ctx.Context = context.Background()
}
// As a webhook URL has been given, we handle the request in a
// goroutine and return immediately.
w.asyncCount.Add(1)

View File

@@ -0,0 +1,20 @@
@chromium
@chromium-concurrent
Feature: Chromium concurrent conversions
Scenario: Concurrent HTML to PDF conversions with max concurrency 3
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_MAX_CONCURRENCY | 3 |
When I make 3 concurrent "POST" requests to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
Then all concurrent response status codes should be 200
Then all concurrent responses should have 1 PDF(s)
Scenario: Concurrent conversions exceeding restart-after limit
Given I have a Gotenberg container with the following environment variable(s):
| CHROMIUM_MAX_CONCURRENCY | 3 |
| CHROMIUM_RESTART_AFTER | 5 |
When I make 10 concurrent "POST" requests to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
Then all concurrent response status codes should be 200
Then all concurrent responses should have 1 PDF(s)

View File

@@ -195,6 +195,36 @@ Feature: /forms/chromium/convert/html
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/html (Wait For Selector)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Wait on selector returns true.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| waitForSelector | #wait-selector | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Wait on selector returns true.
"""
Scenario: POST /forms/chromium/convert/html (Emulated Media Type)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
@@ -251,6 +281,82 @@ Feature: /forms/chromium/convert/html
Emulated media type is 'print'.
"""
Scenario: POST /forms/chromium/convert/html (Emulated Media Features)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Prefers reduced motion.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| emulatedMediaType | screen | field |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'print'.
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| emulatedMediaType | print | field |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Scenario: POST /forms/chromium/convert/html (Default Allow / Deny Lists)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
@@ -343,6 +449,16 @@ Feature: /forms/chromium/convert/html
https://gethttpstatus.com/400 - 400: Bad Request
"""
Scenario: POST /forms/chromium/convert/html (Fail On Resource HTTP Status Codes - Ignore Domains)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/feature-rich-html/index.html | file |
| failOnResourceHttpStatusCodes | [499,599] | field |
| ignoreResourceHttpStatusDomains | ["gethttpstatus.com"] | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/chromium/convert/html (Fail On Resource Loading Failed)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
@@ -510,6 +626,15 @@ Feature: /forms/chromium/convert/html
"""
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| emulatedMediaFeatures | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'emulatedMediaFeatures' is invalid (got 'foo', resulting to unmarshal emulatedMediaFeatures: invalid character 'o' in literal false (expecting 'a'))
"""
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| splitMode | foo | field |
@@ -863,6 +988,38 @@ Feature: /forms/chromium/convert/html
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be encrypted
@watermark
Scenario: POST /forms/chromium/convert/html (Watermark - Text)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@stamp
Scenario: POST /forms/chromium/convert/html (Stamp - Text)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| stampSource | text | field |
| stampExpression | DRAFT | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@rotate
Scenario: POST /forms/chromium/convert/html (Rotate 90)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| rotateAngle | 90 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@embed
Scenario: POST /forms/chromium/convert/html (Embeds)
Given I have a default Gotenberg container
@@ -882,13 +1039,15 @@ Feature: /forms/chromium/convert/html
# FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
@convert
@metadata
@watermark
@stamp
@flatten
@embed
Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds)
Scenario: POST /forms/chromium/convert/html (PDF/A-3b & PDF/UA-1 & Metadata & Watermark & Stamp & Flatten & Embeds)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s):
| files | testdata/page-1-html/index.html | file |
| pdfa | PDF/A-1b | field |
| pdfa | PDF/A-3b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| flatten | true | field |
@@ -900,8 +1059,8 @@ Feature: /forms/chromium/convert/html
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 5 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
Then the response PDF(s) should be flatten
Then the response PDF(s) should have the "embed_1.xml" file embedded
Then the response PDF(s) should have the "embed_2.xml" file embedded
@@ -915,11 +1074,8 @@ Feature: /forms/chromium/convert/html
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",

View File

@@ -1012,12 +1012,12 @@ Feature: /forms/chromium/convert/markdown
@metadata
@flatten
@embed
Scenario: POST /forms/chromium/convert/markdown (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds)
Scenario: POST /forms/chromium/convert/markdown (PDF/A-3b & PDF/UA-1 & Metadata & Flatten & Embeds)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s):
| files | testdata/page-1-markdown/index.html | file |
| files | testdata/page-1-markdown/page_1.md | file |
| pdfa | PDF/A-1b | field |
| pdfa | PDF/A-3b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| flatten | true | field |
@@ -1029,8 +1029,8 @@ Feature: /forms/chromium/convert/markdown
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 5 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
Then the response PDF(s) should be flatten
Then the response PDF(s) should have the "embed_1.xml" file embedded
Then the response PDF(s) should have the "embed_2.xml" file embedded
@@ -1044,11 +1044,8 @@ Feature: /forms/chromium/convert/markdown
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",

View File

@@ -255,6 +255,38 @@ Feature: /forms/chromium/convert/url
Wait delay > 2 seconds or expression window globalVar === 'ready' returns true.
"""
Scenario: POST /forms/chromium/convert/url (Wait For Selector)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Wait on selector returns true.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| waitForSelector | #wait-selector | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Wait on selector returns true.
"""
Scenario: POST /forms/chromium/convert/url (Emulated Media Type)
Given I have a default Gotenberg container
Given I have a static server
@@ -314,6 +346,86 @@ Feature: /forms/chromium/convert/url
Emulated media type is 'print'.
"""
Scenario: POST /forms/chromium/convert/url (Emulated Media Features)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Prefers reduced motion.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| emulatedMediaType | screen | field |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/feature-rich-html-remote/index.html | field |
| emulatedMediaType | print | field |
| emulatedMediaFeatures | {"prefers-reduced-motion":"reduce"} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the "foo.pdf" PDF should have 1 page(s)
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Emulated media type is 'print'.
"""
Then the "foo.pdf" PDF should have the following content at page 1:
"""
Prefers reduced motion.
"""
Then the "foo.pdf" PDF should NOT have the following content at page 1:
"""
Emulated media type is 'screen'.
"""
Scenario: POST /forms/chromium/convert/url (Default Allow / Deny Lists)
Given I have a default Gotenberg container
Given I have a static server
@@ -595,6 +707,16 @@ Feature: /forms/chromium/convert/url
Invalid form data: form field 'extraHttpHeaders' is invalid (got '{"foo":"bar;scope=*."}', resulting to invalid scope regex pattern for header 'foo': error parsing regexp: missing argument to repetition operator in `*.`)
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| emulatedMediaFeatures | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'emulatedMediaFeatures' is invalid (got 'foo', resulting to unmarshal emulatedMediaFeatures: invalid character 'o' in literal false (expecting 'a'))
"""
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| splitMode | foo | field |
@@ -985,12 +1107,12 @@ Feature: /forms/chromium/convert/url
@metadata
@flatten
@embed
Scenario: POST /forms/chromium/convert/url (PDF/A-1b & PDF/UA-1 & Metadata & Flatten)
Scenario: POST /forms/chromium/convert/url (PDF/A-3b & PDF/UA-1 & Metadata & Flatten & Embeds)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s):
| url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field |
| pdfa | PDF/A-1b | field |
| pdfa | PDF/A-3b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| flatten | true | field |
@@ -1002,8 +1124,8 @@ Feature: /forms/chromium/convert/url
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 5 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
Then the response PDF(s) should be flatten
Then the response PDF(s) should have the "embed_1.xml" file embedded
Then the response PDF(s) should have the "embed_2.xml" file embedded
@@ -1017,11 +1139,8 @@ Feature: /forms/chromium/convert/url
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",

View File

@@ -16,6 +16,129 @@ Feature: /debug
"""
{
"version": "{version}",
"timezone": "UTC",
"architecture": "ignore",
"modules": [
"api",
"chromium",
"exiftool",
"libreoffice",
"libreoffice-api",
"libreoffice-pdfengine",
"logging",
"pdfcpu",
"pdfengines",
"pdftk",
"prometheus",
"qpdf",
"webhook"
],
"modules_additional_data": {
"chromium": {
"version": "ignore"
},
"exiftool": {
"version": "ignore"
},
"libreoffice-api": {
"version": "ignore"
},
"pdfcpu": {
"version": "ignore"
},
"pdftk": {
"version": "ignore"
},
"qpdf": {
"version": "ignore"
}
},
"flags": {
"api-bind-ip": "",
"api-body-limit": "",
"api-disable-download-from": "false",
"api-disable-health-check-logging": "false",
"api-download-from-allow-list": "",
"api-download-from-deny-list": "",
"api-download-from-max-retry": "4",
"api-enable-basic-auth": "false",
"api-enable-debug-route": "true",
"api-port": "3000",
"api-port-from-env": "",
"api-root-path": "/",
"api-start-timeout": "30s",
"api-timeout": "30s",
"api-tls-cert-file": "",
"api-tls-key-file": "",
"api-trace-header": "Gotenberg-Trace",
"chromium-allow-file-access-from-files": "false",
"chromium-allow-insecure-localhost": "false",
"chromium-allow-list": "",
"chromium-auto-start": "false",
"chromium-clear-cache": "false",
"chromium-clear-cookies": "false",
"chromium-deny-list": "^file:(?!//\\/tmp/).*",
"chromium-disable-javascript": "false",
"chromium-disable-routes": "false",
"chromium-disable-web-security": "false",
"chromium-host-resolver-rules": "",
"chromium-ignore-certificate-errors": "false",
"chromium-incognito": "false",
"chromium-max-concurrency": "6",
"chromium-max-queue-size": "0",
"chromium-proxy-server": "",
"chromium-restart-after": "100",
"chromium-start-timeout": "20s",
"gotenberg-build-debug-data": "true",
"gotenberg-graceful-shutdown-duration": "30s",
"libreoffice-auto-start": "false",
"libreoffice-disable-routes": "false",
"libreoffice-max-queue-size": "0",
"libreoffice-restart-after": "10",
"libreoffice-start-timeout": "20s",
"log-fields-prefix": "",
"log-format": "auto",
"log-level": "info",
"pdfengines-convert-engines": "[libreoffice-pdfengine]",
"pdfengines-disable-routes": "false",
"pdfengines-engines": "[]",
"pdfengines-flatten-engines": "[qpdf]",
"pdfengines-merge-engines": "[qpdf,pdfcpu,pdftk]",
"pdfengines-read-bookmarks-engines": "[pdfcpu]",
"pdfengines-read-metadata-engines": "[exiftool]",
"pdfengines-split-engines": "[pdfcpu,qpdf,pdftk]",
"pdfengines-write-bookmarks-engines": "[pdfcpu]",
"pdfengines-write-metadata-engines": "[exiftool]",
"prometheus-collect-interval": "1s",
"prometheus-disable-collect": "false",
"prometheus-disable-route-logging": "false",
"prometheus-namespace": "gotenberg",
"prometheus-metrics-path": "/prometheus/metrics",
"webhook-allow-list": "",
"webhook-client-timeout": "30s",
"webhook-deny-list": "",
"webhook-disable": "false",
"webhook-error-allow-list": "",
"webhook-error-deny-list": "",
"webhook-max-retry": "4",
"webhook-retry-max-wait": "30s",
"webhook-retry-min-wait": "1s"
}
}
"""
Scenario: GET /debug (Environment based timezone)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| TZ | America/New_York |
When I make a "GET" request to Gotenberg at the "/debug" endpoint
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"version": "{version}",
"timezone": "America/New_York",
"architecture": "ignore",
"modules": [
"api",
@@ -84,8 +207,9 @@ Feature: /debug
"chromium-ignore-certificate-errors": "false",
"chromium-incognito": "false",
"chromium-max-queue-size": "0",
"chromium-max-concurrency": "6",
"chromium-proxy-server": "",
"chromium-restart-after": "10",
"chromium-restart-after": "100",
"chromium-start-timeout": "20s",
"gotenberg-build-debug-data": "true",
"gotenberg-graceful-shutdown-duration": "30s",
@@ -102,13 +226,16 @@ Feature: /debug
"pdfengines-engines": "[]",
"pdfengines-flatten-engines": "[qpdf]",
"pdfengines-merge-engines": "[qpdf,pdfcpu,pdftk]",
"pdfengines-read-bookmarks-engines": "[pdfcpu]",
"pdfengines-read-metadata-engines": "[exiftool]",
"pdfengines-split-engines": "[pdfcpu,qpdf,pdftk]",
"pdfengines-write-bookmarks-engines": "[pdfcpu]",
"pdfengines-write-metadata-engines": "[exiftool]",
"prometheus-collect-interval": "1s",
"prometheus-disable-collect": "false",
"prometheus-disable-route-logging": "false",
"prometheus-namespace": "gotenberg",
"prometheus-metrics-path": "/prometheus/metrics",
"webhook-allow-list": "",
"webhook-client-timeout": "30s",
"webhook-deny-list": "",
@@ -133,6 +260,7 @@ Feature: /debug
"""
{
"version": "",
"timezone": "",
"architecture": "",
"modules": null,
"modules_additional_data": null,

View File

@@ -563,6 +563,72 @@ Feature: /forms/libreoffice/convert
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be encrypted
@watermark
Scenario: POST /forms/libreoffice/convert (Watermark - Text)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@stamp
Scenario: POST /forms/libreoffice/convert (Stamp - Text)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| stampSource | text | field |
| stampExpression | DRAFT | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@rotate
Scenario: POST /forms/libreoffice/convert (Rotate 90)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| rotateAngle | 90 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@watermark
Scenario: POST /forms/libreoffice/convert (Native Watermark - Text)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| nativeWatermarkText | CONFIDENTIAL | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@watermark
Scenario: POST /forms/libreoffice/convert (Native Watermark - Text with Options)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| nativeWatermarkText | DRAFT | field |
| nativeWatermarkColor | 16711680 | field |
| nativeWatermarkFontHeight | 48 | field |
| nativeWatermarkRotateAngle | 450 | field |
| nativeWatermarkFontName | Courier | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@watermark
Scenario: POST /forms/libreoffice/convert (Native Watermark - Tiled)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| nativeTiledWatermarkText | CONFIDENTIAL | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@embed
Scenario: POST /forms/libreoffice/convert (Embeds)
Given I have a default Gotenberg container
@@ -582,15 +648,23 @@ Feature: /forms/libreoffice/convert
# FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
@convert
@metadata
@watermark
@stamp
@rotate
@flatten
@embed
Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds)
Scenario: POST /forms/libreoffice/convert (PDF/A-3b & PDF/UA-1 & Metadata & Watermark & Stamp & Rotate & Flatten & Embeds)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s):
| files | testdata/page_1.docx | file |
| pdfa | PDF/A-1b | field |
| pdfa | PDF/A-3b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
| stampSource | text | field |
| stampExpression | DRAFT | field |
| rotateAngle | 90 | field |
| flatten | true | field |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
@@ -600,8 +674,8 @@ Feature: /forms/libreoffice/convert
Then there should be 1 PDF(s) in the response
Then there should be the following file(s) in the response:
| foo.pdf |
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 5 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
Then the response PDF(s) should be flatten
Then the response PDF(s) should have the "embed_1.xml" file embedded
Then the response PDF(s) should have the "embed_2.xml" file embedded
@@ -615,11 +689,8 @@ Feature: /forms/libreoffice/convert
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",

View File

@@ -0,0 +1,339 @@
@pdfengines
@pdfengines-bookmarks
@bookmarks
Feature: /forms/pdfengines/bookmarks/{write|read}
Scenario: POST /forms/pdfengines/bookmarks/{write|read} (Single PDF & Bookmarks list)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| bookmarks | [{"title":"Index","page":1,"children":[{"title":"Sub-index","page":1}]}] | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": [
{
"title": "Index",
"page": 1,
"children": [
{
"title": "Sub-index",
"page": 1
}
]
}
]
}
"""
Scenario: POST /forms/pdfengines/bookmarks/{write|read} (Single PDF & Bookmarks Map)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| bookmarks | {"page_1.pdf":[{"title":"Index","page":1}]} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": [
{
"title": "Index",
"page": 1
}
]
}
"""
Scenario: POST /forms/pdfengines/bookmarks/{write|read} (Many PDFs & Bookmarks List)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files. | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| bookmarks | [{"title":"Index","page":1}] | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/page_1.pdf | file |
| files | teststore/page_2.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"page_1.pdf": [
{
"title": "Index",
"page": 1
}
],
"page_2.pdf": [
{
"title": "Index",
"page": 1
}
]
}
"""
Scenario: POST /forms/pdfengines/bookmarks/{write|read} (Many PDFs & Bookmarks Map)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files. | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| bookmarks | {"page_1.pdf":[{"title":"Index","page":1}],"page_2.pdf":[{"title":"Index","page":1}]} | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/page_1.pdf | file |
| files | teststore/page_2.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"page_1.pdf": [
{
"title": "Index",
"page": 1
}
],
"page_2.pdf": [
{
"title": "Index",
"page": 1
}
]
}
"""
Scenario: POST /forms/pdfengines/bookmarks/read (Empty List)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"page_1.pdf": []
}
"""
Scenario: POST /forms/pdfengines/bookmarks/write (Bad Request)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'bookmarks' is required; no form file found for extensions: [.pdf]
"""
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| bookmarks | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'bookmarks' is invalid (got 'foo', resulting to unmarshal bookmarks: invalid character 'o' in literal false (expecting 'a'))
"""
Scenario: POST /forms/pdfengines/bookmarks/read (Bad Request)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: no form file found for extensions: [.pdf]
"""
Scenario: POST /forms/pdfengines/bookmarks/write (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_DISABLE_ROUTES | true |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| bookmarks | [{"title":"Index","page":1}] | field |
Then the response status code should be 404
Scenario: POST /forms/pdfengines/bookmarks/read (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_DISABLE_ROUTES | true |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
Then the response status code should be 404
Scenario: POST /forms/pdfengines/bookmarks/write (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| bookmarks | [{"title":"Index","page":1}] | field |
| Gotenberg-Trace | forms_pdfengines_bookmarks_write | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_bookmarks_write"
Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_bookmarks_write" |
Scenario: POST /forms/pdfengines/bookmarks/read (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| Gotenberg-Trace | forms_pdfengines_bookmarks_read | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_bookmarks_read"
Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_bookmarks_read" |
@output-filename
Scenario: POST /forms/pdfengines/bookmarks/write (Output Filename - Single PDF)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| bookmarks | [{"title":"Index","page":1}] | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be the following file(s) in the response:
| foo.pdf |
@output-filename
Scenario: POST /forms/pdfengines/bookmarks/write (Output Filename - Many PDFs)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| bookmarks | [{"title":"Index","page":1}] | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be the following file(s) in the response:
| foo.zip |
| page_1.pdf |
| page_2.pdf |
@download-from
Scenario: POST /forms/pdfengines/bookmarks/write (Download From)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| bookmarks | [{"title":"Index","page":1}] | field |
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
| Gotenberg-Output-Filename | foo | header |
Then the file request header "X-Foo" should be "bar"
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
@download-from
Scenario: POST /forms/pdfengines/bookmarks/read (Download From)
Given I have a default Gotenberg container
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field |
Then the file request header "X-Foo" should be "bar"
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
@webhook
Scenario: POST /forms/pdfengines/bookmarks/write (Webhook)
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| bookmarks | [{"title":"Index","page":1}] | field |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": [
{
"title": "Index",
"page": 1
}
]
}
"""
@webhook
Scenario: POST /forms/pdfengines/bookmarks/read (Webhook)
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "Content-Type" should be "application/json"
Then the webhook request body should match JSON:
"""
{
"status": 400,
"message": "The webhook middleware can only work with multipart/form-data routes that results in output files"
}
"""
Scenario: POST /forms/pdfengines/bookmarks/write (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| bookmarks | [{"title":"Index","page":1}] | field |
Then the response status code should be 401
Scenario: POST /forms/pdfengines/bookmarks/read (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
Then the response status code should be 401
Scenario: POST /foo/forms/pdfengines/bookmarks/{write|read} (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| API_ROOT_PATH | /foo/ |
When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| bookmarks | [{"title":"Index","page":1}] | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"

View File

@@ -1,5 +1,5 @@
@pdfengines
@pdfengines-encrypt
@pdfengines-merge
@merge
Feature: /forms/pdfengines/merge
@@ -130,6 +130,26 @@ Feature: /forms/pdfengines/merge
"""
Invalid form data: form field 'metadata' is invalid (got 'foo', resulting to unmarshal metadata: invalid character 'o' in literal false (expecting 'a'))
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| bookmarks | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'bookmarks' is invalid (got 'foo', resulting to unmarshal bookmarks: invalid character 'o' in literal false (expecting 'a'))
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| autoIndexBookmarks | foo | field |
Then the response status code should be 400
Then the response header "Content-Type" should be "text/plain; charset=UTF-8"
Then the response body should match string:
"""
Invalid form data: form field 'autoIndexBookmarks' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax)
"""
@convert
Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1)
@@ -203,6 +223,135 @@ Feature: /forms/pdfengines/merge
}
"""
@bookmarks
Scenario: POST /forms/pdfengines/merge (Bookmarks List)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| bookmarks | [{"title":"Merged Index","page":1}] | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": [
{
"title": "Merged Index",
"page": 1
}
]
}
"""
@bookmarks
Scenario: POST /forms/pdfengines/merge (Bookmarks Map)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| bookmarks | {"page_1.pdf":[{"title":"Page 1 Index","page":1,"children":[{"title":"Page 1 Sub-index","page":1}]}],"page_2.pdf":[{"title":"Page 2 Index","page":1,"children":[{"title":"Page 2 Sub-index","page":1}]}]} | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": [
{
"title": "Page 1 Index",
"page": 1,
"children": [
{
"title": "Page 1 Sub-index",
"page": 1
}
]
},
{
"title": "Page 2 Index",
"page": 2,
"children": [
{
"title": "Page 2 Sub-index",
"page": 2
}
]
}
]
}
"""
@bookmarks
Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1_with_bookmarks.pdf | file |
| files | testdata/page_2_with_bookmarks.pdf | file |
| autoIndexBookmarks | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": [
{
"title": "Page 1",
"page": 1
},
{
"title": "Page 2",
"page": 2
}
]
}
"""
@bookmarks
Scenario: POST /forms/pdfengines/merge (Auto-index Bookmarks + Bookmarks Map)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2_with_bookmarks.pdf | file |
| bookmarks | {"page_1.pdf":[{"title":"Page 1 Index","page":1}]} | field |
| autoIndexBookmarks | true | field |
| Gotenberg-Output-Filename | foo | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": [
{
"title": "Page 1 Index",
"page": 1
},
{
"title": "Page 2",
"page": 2
}
]
}
"""
@flatten
Scenario: POST /forms/pdfengines/merge (Flatten)
Given I have a default Gotenberg container
@@ -252,6 +401,41 @@ Feature: /forms/pdfengines/merge
Then there should be 1 PDF(s) in the response
Then the response PDF(s) should be encrypted
@watermark
Scenario: POST /forms/pdfengines/merge (Watermark - Text)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@stamp
Scenario: POST /forms/pdfengines/merge (Stamp - Text)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| stampSource | text | field |
| stampExpression | DRAFT | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@rotate
Scenario: POST /forms/pdfengines/merge (Rotate 90)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| rotateAngle | 90 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@embed
Scenario: POST /foo/forms/pdfengines/merge (Embeds)
Given I have a default Gotenberg container
@@ -268,16 +452,26 @@ Feature: /forms/pdfengines/merge
# FIXME: once decrypt is done, add encrypt and check after the content of the PDF.
@convert
@metadata
@watermark
@stamp
@rotate
@flatten
@embed
Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds)
@bookmarks
Scenario: POST /forms/pdfengines/merge (PDF/A-3b & PDF/UA-1 & Metadata & Watermark & Stamp & Rotate & Flatten & Embeds & Bookmarks)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| pdfa | PDF/A-1b | field |
| pdfa | PDF/A-3b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
| stampSource | text | field |
| stampExpression | DRAFT | field |
| rotateAngle | 90 | field |
| bookmarks | [{"title":"Merged Index","page":1}] | field |
| flatten | true | field |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
@@ -296,11 +490,26 @@ Feature: /forms/pdfengines/merge
"""
Page 2
"""
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 5 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
Then the response PDF(s) should be flatten
Then the response PDF(s) should have the "embed_1.xml" file embedded
Then the response PDF(s) should have the "embed_2.xml" file embedded
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/json"
Then the response body should match JSON:
"""
{
"foo.pdf": [
{
"title": "Merged Index",
"page": 1
}
]
}
"""
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s):
| files | teststore/foo.pdf | file |
Then the response status code should be 200
@@ -311,11 +520,8 @@ Feature: /forms/pdfengines/merge
"foo.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
@@ -354,6 +560,32 @@ Feature: /forms/pdfengines/merge
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
@download-from
Scenario: POST /forms/pdfengines/merge (Watermark via Download From)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/watermark.png","field":"watermark"}] | field |
| watermarkSource | image | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
@download-from
Scenario: POST /forms/pdfengines/merge (Stamp via Download From)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdfcpu |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_2.pdf","field":"stamp"}] | field |
| stampSource | pdf | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
@webhook
Scenario: POST /forms/pdfengines/merge (Webhook)
Given I have a default Gotenberg container
@@ -399,3 +631,36 @@ Feature: /forms/pdfengines/merge
| files | testdata/page_2.pdf | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
@convert
@encrypt
Scenario: POST /forms/pdfengines/merge (PDF/A + Encrypt => 400)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| pdfa | PDF/A-1b | field |
| userPassword | secret | field |
Then the response status code should be 400
@convert
@embed
Scenario: POST /forms/pdfengines/merge (PDF/A-1b + Embeds => 400)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| pdfa | PDF/A-1b | field |
| embeds | testdata/embed_1.xml | file |
Then the response status code should be 400
@convert
@embed
Scenario: POST /forms/pdfengines/merge (PDF/A-3b + Embeds => 200)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| pdfa | PDF/A-3b | field |
| embeds | testdata/embed_1.xml | file |
Then the response status code should be 200

View File

@@ -1,7 +1,7 @@
@pdfengines
@pdfengines-metadata
@metadata
Feature: /forms/pdfengines/{write|read}
Feature: /forms/pdfengines/metadata/{write|read}
Scenario: POST /forms/pdfengines/metadata/{write|read} (Single PDF)
Given I have a default Gotenberg container

View File

@@ -0,0 +1,164 @@
@pdfengines
@pdfengines-rotate
@rotate
Feature: /forms/pdfengines/rotate
Scenario: POST /forms/pdfengines/rotate (90 - All Pages - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| rotateAngle | 90 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "pages_3.pdf" PDF should have 3 page(s)
Scenario: POST /forms/pdfengines/rotate (180 - All Pages - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 180 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "page_1.pdf" PDF should have 1 page(s)
Scenario: POST /forms/pdfengines/rotate (270 - All Pages - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 270 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "page_1.pdf" PDF should have 1 page(s)
Scenario: POST /forms/pdfengines/rotate (90 - Specific Pages - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| rotateAngle | 90 | field |
| rotatePages | 1,3 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "pages_3.pdf" PDF should have 3 page(s)
Scenario: POST /forms/pdfengines/rotate (90 - All Pages - pdftk)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdftk |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "page_1.pdf" PDF should have 1 page(s)
Scenario: POST /forms/pdfengines/rotate (Specific Pages - pdftk unsupported)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_ROTATE_ENGINES | pdftk |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| rotateAngle | 90 | field |
| rotatePages | 1,3 | field |
Then the response status code should be 500
Scenario: POST /forms/pdfengines/rotate (Bad Request - Invalid Angle)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 45 | field |
Then the response status code should be 400
Then the response body should contain string:
"""
Invalid form data: form field 'rotateAngle' is invalid
"""
Scenario: POST /forms/pdfengines/rotate (Bad Request - Missing Angle)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: form field 'rotateAngle' is required
"""
Scenario: POST /forms/pdfengines/rotate (Bad Request - No PDF)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| rotateAngle | 90 | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: no form file found for extensions: [.pdf]
"""
Scenario: POST /forms/pdfengines/rotate (Many PDFs)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| rotateAngle | 90 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Scenario: POST /forms/pdfengines/rotate (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_DISABLE_ROUTES | true |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | field |
Then the response status code should be 404
Scenario: POST /forms/pdfengines/rotate (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | field |
| Gotenberg-Trace | forms_pdfengines_rotate | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_rotate"
Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_rotate" |
@webhook
Scenario: POST /forms/pdfengines/rotate (Webhook)
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | field |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
Scenario: POST /forms/pdfengines/rotate (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | field |
Then the response status code should be 401
Scenario: POST /foo/forms/pdfengines/rotate (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| API_ROOT_PATH | /foo/ |
When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/rotate" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| rotateAngle | 90 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"

View File

@@ -473,6 +473,44 @@ Feature: /forms/pdfengines/split
Then there should be 2 PDF(s) in the response
Then the response PDF(s) should be encrypted
@watermark
Scenario: POST /forms/pdfengines/split (Watermark - Text)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
@stamp
Scenario: POST /forms/pdfengines/split (Stamp - Text)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| stampSource | text | field |
| stampExpression | DRAFT | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
@rotate
Scenario: POST /forms/pdfengines/split (Rotate 90)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| rotateAngle | 90 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
@embed
Scenario: POST /foo/forms/pdfengines/split (Embeds)
Given I have a default Gotenberg container
@@ -494,20 +532,28 @@ Feature: /forms/pdfengines/split
# FIXME: once decrypt is done, add encrypt and check after the content of the PDFs.
@convert
@metadata
@watermark
@stamp
@rotate
@flatten
@embed
Scenario: POST /forms/pdfengines/split (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds)
Scenario: POST /forms/pdfengines/split (PDF/A-3b & PDF/UA-1 & Metadata & Watermark & Stamp & Rotate & Flatten & Embeds)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| pdfa | PDF/A-1b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| flatten | true | field |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
| files | testdata/pages_3.pdf | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| pdfa | PDF/A-3b | field |
| pdfua | true | field |
| metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
| stampSource | text | field |
| stampExpression | DRAFT | field |
| rotateAngle | 90 | field |
| flatten | true | field |
| embeds | testdata/embed_1.xml | file |
| embeds | testdata/embed_2.xml | file |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
@@ -528,8 +574,8 @@ Feature: /forms/pdfengines/split
"""
Page 3
"""
Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s)
Then the response PDF(s) should be valid "PDF/A-3b" with a tolerance of 5 failed rule(s)
Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 3 failed rule(s)
Then the response PDF(s) should be flatten
Then the response PDF(s) should have the "embed_1.xml" file embedded
Then the response PDF(s) should have the "embed_2.xml" file embedded
@@ -544,11 +590,8 @@ Feature: /forms/pdfengines/split
"pages_3_0.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
@@ -558,11 +601,8 @@ Feature: /forms/pdfengines/split
"pages_3_1.pdf": {
"Author": "Julien Neuhart",
"Copyright": "Julien Neuhart",
"CreateDate": "2006:09:18 16:27:50-04:00",
"Creator": "Gotenberg",
"Keywords": ["first", "second"],
"Marked": true,
"ModDate": "2006:09:18 16:27:50-04:00",
"PDFVersion": 1.7,
"Producer": "Gotenberg",
"Subject": "Sample",
@@ -688,3 +728,39 @@ Feature: /forms/pdfengines/split
| splitSpan | 2 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
@convert
@encrypt
Scenario: POST /forms/pdfengines/split (PDF/A + Encrypt => 400)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| pdfa | PDF/A-1b | field |
| userPassword | secret | field |
Then the response status code should be 400
@convert
@embed
Scenario: POST /forms/pdfengines/split (PDF/A-1b + Embeds => 400)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| pdfa | PDF/A-1b | field |
| embeds | testdata/embed_1.xml | file |
Then the response status code should be 400
@convert
@embed
Scenario: POST /forms/pdfengines/split (PDF/A-3b + Embeds => 200)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| splitMode | intervals | field |
| splitSpan | 2 | field |
| pdfa | PDF/A-3b | field |
| embeds | testdata/embed_1.xml | file |
Then the response status code should be 200

View File

@@ -0,0 +1,237 @@
@pdfengines
@pdfengines-stamp
@stamp
Feature: /forms/pdfengines/stamp
Scenario: POST /forms/pdfengines/stamp (Text - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | text | field |
| stampExpression | CONFIDENTIAL | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "page_1.pdf" PDF should have 1 page(s)
Scenario: POST /forms/pdfengines/stamp (Text with Pages - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| stampSource | text | field |
| stampExpression | DRAFT | field |
| stampPages | 1-2 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "pages_3.pdf" PDF should have 3 page(s)
Scenario: POST /forms/pdfengines/stamp (Text with Options - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | text | field |
| stampExpression | SAMPLE | field |
| stampOptions | {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"} | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/pdfengines/stamp (Image - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stamp | testdata/watermark.png | file |
| stampSource | image | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/pdfengines/stamp (PDF - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stamp | testdata/page_2.pdf | file |
| stampSource | pdf | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/pdfengines/stamp (PDF - pdftk)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdftk |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stamp | testdata/page_2.pdf | file |
| stampSource | pdf | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/pdfengines/stamp (Text - pdftk unsupported)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdftk |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | text | field |
| stampExpression | CONFIDENTIAL | field |
Then the response status code should be 400
Then the response body should match string:
"""
At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues
"""
@download-from
Scenario: POST /forms/pdfengines/stamp (Image via Download From)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdfcpu |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/watermark.png","field":"stamp"}] | field |
| stampSource | image | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@download-from
Scenario: POST /forms/pdfengines/stamp (PDF via Download From)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_STAMP_ENGINES | pdfcpu |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_2.pdf","field":"stamp"}] | field |
| stampSource | pdf | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/pdfengines/stamp (Many PDFs)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| stampSource | text | field |
| stampExpression | DRAFT | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Scenario: POST /forms/pdfengines/stamp (Bad Request - No Source)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: form field 'stampSource' is required
"""
Scenario: POST /forms/pdfengines/stamp (Bad Request - Invalid Source)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | foo | field |
Then the response status code should be 400
Then the response body should contain string:
"""
Invalid form data: form field 'stampSource' is invalid
"""
Scenario: POST /forms/pdfengines/stamp (Bad Request - Missing File for Image Source)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | image | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a stamp file is required for image or pdf source
"""
Scenario: POST /forms/pdfengines/stamp (Bad Request - Missing File for PDF Source)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | pdf | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a stamp file is required for image or pdf source
"""
Scenario: POST /forms/pdfengines/stamp (Bad Request - No PDF)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| stampSource | text | field |
| stampExpression | CONFIDENTIAL | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: no form file found for extensions: [.pdf]
"""
Scenario: POST /forms/pdfengines/stamp (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_DISABLE_ROUTES | true |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | text | field |
| stampExpression | CONFIDENTIAL | field |
Then the response status code should be 404
Scenario: POST /forms/pdfengines/stamp (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | text | field |
| stampExpression | CONFIDENTIAL | field |
| Gotenberg-Trace | forms_pdfengines_stamp | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_stamp"
Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_stamp" |
@webhook
Scenario: POST /forms/pdfengines/stamp (Webhook)
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | text | field |
| stampExpression | CONFIDENTIAL | field |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
Scenario: POST /forms/pdfengines/stamp (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | text | field |
| stampExpression | CONFIDENTIAL | field |
Then the response status code should be 401
Scenario: POST /foo/forms/pdfengines/stamp (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| API_ROOT_PATH | /foo/ |
When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/stamp" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| stampSource | text | field |
| stampExpression | CONFIDENTIAL | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"

View File

@@ -0,0 +1,237 @@
@pdfengines
@pdfengines-watermark
@watermark
Feature: /forms/pdfengines/watermark
Scenario: POST /forms/pdfengines/watermark (Text - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "page_1.pdf" PDF should have 1 page(s)
Scenario: POST /forms/pdfengines/watermark (Text with Pages - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/pages_3.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | DRAFT | field |
| watermarkPages | 1-2 | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Then the "pages_3.pdf" PDF should have 3 page(s)
Scenario: POST /forms/pdfengines/watermark (Text with Options - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | SAMPLE | field |
| watermarkOptions | {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"} | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/pdfengines/watermark (Image - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermark | testdata/watermark.png | file |
| watermarkSource | image | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/pdfengines/watermark (PDF - pdfcpu)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermark | testdata/page_2.pdf | file |
| watermarkSource | pdf | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/pdfengines/watermark (PDF - pdftk)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdftk |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermark | testdata/page_2.pdf | file |
| watermarkSource | pdf | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/pdfengines/watermark (Text - pdftk unsupported)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdftk |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
Then the response status code should be 400
Then the response body should match string:
"""
At least one PDF engine cannot process the requested stamp source type, while others may have failed due to different issues
"""
@download-from
Scenario: POST /forms/pdfengines/watermark (Image via Download From)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/watermark.png","field":"watermark"}] | field |
| watermarkSource | image | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
@download-from
Scenario: POST /forms/pdfengines/watermark (PDF via Download From)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_WATERMARK_ENGINES | pdfcpu |
Given I have a static server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_2.pdf","field":"watermark"}] | field |
| watermarkSource | pdf | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the response
Scenario: POST /forms/pdfengines/watermark (Many PDFs)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| files | testdata/page_2.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | DRAFT | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/zip"
Then there should be 2 PDF(s) in the response
Scenario: POST /forms/pdfengines/watermark (Bad Request - No Source)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: form field 'watermarkSource' is required
"""
Scenario: POST /forms/pdfengines/watermark (Bad Request - Invalid Source)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | foo | field |
Then the response status code should be 400
Then the response body should contain string:
"""
Invalid form data: form field 'watermarkSource' is invalid
"""
Scenario: POST /forms/pdfengines/watermark (Bad Request - Missing File for Image Source)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | image | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a watermark file is required for image or pdf source
"""
Scenario: POST /forms/pdfengines/watermark (Bad Request - Missing File for PDF Source)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | pdf | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: a watermark file is required for image or pdf source
"""
Scenario: POST /forms/pdfengines/watermark (Bad Request - No PDF)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
Then the response status code should be 400
Then the response body should match string:
"""
Invalid form data: no form file found for extensions: [.pdf]
"""
Scenario: POST /forms/pdfengines/watermark (Routes Disabled)
Given I have a Gotenberg container with the following environment variable(s):
| PDFENGINES_DISABLE_ROUTES | true |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
Then the response status code should be 404
Scenario: POST /forms/pdfengines/watermark (Gotenberg Trace)
Given I have a default Gotenberg container
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
| Gotenberg-Trace | forms_pdfengines_watermark | header |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_watermark"
Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_watermark" |
@webhook
Scenario: POST /forms/pdfengines/watermark (Webhook)
Given I have a default Gotenberg container
Given I have a webhook server
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
| Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header |
| Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header |
Then the response status code should be 204
When I wait for the asynchronous request to the webhook
Then the webhook request header "Content-Type" should be "application/pdf"
Then there should be 1 PDF(s) in the webhook request
Scenario: POST /forms/pdfengines/watermark (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_BASIC_AUTH | true |
| GOTENBERG_API_BASIC_AUTH_USERNAME | foo |
| GOTENBERG_API_BASIC_AUTH_PASSWORD | bar |
When I make a "POST" request to Gotenberg at the "/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
Then the response status code should be 401
Scenario: POST /foo/forms/pdfengines/watermark (Root Path)
Given I have a Gotenberg container with the following environment variable(s):
| API_ENABLE_DEBUG_ROUTE | true |
| API_ROOT_PATH | /foo/ |
When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/watermark" endpoint with the following form data and header(s):
| files | testdata/page_1.pdf | file |
| watermarkSource | text | field |
| watermarkExpression | CONFIDENTIAL | field |
Then the response status code should be 200
Then the response header "Content-Type" should be "application/pdf"

View File

@@ -29,6 +29,31 @@ Feature: /prometheus/metrics
Then the Gotenberg container should log the following entries:
| "path":"/prometheus/metrics" |
Scenario: GET /custom/metrics (Custom Metrics Path)
Given I have a Gotenberg container with the following environment variable(s):
| PROMETHEUS_METRICS_PATH | /custom/metrics |
When I make a "GET" request to Gotenberg at the "/custom/metrics" endpoint
Then the response status code should be 200
Then the response header "Content-Type" should be "text/plain; version=0.0.4; charset=utf-8; escaping=underscores"
Then the response body should match string:
"""
# HELP gotenberg_chromium_requests_queue_size Current number of Chromium conversion requests waiting to be treated.
# TYPE gotenberg_chromium_requests_queue_size gauge
gotenberg_chromium_requests_queue_size 0
# HELP gotenberg_chromium_restarts_count Current number of Chromium restarts.
# TYPE gotenberg_chromium_restarts_count gauge
gotenberg_chromium_restarts_count 0
# HELP gotenberg_libreoffice_requests_queue_size Current number of LibreOffice conversion requests waiting to be treated.
# TYPE gotenberg_libreoffice_requests_queue_size gauge
gotenberg_libreoffice_requests_queue_size 0
# HELP gotenberg_libreoffice_restarts_count Current number of LibreOffice restarts.
# TYPE gotenberg_libreoffice_restarts_count gauge
gotenberg_libreoffice_restarts_count 0
"""
Then the Gotenberg container should log the following entries:
| "path":"/custom/metrics" |
Scenario: GET /prometheus/metrics (Custom Namespace)
Given I have a Gotenberg container with the following environment variable(s):
| PROMETHEUS_NAMESPACE | foo |

View File

@@ -5,11 +5,11 @@ import (
"reflect"
)
func compareJson(expected, actual interface{}) error {
func compareJson(expected, actual any) error {
// Handle maps (JSON objects).
expectedMap, ok := expected.(map[string]interface{})
expectedMap, ok := expected.(map[string]any)
if ok {
actualMap, ok := actual.(map[string]interface{})
actualMap, ok := actual.(map[string]any)
if !ok {
return fmt.Errorf("expected an object, but actual is: %T", actual)
}
@@ -31,9 +31,9 @@ func compareJson(expected, actual interface{}) error {
}
// Handle slices (JSON arrays).
expectedSlice, ok := expected.([]interface{})
expectedSlice, ok := expected.([]any)
if ok {
actualSlice, ok := actual.([]interface{})
actualSlice, ok := actual.([]any)
if !ok {
return fmt.Errorf("expected an array, but actual is: %T", actual)
}

View File

@@ -22,7 +22,7 @@ var (
type noopLogger struct{}
func (n *noopLogger) Printf(format string, v ...interface{}) {
func (n *noopLogger) Printf(format string, v ...any) {
// NOOP
}

View File

@@ -20,6 +20,7 @@ func doRequest(method, url string, headers map[string]string, body io.Reader) (*
req.Header.Set(header, value)
}
// #nosec
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send a request: %w", err)
@@ -74,6 +75,7 @@ func doFormDataRequest(method, url string, fields map[string]string, files map[s
}
req.Header.Set("Content-Type", writer.FormDataContentType())
// #nosec
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, fmt.Errorf("send a request: %w", err)

View File

@@ -14,6 +14,7 @@ import (
"regexp"
"strconv"
"strings"
"sync"
"time"
"github.com/cucumber/godog"
@@ -24,7 +25,9 @@ import (
type scenario struct {
resp *httptest.ResponseRecorder
concurrentResps []*httptest.ResponseRecorder
workdir string
teststoreDir string
gotenbergContainer testcontainers.Container
gotenbergContainerNetwork *testcontainers.DockerNetwork
server *server
@@ -33,6 +36,7 @@ type scenario struct {
func (s *scenario) reset(ctx context.Context) error {
s.resp = httptest.NewRecorder()
s.concurrentResps = nil
err := os.RemoveAll(s.workdir)
if err != nil {
@@ -160,12 +164,14 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
fields[name] = value
case "file":
if strings.Contains(value, "teststore") {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", dirPath)
if s.teststoreDir == "" {
return errors.New("no teststore directory available from previous requests")
}
value = strings.ReplaceAll(value, "teststore", dirPath)
_, err := os.Stat(s.teststoreDir)
if os.IsNotExist(err) {
return fmt.Errorf("directory %q does not exist", s.teststoreDir)
}
value = strings.ReplaceAll(value, "teststore", s.teststoreDir)
} else {
wd, err := os.Getwd()
if err != nil {
@@ -213,6 +219,13 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
return fmt.Errorf("write response body: %w", err)
}
if resp.StatusCode == http.StatusNoContent {
// Gotenberg processes this asynchronously. The webhook test server
// will save the incoming files under this trace ID directory shortly.
s.teststoreDir = fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
return nil
}
if resp.StatusCode != http.StatusOK {
return nil
}
@@ -238,6 +251,8 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
return fmt.Errorf("create working directory: %w", err)
}
s.teststoreDir = dirPath
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
file, err := os.Create(fpath)
if err != nil {
@@ -281,6 +296,168 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct
return nil
}
func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count int, method, endpoint string, dataTable *godog.Table) error {
if s.gotenbergContainer == nil {
return errors.New("no Gotenberg container")
}
fields := make(map[string]string)
files := make(map[string][]string)
headers := make(map[string]string)
for _, row := range dataTable.Rows {
name := row.Cells[0].Value
value := row.Cells[1].Value
kind := row.Cells[2].Value
switch kind {
case "field":
fields[name] = value
case "file":
wd, err := os.Getwd()
if err != nil {
return fmt.Errorf("get current directory: %w", err)
}
value = fmt.Sprintf("%s/%s", wd, value)
files[name] = append(files[name], value)
case "header":
headers[name] = value
default:
return fmt.Errorf("unexpected %q %q", kind, value)
}
}
base, err := containerHttpEndpoint(ctx, s.gotenbergContainer, "3000")
if err != nil {
return fmt.Errorf("get container HTTP endpoint: %w", err)
}
var (
mu sync.Mutex
wg sync.WaitGroup
)
s.concurrentResps = make([]*httptest.ResponseRecorder, 0, count)
errs := make([]error, 0)
for range count {
wg.Go(func() {
resp, reqErr := doFormDataRequest(method, fmt.Sprintf("%s%s", base, endpoint), fields, files, headers)
if reqErr != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("do request: %w", reqErr))
mu.Unlock()
return
}
defer resp.Body.Close()
body, reqErr := io.ReadAll(resp.Body)
if reqErr != nil {
mu.Lock()
errs = append(errs, fmt.Errorf("read response body: %w", reqErr))
mu.Unlock()
return
}
rec := httptest.NewRecorder()
rec.Code = resp.StatusCode
for key, values := range resp.Header {
for _, v := range values {
rec.Header().Add(key, v)
}
}
_, _ = rec.Body.Write(body)
if resp.StatusCode == http.StatusOK {
cd := resp.Header.Get("Content-Disposition")
if cd != "" {
_, params, parseErr := mime.ParseMediaType(cd)
if parseErr == nil {
if filename, ok := params["filename"]; ok {
traceID := resp.Header.Get("Gotenberg-Trace")
dirPath := fmt.Sprintf("%s/%s", s.workdir, traceID)
mu.Lock()
mkErr := os.MkdirAll(dirPath, 0o755)
mu.Unlock()
if mkErr == nil {
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
f, fErr := os.Create(fpath)
if fErr == nil {
_, _ = f.Write(body)
f.Close()
}
}
}
}
}
}
mu.Lock()
s.concurrentResps = append(s.concurrentResps, rec)
mu.Unlock()
})
}
wg.Wait()
if len(errs) > 0 {
return fmt.Errorf("concurrent requests failed: %v", errs)
}
return nil
}
func (s *scenario) allConcurrentResponseStatusCodesShouldBe(expected int) error {
if len(s.concurrentResps) == 0 {
return errors.New("no concurrent responses recorded")
}
for i, resp := range s.concurrentResps {
if resp.Code != expected {
return fmt.Errorf("concurrent response %d: expected status %d, got %d %q", i+1, expected, resp.Code, resp.Body.String())
}
}
return nil
}
func (s *scenario) allConcurrentResponsesShouldHavePdfs(expected int) error {
if len(s.concurrentResps) == 0 {
return errors.New("no concurrent responses recorded")
}
for i, resp := range s.concurrentResps {
traceID := resp.Header().Get("Gotenberg-Trace")
dirPath := fmt.Sprintf("%s/%s", s.workdir, traceID)
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
return fmt.Errorf("concurrent response %d: directory %q does not exist", i+1, dirPath)
}
var paths []string
err = filepath.Walk(dirPath, func(path string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
if strings.EqualFold(filepath.Ext(info.Name()), ".pdf") {
paths = append(paths, path)
}
return nil
})
if err != nil {
return fmt.Errorf("concurrent response %d: walk %q: %w", i+1, dirPath, err)
}
if len(paths) != expected {
return fmt.Errorf("concurrent response %d: expected %d PDF(s), got %d", i+1, expected, len(paths))
}
}
return nil
}
func (s *scenario) iWaitForTheAsynchronousRequestToWebhook(ctx context.Context) error {
if s.server == nil {
return errors.New("server not initialized")
@@ -327,7 +504,7 @@ func (s *scenario) theGotenbergContainerShouldLogTheFollowingEntries(ctx context
}
var err error
for i := 0; i < 3; i++ {
for range 3 {
err = check()
if err != nil && !invert {
// We have to retry as not all logs may have been produced.
@@ -450,7 +627,7 @@ func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocStr
body = s.server.bodyCopy
}
var expected, actual interface{}
var expected, actual any
content := strings.ReplaceAll(expectedDoc.Content, "{version}", GotenbergVersion)
err := json.Unmarshal([]byte(content), &expected)
@@ -472,7 +649,7 @@ func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocStr
}
func (s *scenario) thereShouldBePdfs(expected int, kind string) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -501,7 +678,7 @@ func (s *scenario) thereShouldBePdfs(expected int, kind string) error {
}
func (s *scenario) thereShouldBeTheFollowingFiles(kind string, filesTable *godog.Table) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -543,7 +720,7 @@ func (s *scenario) thereShouldBeTheFollowingFiles(kind string, filesTable *godog
}
func (s *scenario) thePdfsShouldBeValidWithAToleranceOf(ctx context.Context, kind, validate string, tolerance int) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -623,7 +800,7 @@ func (s *scenario) thePdfShouldHavePages(ctx context.Context, name string, pages
}
} else {
substr := strings.ReplaceAll(name, "*_", "")
err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error {
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
@@ -679,7 +856,7 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context,
}
} else {
substr := strings.ReplaceAll(name, "*_", "")
err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error {
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
@@ -746,7 +923,7 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context
}
} else {
substr := strings.ReplaceAll(name, "*_", "")
err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error {
err := filepath.Walk(s.teststoreDir, func(currentPath string, info os.FileInfo, pathErr error) error {
if pathErr != nil {
return pathErr
}
@@ -790,7 +967,7 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context
}
func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should string) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -840,7 +1017,7 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri
}
func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -892,7 +1069,7 @@ func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, sh
}
func (s *scenario) thePdfsShouldHaveEmbeddedFile(ctx context.Context, kind, should, embed string) error {
dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"))
dirPath := s.teststoreDir
_, err := os.Stat(dirPath)
if os.IsNotExist(err) {
@@ -965,9 +1142,12 @@ func InitializeScenario(ctx *godog.ScenarioContext) {
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint$`, s.iMakeARequestToGotenberg)
ctx.When(`^I make a "(GET|HEAD)" request to Gotenberg at the "([^"]*)" endpoint with the following header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingHeaders)
ctx.When(`^I make a "(POST)" request to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders)
ctx.When(`^I make (\d+) concurrent "(POST)" requests to Gotenberg at the "([^"]*)" endpoint with the following form data and header\(s\):$`, s.iMakeConcurrentRequestsToGotenberg)
ctx.When(`^I wait for the asynchronous request to the webhook$`, s.iWaitForTheAsynchronousRequestToWebhook)
ctx.Then(`^the Gotenberg container (should|should NOT) log the following entries:$`, s.theGotenbergContainerShouldLogTheFollowingEntries)
ctx.Then(`^the response status code should be (\d+)$`, s.theResponseStatusCodeShouldBe)
ctx.Then(`^all concurrent response status codes should be (\d+)$`, s.allConcurrentResponseStatusCodesShouldBe)
ctx.Then(`^all concurrent responses should have (\d+) PDF\(s\)$`, s.allConcurrentResponsesShouldHavePdfs)
ctx.Then(`^the (response|webhook request|file request|server request) header "([^"]*)" should be "([^"]*)"$`, s.theHeaderValueShouldBe)
ctx.Then(`^the (response|webhook request|file request|server request) cookie "([^"]*)" should be "([^"]*)"$`, s.theCookieValueShouldBe)
ctx.Then(`^the (response|webhook request) body should match string:$`, s.theBodyShouldMatchString)

View File

@@ -79,12 +79,14 @@ func newServer(ctx context.Context, workdir string) (*server, error) {
}
dirPath := fmt.Sprintf("%s/%s", workdir, s.req.Header.Get("Gotenberg-Trace"))
// #nosec
err = os.MkdirAll(dirPath, 0o755)
if err != nil {
return webhookErr(fmt.Errorf("create working directory: %w", err))
}
fpath := fmt.Sprintf("%s/%s", dirPath, filename)
// #nosec
file, err := os.Create(fpath)
if err != nil {
return webhookErr(fmt.Errorf("create file %q: %w", fpath, err))

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