Compare commits

...

35 Commits

Author SHA1 Message Date
Julien Neuhart
651922194f chore(deps): update Go dependencies 2025-01-29 20:48:43 +01:00
Julien Neuhart
1d1b4fb04d feat(pdfengines): add flatten form field to split route 2025-01-28 17:15:14 +01:00
Peter Chakalov
b418f1eb05 feat(pdfengines): add support for flattening annotations (#1105)
* initial changes

* Add tests

* Fix edge case when we need to regenerate appearances

* Fix comments

* Add missing comment

* Add missing comment

* Add missing comment

* Add flatten option to the merge route

* Add flatten option to the libreoffice convert route

* Add flatten option to the chromium convert route

* Revert "Add flatten option to the chromium convert route"

This reverts commit cdab8b4e6b.

* Ignore lint false positives

* Add missing tests

* Add flatten route tests

* Replace input instead of creating a new file

* create copy before flatten in tests

---------

Co-authored-by: Peter Chakalov <peter.chakalov@abraxa.com>
2025-01-28 14:38:27 +01:00
Julien Neuhart
a9b44ee39e chore(lint): remove rule G115 (gosec) exception 2025-01-27 16:05:52 +01:00
Julien Neuhart
cb48355898 fix(ci): missing checkout step on reusable workflow delete 2025-01-24 10:46:31 +01:00
Julien Neuhart
6ce47e3a20 ci(workflows): cleanup PR Docker images 2025-01-24 10:27:04 +01:00
Julien Neuhart
09e511bc49 ci(workflows): use sub-actions 2025-01-24 10:27:04 +01:00
Julien Neuhart
66ade27383 docs(README): fix continuous integration badge 2025-01-23 20:18:18 +01:00
Julien Neuhart
d96058d589 fix(Dockerfile): use latest Chromium for armhf 2025-01-23 20:04:13 +01:00
Julien Neuhart
8631a15933 ci(comment): remove comment about arm64 testing 2025-01-23 20:04:13 +01:00
Julien Neuhart
c0bf81ad7c ci(workflows): multiple jobs multiple architectures build and push 2025-01-23 18:03:23 +01:00
Julien Neuhart
8fd1fa203e fix(Dockerfile): remove apt-get upgrade -yqq from armv7 Chromium install 2025-01-17 18:01:44 +01:00
Julien Neuhart
51b907bcf5 chore(deps): update Go dependencies 2025-01-17 15:25:17 +01:00
Julien Neuhart
ecb5d974c5 chore(Dockerfile): add apt-get upgrade -yqq 2025-01-17 15:24:37 +01:00
Julien Neuhart
19c91aff19 feat(api): add root, favicon and version routes to basic auth middleware if enabled 2025-01-17 14:08:01 +01:00
Julien Neuhart
8e0cc7dd2f fix(typo): root route HTML 2025-01-17 10:00:08 +01:00
Khiet Tam Nguyen
6478528ebc feat(api): add dummy root route pointing to docs (#1099)
* feat(api): added dummy root route pointing to docs

* test(api): root request test added

* feat(api): added NoContent return for favicon

from @gulien

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>

* test(api): added favicon

* lint(api): fix format

* refactor(api): use %s for favicon.ico for consistency with /health

* test(api): added new recorder for each check

* docs(api): consistent comment for favicon

---------

Co-authored-by: Julien Neuhart <neuhart.julien@gmail.com>
2025-01-17 09:58:58 +01:00
Julien Neuhart
3e3b24f611 ci(workflows): add permissions 2025-01-11 11:28:27 +01:00
Julien Neuhart
6dc7d70a1f ci(continous integration): add concurrency to avoid concurrent runs 2025-01-11 11:17:50 +01:00
Julien Neuhart
a6f4c5651c feat(chromium): set the default value of the flag --chromium-restart-after to 10 2025-01-11 11:08:19 +01:00
Julien Neuhart
291a4d39fe chore(deps): upgrade to golangci-lint v1.63.4 2025-01-10 17:02:16 +01:00
Julien Neuhart
e639a1dd73 chore(deps): update Go dependencies 2025-01-10 16:59:01 +01:00
Julien Neuhart
e3a961623b feat(logging): add new field log_type with access or application as values 2025-01-10 16:58:26 +01:00
Hugo Tacla
1ca27fdba2 fix(pdfengines): wrong variable name (#1093) 2025-01-08 19:24:43 +01:00
Julien Neuhart
f72428056a chore(code): fmt 2025-01-08 17:15:30 +01:00
Hugo Tacla
bdb3f11ac7 fix(typo): medata -> metadata (#1092)
* Update pdfengines.go

* Update pdfengines_test.go

* Update multi.go

* Update multi_test.go
2025-01-08 17:14:53 +01:00
Julien Neuhart
2f69af1a52 chore(LICENSE): remove year 2025-01-06 09:17:13 +01:00
Julien Neuhart
984cf77ee0 fix(chromium): info log into debug log + slight improvement of the killing of stuck processes 2025-01-02 17:22:13 +01:00
Julien Neuhart
1762596fce chore(dep)s: update dependencies 2025-01-02 17:18:53 +01:00
Julien Neuhart
910eb9b770 feat(state): improve clean up when LibreOffice and Chromium are restarted 2024-12-25 17:04:25 +01:00
Julien Neuhart
16807bd57f fix(split): wrong output paths when converting to PDF/A & PDF/UA 2024-12-23 10:04:24 +01:00
Julien Neuhart
8bc29ad92d feat(split): add splitUnify form field 2024-12-21 12:45:20 +01:00
Julien Neuhart
51a913a5e4 chore(deps): update Go depencies 2024-12-20 16:16:32 +01:00
Julien Neuhart
c30da805b3 feat(pdfengines): add split feature 2024-12-20 16:16:32 +01:00
Julien Neuhart
42ee593708 chore(pdfengines): add stubs for common workflows 2024-11-25 09:28:42 +01:00
62 changed files with 3971 additions and 1012 deletions

14
.env Normal file
View File

@@ -0,0 +1,14 @@
GOLANG_VERSION=1.23
DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOLANGCI_LINT_VERSION=v1.63.4 # See https://github.com/golangci/golangci-lint/releases.
GOTENBERG_VERSION=snapshot
DOCKERFILE=build/Dockerfile
DOCKERFILE_CLOUDRUN=build/Dockerfile.cloudrun
DOCKER_BUILD_CONTEXT='.'

51
.github/workflows/__build-push.yml vendored Normal file
View File

@@ -0,0 +1,51 @@
on:
workflow_call:
inputs:
runs_on:
type: string
required: true
platform:
type: string
required: true
gotenberg_version:
type: string
required: true
alternate_repository:
type: string
default: ''
outputs:
tags:
value: ${{ jobs.build_push.outputs.tags }}
permissions:
contents: read
jobs:
build_push:
name: Build and push Docker images
runs-on: ${{ inputs.runs_on }}
outputs:
tags: ${{ steps.build_push.outputs.tags }}
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push ${{ inputs.platform }}
id: build_push
uses: gotenberg/gotenberg-release-action/actions/build-push@main
with:
version: ${{ inputs.gotenberg_version }}
platform: ${{ inputs.platform }}
alternate_repository: ${{ inputs.alternate_repository }}

28
.github/workflows/__delete.yml vendored Normal file
View File

@@ -0,0 +1,28 @@
on:
workflow_call:
inputs:
gotenberg_version:
type: string
required: true
alternate_repository:
type: string
default: ''
permissions:
contents: read
jobs:
delete:
name: Delete Docker images
runs-on: ubuntu-latest
steps:
- name: Check out code
uses: actions/checkout@v4
- name: Delete
uses: gotenberg/gotenberg-release-action/actions/delete@main
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
version: ${{ inputs.gotenberg_version }}
alternate_repository: ${{ inputs.alternate_repository }}

40
.github/workflows/__merge-clean.yml vendored Normal file
View File

@@ -0,0 +1,40 @@
on:
workflow_call:
inputs:
tags:
type: string
required: true
alternate_registry:
type: string
default: ''
permissions:
contents: read
jobs:
merge_clean:
name: Merge and clean Docker images
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v4
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Merge and clean
uses: gotenberg/gotenberg-release-action/actions/merge-clean@main
with:
docker_hub_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_hub_password: ${{ secrets.DOCKERHUB_TOKEN }}
tags: ${{ inputs.tags }}
alternate_registry: ${{ inputs.alternate_registry }}

View File

@@ -0,0 +1,58 @@
name: Continuous Delivery
on:
release:
types: [ published ]
permissions:
contents: read
jobs:
release_amd64:
name: Release linux/amd64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/amd64
gotenberg_version: ${{ github.event.release.tag_name }}
release_386:
name: Release linux/386
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/386
gotenberg_version: ${{ github.event.release.tag_name }}
release_arm64:
name: Release linux/arm64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm64
gotenberg_version: ${{ github.event.release.tag_name }}
release_arm_v7:
name: Release linux/arm/v7
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm/v7
gotenberg_version: ${{ github.event.release.tag_name }}
merge_clean_release_tags:
needs:
- release_amd64
- release_386
- release_arm64
- release_arm_v7
name: Merge and clean release tags
uses: ./.github/workflows/__merge-clean.yml
secrets: inherit
with:
tags: "${{ needs.release_amd64.outputs.tags }},${{ needs.release_386.outputs.tags }},${{ needs.release_arm64.outputs.tags }},${{ needs.release_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine

View File

@@ -0,0 +1,188 @@
name: Continuous Integration
on:
push:
branches:
- main
pull_request:
branches:
- main
concurrency:
group: ${{ (github.event_name == 'pull_request' && github.event.pull_request.number) || 'main' }}
cancel-in-progress: true
permissions:
contents: write
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: false
- name: Checkout source code
uses: actions/checkout@v4
- name: Run linters
uses: golangci/golangci-lint-action@v6
with:
version: v1.63.4
tests:
needs:
- lint
name: Tests
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Checkout source code
uses: actions/checkout@v4
- name: Build testing environment
run: make build build-tests
- name: Run tests
run: make tests-once
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
snapshot_amd64:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot linux/amd64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/amd64
gotenberg_version: pr-${{ github.event.pull_request.number }}
alternate_repository: snapshot
snapshot_386:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot linux/386
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/386
gotenberg_version: pr-${{ github.event.pull_request.number }}
alternate_repository: snapshot
snapshot_arm64:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot linux/arm64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm64
gotenberg_version: pr-${{ github.event.pull_request.number }}
alternate_repository: snapshot
snapshot_arm_v7:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot linux/arm/v7
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm/v7
gotenberg_version: pr-${{ github.event.pull_request.number }}
alternate_repository: snapshot
merge_clean_snapshot_tags:
needs:
- snapshot_amd64
- snapshot_386
- snapshot_arm64
- snapshot_arm_v7
name: Merge and clean snapshot tags
uses: ./.github/workflows/__merge-clean.yml
secrets: inherit
with:
tags: "${{ needs.snapshot_amd64.outputs.tags }},${{ needs.snapshot_386.outputs.tags }},${{ needs.snapshot_arm64.outputs.tags }},${{ needs.snapshot_arm_v7.outputs.tags }}"
edge_amd64:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
name: Edge linux/amd64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/amd64
gotenberg_version: edge
edge_386:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
name: Edge linux/386
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-latest
platform: linux/386
gotenberg_version: edge
edge_arm64:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
name: Edge linux/arm64
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm64
gotenberg_version: edge
edge_arm_v7:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
name: Edge linux/arm/v7
uses: ./.github/workflows/__build-push.yml
secrets: inherit
with:
runs_on: ubuntu-24.04-arm
platform: linux/arm/v7
gotenberg_version: edge
merge_clean_edge_tags:
needs:
- edge_amd64
- edge_386
- edge_arm64
- edge_arm_v7
name: Merge and clean edge tags
uses: ./.github/workflows/__merge-clean.yml
secrets: inherit
with:
tags: "${{ needs.edge_amd64.outputs.tags }},${{ needs.edge_386.outputs.tags }},${{ needs.edge_arm64.outputs.tags }},${{ needs.edge_arm_v7.outputs.tags }}"
alternate_registry: thecodingmachine

View File

@@ -1,26 +0,0 @@
name: Continuous Delivery
on:
release:
types: [ published ]
jobs:
release:
name: Release Docker image
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Checkout source code
uses: actions/checkout@v4
- name: Log in to Docker Hub Container Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image for release
run: |
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }}
make release GOTENBERG_VERSION=${{ github.event.release.tag_name }} DOCKER_REGISTRY=thecodingmachine

View File

@@ -1,95 +0,0 @@
name: Continuous Integration
on:
push:
branches:
- main
pull_request:
branches:
- main
jobs:
lint:
name: Lint
runs-on: ubuntu-latest
steps:
- name: Setup Go
uses: actions/setup-go@v5
with:
go-version: '1.23'
cache: false
- name: Checkout source code
uses: actions/checkout@v4
- name: Run linters
uses: golangci/golangci-lint-action@v6
with:
version: v1.61.0
tests:
needs:
- lint
name: Tests
# TODO: once arm64 actions are available, also run the tests on this architecture.
# See: https://github.com/actions/virtual-environments/issues/2552#issuecomment-771478000.
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Checkout source code
uses: actions/checkout@v4
- name: Build testing environment
run: make build build-tests
- name: Run tests
run: make tests-once
- name: Upload to Codecov
uses: codecov/codecov-action@v4
with:
token: ${{ secrets.CODECOV_TOKEN }}
verbose: true
snapshot_release:
if: github.event_name == 'pull_request'
needs:
- tests
name: Snapshot release
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Checkout source code
uses: actions/checkout@v4
- name: Log in to Docker Hub Container Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push snapshot Docker image (linux/amd64)
run: make release GOTENBERG_VERSION=${{ github.head_ref }} DOCKER_REPOSITORY=snapshot LINUX_AMD64_RELEASE=true
edge_release:
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
needs:
- tests
name: Edge release
runs-on: ubuntu-latest
steps:
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Checkout source code
uses: actions/checkout@v4
- name: Log in to Docker Hub Container Registry
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Build and push Docker image for main branch
run: |
make release GOTENBERG_VERSION=edge
make release GOTENBERG_VERSION=edge DOCKER_REGISTRY=thecodingmachine

View File

@@ -0,0 +1,18 @@
name: Pull Request Cleanup
on:
pull_request:
types: [ closed ]
permissions:
contents: read
jobs:
cleanup:
name: Cleanup
uses: ./.github/workflows/__delete.yml
secrets: inherit
with:
gotenberg_version: pr-${{ github.event.pull_request.number }}
alternate_repository: snapshot

View File

@@ -6,10 +6,6 @@ linters-settings:
- prefix(github.com/gotenberg/gotenberg/v8) - prefix(github.com/gotenberg/gotenberg/v8)
skip-generated: true skip-generated: true
custom-order: true custom-order: true
# Until https://github.com/securego/gosec/issues/1187 is resolved.
gosec:
excludes:
- G115
linters: linters:
disable-all: true disable-all: true

View File

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

View File

@@ -1,3 +1,5 @@
include .env
.PHONY: help .PHONY: help
help: ## Show the help help: ## Show the help
@grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}' @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-30s\033[0m %s\n", $$1, $$2}'
@@ -5,17 +7,6 @@ help: ## Show the help
.PHONY: it .PHONY: it
it: build build-tests ## Initialize the development environment it: build build-tests ## Initialize the development environment
GOLANG_VERSION=1.23
DOCKER_REGISTRY=gotenberg
DOCKER_REPOSITORY=gotenberg
GOTENBERG_VERSION=snapshot
GOTENBERG_USER_GID=1001
GOTENBERG_USER_UID=1001
NOTO_COLOR_EMOJI_VERSION=v2.047 # See https://github.com/googlefonts/noto-emoji/releases.
PDFTK_VERSION=v3.3.3 # See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
PDFCPU_VERSION=v0.8.1 # See https://github.com/pdfcpu/pdfcpu/releases.
GOLANGCI_LINT_VERSION=v1.61.0 # See https://github.com/golangci/golangci-lint/releases.
.PHONY: build .PHONY: build
build: ## Build the Gotenberg's Docker image build: ## Build the Gotenberg's Docker image
docker build \ docker build \
@@ -27,7 +18,7 @@ build: ## Build the Gotenberg's Docker image
--build-arg PDFTK_VERSION=$(PDFTK_VERSION) \ --build-arg PDFTK_VERSION=$(PDFTK_VERSION) \
--build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \ --build-arg PDFCPU_VERSION=$(PDFCPU_VERSION) \
-t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \ -t $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
-f build/Dockerfile . -f $(DOCKERFILE) $(DOCKER_BUILD_CONTEXT)
GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s GOTENBERG_GRACEFUL_SHUTDOWN_DURATION=30s
API_PORT=3000 API_PORT=3000
@@ -36,7 +27,7 @@ API_BIND_IP=
API_START_TIMEOUT=30s API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_BODY_LIMIT= API_BODY_LIMIT=
API_ROOT_PATH=/ API_ROOT_PATH="/"
API_TRACE_HEADER=Gotenberg-Trace API_TRACE_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME= GOTENBERG_API_BASIC_AUTH_USERNAME=
@@ -46,7 +37,7 @@ API-DOWNLOAD-FROM-DENY-LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4 API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false API_DISABLE_HEALTH_CHECK_LOGGING=false
CHROMIUM_RESTART_AFTER=0 CHROMIUM_RESTART_AFTER=10
CHROMIUM_MAX_QUEUE_SIZE=0 CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_AUTO_START=false CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s CHROMIUM_START_TIMEOUT=20s
@@ -73,6 +64,8 @@ LOG_FORMAT=auto
LOG_FIELDS_PREFIX= LOG_FIELDS_PREFIX=
PDFENGINES_ENGINES= PDFENGINES_ENGINES=
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
PDFENGINES_FLATTEN_ENGINES=qpdf
PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine
PDFENGINES_READ_METADATA_ENGINES=exiftool PDFENGINES_READ_METADATA_ENGINES=exiftool
PDFENGINES_WRITE_METADATA_ENGINES=exiftool PDFENGINES_WRITE_METADATA_ENGINES=exiftool
@@ -141,6 +134,8 @@ run: ## Start a Gotenberg container
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \ --log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--pdfengines-engines=$(PDFENGINES_ENGINES) \ --pdfengines-engines=$(PDFENGINES_ENGINES) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \ --pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_FLATTEN_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \ --pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \ --pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \ --pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
@@ -197,20 +192,3 @@ fmt: ## Format the code and "optimize" the dependencies
godoc: ## Run a webserver with Gotenberg godoc godoc: ## Run a webserver with Gotenberg godoc
$(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8) $(info http://localhost:6060/pkg/github.com/gotenberg/gotenberg/v8)
godoc -http=:6060 godoc -http=:6060
LINUX_AMD64_RELEASE=false
.PHONY: release
release: ## Build the Gotenberg's Docker image and push it to a Docker repository
./scripts/release.sh \
$(GOLANG_VERSION) \
$(GOTENBERG_VERSION) \
$(GOTENBERG_USER_GID) \
$(GOTENBERG_USER_UID) \
$(NOTO_COLOR_EMOJI_VERSION) \
$(PDFTK_VERSION) \
$(PDFCPU_VERSION) \
$(DOCKER_REGISTRY) \
$(DOCKER_REPOSITORY) \
$(LINUX_AMD64_RELEASE)

View File

@@ -6,7 +6,7 @@
<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/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://hub.docker.com/r/thecodingmachine/gotenberg"><img alt="Total downloads (thecodingmachine/gotenberg)" src="https://img.shields.io/docker/pulls/thecodingmachine/gotenberg"></a>
<br> <br>
<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://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> <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>
<a href="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"><img alt="Code coverage" src="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"></a> <a href="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"><img alt="Code coverage" src="https://codecov.io/gh/gotenberg/gotenberg/branch/main/graph/badge.svg"></a>
</p> </p>

View File

@@ -61,7 +61,6 @@ ARG GOTENBERG_USER_GID
ARG GOTENBERG_USER_UID ARG GOTENBERG_USER_UID
ARG NOTO_COLOR_EMOJI_VERSION ARG NOTO_COLOR_EMOJI_VERSION
ARG PDFTK_VERSION ARG PDFTK_VERSION
ARG TMP_CHOMIUM_VERSION_ARMHF="116.0.5845.180-1~deb12u1"
LABEL org.opencontainers.image.title="Gotenberg" \ LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A Docker-powered stateless API for PDF files." \ org.opencontainers.image.description="A Docker-powered stateless API for PDF files." \
@@ -82,6 +81,7 @@ RUN \
# Install system dependencies required for the next instructions or debugging. # Install system dependencies required for the next instructions or debugging.
# Note: tini is a helper for reaping zombie processes. # Note: tini is a helper for reaping zombie processes.
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 default-jre-headless &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 default-jre-headless &&\
# Cleanup. # Cleanup.
# Note: the Debian image does automatically a clean after each install thanks to a hook. # Note: the Debian image does automatically a clean after each install thanks to a hook.
@@ -96,6 +96,7 @@ RUN \
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html. # https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html.
curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\ curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
./ttf-mscorefonts-installer_3.8.1_all.deb \ ./ttf-mscorefonts-installer_3.8.1_all.deb \
culmus \ culmus \
@@ -145,27 +146,18 @@ RUN \
# Install either Google Chrome stable on amd64 architecture or # Install either Google Chrome stable on amd64 architecture or
# Chromium on other architectures. # Chromium on other architectures.
# See https://github.com/gotenberg/gotenberg/issues/328. # See https://github.com/gotenberg/gotenberg/issues/328.
# FIXME:
# armhf is currently not working with the latest version of Chromium.
# See: https://github.com/gotenberg/gotenberg/issues/709.
/bin/bash -c \ /bin/bash -c \
'set -e &&\ 'set -e &&\
if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \ if [[ "$(dpkg --print-architecture)" == "amd64" ]]; then \
curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\ curl https://dl.google.com/linux/linux_signing_key.pub | apt-key add - &&\
echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\ echo "deb http://dl.google.com/linux/chrome/deb/ stable main" | tee /etc/apt/sources.list.d/google-chrome.list &&\
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends --allow-unauthenticated google-chrome-stable &&\
mv /usr/bin/google-chrome-stable /usr/bin/chromium; \ mv /usr/bin/google-chrome-stable /usr/bin/chromium; \
elif [[ "$(dpkg --print-architecture)" == "armhf" ]]; then \
apt-get update -qq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends devscripts &&\
debsnap chromium-common "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
debsnap chromium "$TMP_CHOMIUM_VERSION_ARMHF" -v --force --binary --architecture armhf &&\
DEBIAN_FRONTEND=noninteractive apt-get install --fix-broken -y -qq --no-install-recommends "./binary-chromium-common/chromium-common_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" "./binary-chromium/chromium_${TMP_CHOMIUM_VERSION_ARMHF}_armhf.deb" &&\
DEBIAN_FRONTEND=noninteractive apt-get purge -y -qq devscripts &&\
rm -rf ./binary-chromium-common/* ./binary-chromium/*; \
else \ else \
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium; \
fi' &&\ fi' &&\
# Verify installation. # Verify installation.
@@ -177,6 +169,7 @@ RUN \
# Install LibreOffice & unoconverter. # Install LibreOffice & unoconverter.
echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list &&\ echo "deb http://deb.debian.org/debian bookworm-backports main" >> /etc/apt/sources.list &&\
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t bookworm-backports libreoffice &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t bookworm-backports libreoffice &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.0.1/unoconv -o /usr/bin/unoconverter &&\ curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.0.1/unoconv -o /usr/bin/unoconverter &&\
chmod +x /usr/bin/unoconverter &&\ chmod +x /usr/bin/unoconverter &&\
@@ -196,6 +189,7 @@ RUN \
echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \ echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
chmod +x /usr/bin/pdftk &&\ chmod +x /usr/bin/pdftk &&\
apt-get update -qq &&\ apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
# See https://github.com/nextcloud/docker/issues/380. # See https://github.com/nextcloud/docker/issues/380.
mkdir -p /usr/share/man/man1 &&\ mkdir -p /usr/share/man/man1 &&\

45
go.mod
View File

@@ -6,36 +6,39 @@ require (
github.com/alexliesenfeld/health v0.8.0 github.com/alexliesenfeld/health v0.8.0
github.com/andybalholm/brotli v1.1.1 // indirect github.com/andybalholm/brotli v1.1.1 // indirect
github.com/barasher/go-exiftool v1.10.0 github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b github.com/chromedp/cdproto v0.0.0-20250126231910-1730200a0f74
github.com/chromedp/chromedp v0.11.2 github.com/chromedp/chromedp v0.12.1
github.com/golang/snappy v0.0.4 // indirect github.com/golang/snappy v0.0.4 // indirect
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-retryablehttp v0.7.7 github.com/hashicorp/go-retryablehttp v0.7.7
github.com/klauspost/compress v1.17.11 // indirect github.com/klauspost/compress v1.17.11 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect github.com/klauspost/pgzip v1.2.6 // indirect
github.com/labstack/echo/v4 v4.12.0 github.com/labstack/echo/v4 v4.13.3
github.com/labstack/gommon v0.4.2 github.com/labstack/gommon v0.4.2
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mholt/archiver/v3 v3.5.1 github.com/mholt/archiver/v3 v3.5.1
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/nwaples/rardecode v1.1.3 // indirect github.com/nwaples/rardecode v1.1.3 // indirect
github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/pierrec/lz4/v4 v4.1.22 // indirect
github.com/prometheus/client_golang v1.20.5 github.com/prometheus/client_golang v1.20.5
github.com/russross/blackfriday/v2 v2.1.0 github.com/russross/blackfriday/v2 v2.1.0
github.com/spf13/pflag v1.0.5 github.com/spf13/pflag v1.0.6
github.com/ulikunitz/xz v0.5.12 // indirect github.com/ulikunitz/xz v0.5.12 // indirect
go.uber.org/multierr v1.11.0 go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.0 go.uber.org/zap v1.27.0
golang.org/x/crypto v0.29.0 // indirect golang.org/x/crypto v0.32.0 // indirect
golang.org/x/net v0.31.0 golang.org/x/net v0.34.0
golang.org/x/sync v0.9.0 golang.org/x/sync v0.10.0
golang.org/x/sys v0.27.0 // indirect golang.org/x/sys v0.29.0 // indirect
golang.org/x/term v0.26.0 golang.org/x/term v0.28.0
golang.org/x/text v0.20.0 golang.org/x/text v0.21.0
) )
require github.com/dlclark/regexp2 v1.11.4 require (
github.com/dlclark/regexp2 v1.11.4
github.com/shirou/gopsutil/v4 v4.24.12
)
require ( require (
github.com/aymerick/douceur v0.2.0 // indirect github.com/aymerick/douceur v0.2.0 // indirect
@@ -43,21 +46,27 @@ require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/chromedp/sysutil v1.1.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/httphead v0.1.0 // indirect
github.com/gobwas/pool v0.2.1 // indirect github.com/gobwas/pool v0.2.1 // indirect
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/golang-jwt/jwt v3.2.2+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/josharian/intern v1.0.0 // indirect github.com/josharian/intern v1.0.0 // indirect
github.com/mailru/easyjson v0.7.7 // indirect github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.1 // indirect github.com/prometheus/client_model v0.6.1 // indirect
github.com/prometheus/common v0.60.1 // indirect github.com/prometheus/common v0.62.0 // indirect
github.com/prometheus/procfs v0.15.1 // indirect github.com/prometheus/procfs v0.15.1 // indirect
github.com/tklauser/go-sysconf v0.3.14 // indirect
github.com/tklauser/numcpus v0.9.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect
golang.org/x/time v0.8.0 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
google.golang.org/protobuf v1.35.2 // indirect golang.org/x/time v0.9.0 // indirect
google.golang.org/protobuf v1.36.4 // indirect
) )

92
go.sum
View File

@@ -11,10 +11,10 @@ github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM=
github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= 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/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b h1:md1Gk5jkNE91SZxFDCMHmKqX0/GsEr1/VTejht0sCbY= github.com/chromedp/cdproto v0.0.0-20250126231910-1730200a0f74 h1:oul0R+ZyorVxVNr7bALbPolKvbXlpIefB4lDv5sjt00=
github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM= github.com/chromedp/cdproto v0.0.0-20250126231910-1730200a0f74/go.mod h1:RTGuBeCeabAJGi3OZf71a6cGa7oYBfBP75VJZFLv6SU=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0= github.com/chromedp/chromedp v0.12.1 h1:kBMblXk7xH5/6j3K9uk8d7/c+fzXWiUsCsPte0VMwOA=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8= github.com/chromedp/chromedp v0.12.1/go.mod h1:F6+wdq9LKFDMoyxhq46ZLz4VLXrsrCAR3sFqJz4Nqc0=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
@@ -25,16 +25,19 @@ github.com/dlclark/regexp2 v1.11.4/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cn
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY= github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 h1:iFaUwBSo5Svw6L7HYpRu/0lE3e0BaElwnNO1qkNQxBY=
github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s=
github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/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 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM=
github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU= github.com/gobwas/httphead v0.1.0 h1:exrUm0f4YX0L7EBwZHuCF4GDp8aJfVeBrlLQrs6NqWU=
github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM= github.com/gobwas/httphead v0.1.0/go.mod h1:O/RXo79gxV8G+RqlR/otEwx4Q36zl9rqC5u12GKvMCM=
github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og= github.com/gobwas/pool v0.2.1 h1:xfeeEhW7pwmX8nuLVlqbzVc7udMDrwetjEv+TZIz1og=
github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw= github.com/gobwas/pool v0.2.1/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs= github.com/gobwas/ws v1.4.0 h1:CTaoG1tojrh4ucGPcoJFiAQUAsEWekEWvLy7GsVNqGs=
github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc= github.com/gobwas/ws v1.4.0/go.mod h1:G3gNqMNtPppf5XUz7O4shetPpcZ1VJ7zt18dlUeakrc=
github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY=
github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I=
github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.2/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM=
github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q= github.com/golang/snappy v0.0.4/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
@@ -63,17 +66,18 @@ github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= 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/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw=
github.com/labstack/echo/v4 v4.12.0 h1:IKpw49IMryVB2p1a4dzwlhP1O2Tf2E0Ir/450lH+kI0= github.com/labstack/echo/v4 v4.13.3 h1:pwhpCPrTl5qry5HRdM5FwdXnhXSLSY+WE+YQSeCaafY=
github.com/labstack/echo/v4 v4.12.0/go.mod h1:UP9Cr2DJXbOK3Kr9ONYzNowSh7HP0aG0ShAyycHSJvM= github.com/labstack/echo/v4 v4.13.3/go.mod h1:o90YNEeQWjDozo584l7AwhJMHN0bOC4tAfg+Xox9q5g=
github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0= 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/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 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/mailru/easyjson v0.7.7 h1:UGYAvKxe3sBsEDzO8ZeWOSlIQfWFlxbzLZe7hwFURr0= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683 h1:7UMa6KCCMjZEMDtTVdcGu0B1GmmC7QJKiCCjyTAWQy0=
github.com/mailru/easyjson v0.7.7/go.mod h1:xzfreul335JAWq5oZzymOObrkdz5UnU4kGfJJLY9Nlc= github.com/lufia/plan9stats v0.0.0-20240909124753-873cd0166683/go.mod h1:ilwx/Dta8jXAgpFYFvSWEMwxmbWXyiUHkd5FwyKhb5k=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo= github.com/mholt/archiver/v3 v3.5.1 h1:rDjOBX9JSF5BvoJGvjqK479aL70qh9DIpZCl+k7Clwo=
@@ -88,28 +92,36 @@ github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWk
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= 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/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.2/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pierrec/lz4/v4 v4.1.21 h1:yOVMLb6qSIDP67pl/5F7RepeKYu/VmTyEXvuMI5d9mQ= github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
github.com/pierrec/lz4/v4 v4.1.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y= github.com/prometheus/client_golang v1.20.5 h1:cxppBPuYhUnsO6yo/aoRol4L7q7UFfdm+bR9r+8l63Y=
github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE= github.com/prometheus/client_golang v1.20.5/go.mod h1:PIEt8X02hGcP8JWbeHyeZ53Y/jReSnHgO035n//V5WE=
github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E= github.com/prometheus/client_model v0.6.1 h1:ZKSh/rekM+n3CeS952MLRAdFwIKqeY8b62p8ais2e9E=
github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY= github.com/prometheus/client_model v0.6.1/go.mod h1:OrxVMOVHjw3lKMa8+x6HeMGkHMQyHDk9E3jmP2AmGiY=
github.com/prometheus/common v0.60.1 h1:FUas6GcOw66yB/73KC+BOZoFJmbo/1pojoILArPAaSc= github.com/prometheus/common v0.62.0 h1:xasJaQlnWAeyHdUBeGjXmutelfJHWMRr+Fg4QszZ2Io=
github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= github.com/prometheus/common v0.62.0/go.mod h1:vyBcEuLSvWos9B1+CyL7JZ2up+uFzXhkqml0W5zIY1I=
github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc= github.com/prometheus/procfs v0.15.1 h1:YagwOFzUgYfKKHX6Dr+sHT7km/hxC76UB0learggepc=
github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk= github.com/prometheus/procfs v0.15.1/go.mod h1:fB45yRUv8NstnjriLhBQLuOUt+WW4BsoGhij/e3PBqk=
github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/shirou/gopsutil/v4 v4.24.12 h1:qvePBOk20e0IKA1QXrIIU+jmk+zEiYVVx06WjBRlZo4=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/shirou/gopsutil/v4 v4.24.12/go.mod h1:DCtMPAad2XceTeIAbGyVfycbYQNBGk2P8cvDi7/VN9o=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg= github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tklauser/go-sysconf v0.3.14 h1:g5vzr9iPFFz24v2KZXs/pvpvh8/V9Fw6vQK5ZZb78yU=
github.com/tklauser/go-sysconf v0.3.14/go.mod h1:1ym4lWMLUOhuBOPGtRcJm7tEGX4SCYNEEEtghGG/8uY=
github.com/tklauser/numcpus v0.9.0 h1:lmyCHtANi8aRUgkckBgoDk1nHCux3n2cgkJLXdQGPDo=
github.com/tklauser/numcpus v0.9.0/go.mod h1:SN6Nq1O3VychhC1npsWostA+oW+VOQTxZrS604NSRyI=
github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= github.com/ulikunitz/xz v0.5.9/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14=
github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc= github.com/ulikunitz/xz v0.5.12 h1:37Nm15o69RwBkXM0J6A5OlE67RZTfzUxTj8fB3dfcsc=
@@ -122,30 +134,34 @@ github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofm
github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos= github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU= github.com/xyproto/randomstring v1.0.5 h1:YtlWPoRdgMu3NZtP45drfy1GKoojuR7hmRcnhZqKjWU=
github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E= github.com/xyproto/randomstring v1.0.5/go.mod h1:rgmS5DeNXLivK7YprL0pY+lTuhNQW3iGxZ18UQApw/E=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= 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/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8=
go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
golang.org/x/crypto v0.29.0 h1:L5SG1JTTXupVV3n6sUqMTeWbjAyfPwoda2DLX8J8FrQ= golang.org/x/crypto v0.32.0 h1:euUpcYgM8WcP71gNpTqQCn6rC2t6ULUPiOzfWaXVVfc=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/crypto v0.32.0/go.mod h1:ZnnJkOaASj8g0AjIduWNlq2NRxL0PlBrbKVyZ6V/Ugc=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.34.0 h1:Mb7Mrk043xzHgnRM88suvJFwzVrRfHEHJEl5/71CKw0=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/net v0.34.0/go.mod h1:di0qlW3YNM5oh6GqDGQr92MyTozJPmybPK4Ev/Gm31k=
golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= golang.org/x/sync v0.10.0 h1:3NQrjDixjgGwUOCaF8w2+VYHv0Ve/vGYSbdkTa98gmQ=
golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= golang.org/x/sys v0.29.0 h1:TPYlXGxvx1MGTn2GiZDhnjPA9wZzZeGKHHmKhHYvgaU=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= golang.org/x/term v0.28.0 h1:/Ts8HFuMR2E6IP/jlo7QVLZHggjKQbhu/7H0LJFr3Gg=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
golang.org/x/text v0.20.0 h1:gK/Kv2otX8gz+wn7Rmb3vT96ZwuoxnQlY+HlJVj7Qug= golang.org/x/text v0.21.0 h1:zyQAAkrwaneQ066sspRyJaG9VNi/YJ1NfzcGB3hZ/qo=
golang.org/x/text v0.20.0/go.mod h1:D4IsuqiFMhST5bX19pQ9ikHC2GsaKyk/oF+pn3ducp4= golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
golang.org/x/time v0.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg= golang.org/x/time v0.9.0 h1:EsRrnYcQiGH+5FfbgvV4AP7qEZstoyrHB0DzarOQ4ZY=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.9.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.35.2 h1:8Ar7bF+apOIoThw1EdZl0p1oWvMqTHmpA2fRTyZO8io= google.golang.org/protobuf v1.36.4 h1:6A3ZDJHn/eNqc1i+IdefRzy/9PokBTPvcqMySR7NNIM=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.4/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

View File

@@ -3,22 +3,56 @@ package gotenberg
import ( import (
"fmt" "fmt"
"os" "os"
"path/filepath"
"strings"
"github.com/google/uuid" "github.com/google/uuid"
) )
// MkdirAll defines the method signature for create a directory. Implement this
// interface if you don't want to rely on [os.MkdirAll], notably for testing
// purpose.
type MkdirAll interface {
// MkdirAll uses the same signature as [os.MkdirAll].
MkdirAll(path string, perm os.FileMode) error
}
// OsMkdirAll implements the [MkdirAll] interface with [os.MkdirAll].
type OsMkdirAll struct{}
// MkdirAll is a wrapper around [os.MkdirAll].
func (o *OsMkdirAll) MkdirAll(path string, perm os.FileMode) error { return os.MkdirAll(path, perm) }
// PathRename defines the method signature for renaming files. Implement this
// interface if you don't want to rely on [os.Rename], notably for testing
// purpose.
type PathRename interface {
// Rename uses the same signature as [os.Rename].
Rename(oldpath, newpath string) error
}
// OsPathRename implements the [PathRename] interface with [os.Rename].
type OsPathRename struct{}
// Rename is a wrapper around [os.Rename].
func (o *OsPathRename) Rename(oldpath, newpath string) error {
return os.Rename(oldpath, newpath)
}
// FileSystem provides utilities for managing temporary directories. It creates // FileSystem provides utilities for managing temporary directories. It creates
// unique directory names based on UUIDs to ensure isolation of temporary files // unique directory names based on UUIDs to ensure isolation of temporary files
// for different modules. // for different modules.
type FileSystem struct { type FileSystem struct {
workingDir string workingDir string
mkdirAll MkdirAll
} }
// NewFileSystem initializes a new [FileSystem] instance with a unique working // NewFileSystem initializes a new [FileSystem] instance with a unique working
// directory. // directory.
func NewFileSystem() *FileSystem { func NewFileSystem(mkdirAll MkdirAll) *FileSystem {
return &FileSystem{ return &FileSystem{
workingDir: uuid.NewString(), workingDir: uuid.NewString(),
mkdirAll: mkdirAll,
} }
} }
@@ -44,7 +78,7 @@ func (fs *FileSystem) NewDirPath() string {
func (fs *FileSystem) MkdirAll() (string, error) { func (fs *FileSystem) MkdirAll() (string, error) {
path := fs.NewDirPath() path := fs.NewDirPath()
err := os.MkdirAll(path, 0o755) err := fs.mkdirAll.MkdirAll(path, 0o755)
if err != nil { if err != nil {
return "", fmt.Errorf("create directory %s: %w", path, err) return "", fmt.Errorf("create directory %s: %w", path, err)
} }
@@ -52,10 +86,27 @@ func (fs *FileSystem) MkdirAll() (string, error) {
return path, nil return path, nil
} }
// PathRename defines the method signature for renaming files. Implement this // WalkDir walks through the root level of a directory and returns a list of
// interface if you don't want to rely on [os.Rename], notably for testing // files paths that match the specified file extension.
// purpose. func WalkDir(dir, ext string) ([]string, error) {
type PathRename interface { var files []string
// Rename uses the same signature as [os.Rename]. err := filepath.Walk(dir, func(path string, info os.FileInfo, pathErr error) error {
Rename(oldpath, newpath string) error if pathErr != nil {
return pathErr
}
if info.IsDir() {
return nil
}
if strings.EqualFold(filepath.Ext(info.Name()), ext) {
files = append(files, path)
}
return nil
})
return files, err
} }
// Interface guards.
var (
_ MkdirAll = (*OsMkdirAll)(nil)
_ PathRename = (*OsPathRename)(nil)
)

View File

@@ -1,14 +1,84 @@
package gotenberg package gotenberg
import ( import (
"errors"
"fmt" "fmt"
"io"
"os" "os"
"path/filepath"
"reflect"
"strings" "strings"
"testing" "testing"
"github.com/google/uuid"
) )
func TestOsMkdirAll_MkdirAll(t *testing.T) {
dirPath, err := NewFileSystem(new(OsMkdirAll)).MkdirAll()
if err != nil {
t.Fatalf("create working directory: %v", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("remove working directory: %v", err)
}
}
func TestOsPathRename_Rename(t *testing.T) {
dirPath, err := NewFileSystem(new(OsMkdirAll)).MkdirAll()
if err != nil {
t.Fatalf("create working directory: %v", err)
}
path := "/tests/test/testdata/api/sample1.txt"
copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
in, err := os.Open(path)
if err != nil {
t.Fatalf("open file: %v", err)
}
defer func() {
err := in.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}()
out, err := os.Create(copyPath)
if err != nil {
t.Fatalf("create new file: %v", err)
}
defer func() {
err := out.Close()
if err != nil {
t.Fatalf("close new file: %v", err)
}
}()
_, err = io.Copy(out, in)
if err != nil {
t.Fatalf("copy file to new file: %v", err)
}
rename := new(OsPathRename)
newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
err = rename.Rename(copyPath, newPath)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("remove working directory: %v", err)
}
}
func TestFileSystem_WorkingDir(t *testing.T) { func TestFileSystem_WorkingDir(t *testing.T) {
fs := NewFileSystem() fs := NewFileSystem(new(MkdirAllMock))
dirName := fs.WorkingDir() dirName := fs.WorkingDir()
if dirName == "" { if dirName == "" {
@@ -17,7 +87,7 @@ func TestFileSystem_WorkingDir(t *testing.T) {
} }
func TestFileSystem_WorkingDirPath(t *testing.T) { func TestFileSystem_WorkingDirPath(t *testing.T) {
fs := NewFileSystem() fs := NewFileSystem(new(MkdirAllMock))
expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir()) expectedPath := fmt.Sprintf("%s/%s", os.TempDir(), fs.WorkingDir())
if fs.WorkingDirPath() != expectedPath { if fs.WorkingDirPath() != expectedPath {
@@ -26,7 +96,7 @@ func TestFileSystem_WorkingDirPath(t *testing.T) {
} }
func TestFileSystem_NewDirPath(t *testing.T) { func TestFileSystem_NewDirPath(t *testing.T) {
fs := NewFileSystem() fs := NewFileSystem(new(MkdirAllMock))
newDir := fs.NewDirPath() newDir := fs.NewDirPath()
expectedPrefix := fs.WorkingDirPath() expectedPrefix := fs.WorkingDirPath()
@@ -36,20 +106,117 @@ func TestFileSystem_NewDirPath(t *testing.T) {
} }
func TestFileSystem_MkdirAll(t *testing.T) { func TestFileSystem_MkdirAll(t *testing.T) {
fs := NewFileSystem() for _, tc := range []struct {
scenario string
mkdirAll MkdirAll
expectError bool
}{
{
scenario: "error",
mkdirAll: &MkdirAllMock{
MkdirAllMock: func(path string, perm os.FileMode) error {
return errors.New("foo")
},
},
expectError: true,
},
{
scenario: "success",
mkdirAll: &MkdirAllMock{
MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
},
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
fs := NewFileSystem(tc.mkdirAll)
newPath, err := fs.MkdirAll() _, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
_, err = os.Stat(newPath) if !tc.expectError && err != nil {
if os.IsNotExist(err) { t.Fatalf("expected no error but got: %v", err)
t.Errorf("expected directory '%s' to exist but it doesn't", newPath) }
}
err = os.RemoveAll(fs.WorkingDirPath()) if tc.expectError && err == nil {
if err != nil { t.Fatal("expected error but got none")
t.Fatalf("expected no error while cleaning up but got: %v", err) }
})
}
}
func TestWalkDir(t *testing.T) {
for _, tc := range []struct {
scenario string
dir string
ext string
expectError bool
expectFiles []string
}{
{
scenario: "directory does not exist",
dir: uuid.NewString(),
ext: ".pdf",
expectError: true,
},
{
scenario: "find PDF files",
dir: func() string {
path := fmt.Sprintf("%s/a_directory", os.TempDir())
err := os.MkdirAll(path, 0o755)
if err != nil {
t.Fatalf(fmt.Sprintf("expected no error but got: %v", err))
}
err = os.WriteFile(fmt.Sprintf("%s/a_foo_file.pdf", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/a_bar_file.PDF", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
err = os.WriteFile(fmt.Sprintf("%s/a_baz_file.txt", path), []byte{1}, 0o755)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
return path
}(),
ext: ".pdf",
expectError: false,
expectFiles: []string{"/tmp/a_directory/a_bar_file.PDF", "/tmp/a_directory/a_foo_file.pdf"},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
defer func() {
err := os.RemoveAll(tc.dir)
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
files, err := WalkDir(tc.dir, tc.ext)
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")
}
if tc.expectError && err != nil {
return
}
if !reflect.DeepEqual(files, tc.expectFiles) {
t.Errorf("expected files %+v, but got %+v", tc.expectFiles, files)
}
})
} }
} }

View File

@@ -5,13 +5,14 @@ import (
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time"
"go.uber.org/zap" "go.uber.org/zap"
) )
// GarbageCollect scans the root path and deletes files or directories with // GarbageCollect scans the root path and deletes files or directories with
// names containing specific substrings. // names containing specific substrings and before a given experiation time.
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string) error { func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
logger = logger.Named("gc") logger = logger.Named("gc")
// To make sure that the next Walk method stays on // To make sure that the next Walk method stays on
@@ -36,7 +37,7 @@ func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string)
} }
for _, substr := range includeSubstr { for _, substr := range includeSubstr {
if strings.Contains(info.Name(), substr) || path == substr { if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
err := os.RemoveAll(path) err := os.RemoveAll(path)
if err != nil { if err != nil {
return fmt.Errorf("garbage collect '%s': %w", path, err) return fmt.Errorf("garbage collect '%s': %w", path, err)

View File

@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"testing" "testing"
"time"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap" "go.uber.org/zap"
@@ -64,7 +65,7 @@ func TestGarbageCollect(t *testing.T) {
} }
}() }()
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr) err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now())
if !tc.expectError && err != nil { if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)

View File

@@ -2,6 +2,7 @@ package gotenberg
import ( import (
"context" "context"
"os"
"go.uber.org/zap" "go.uber.org/zap"
) )
@@ -34,8 +35,12 @@ func (mod *ValidatorMock) Validate() error {
} }
// PdfEngineMock is a mock for the [PdfEngine] interface. // PdfEngineMock is a mock for the [PdfEngine] interface.
//
//nolint:dupl
type PdfEngineMock struct { type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath 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 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) 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 WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error
@@ -45,6 +50,14 @@ func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inpu
return engine.MergeMock(ctx, logger, inputPaths, outputPath) return engine.MergeMock(ctx, logger, inputPaths, outputPath)
} }
func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath)
}
func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return engine.FlattenMock(ctx, logger, inputPath)
}
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error { func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath) return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
} }
@@ -137,6 +150,15 @@ func (provider *MetricsProviderMock) Metrics() ([]Metric, error) {
return provider.MetricsMock() return provider.MetricsMock()
} }
// MkdirAllMock is a mock for the [MkdirAll] interface.
type MkdirAllMock struct {
MkdirAllMock func(path string, perm os.FileMode) error
}
func (mkdirAll *MkdirAllMock) MkdirAll(path string, perm os.FileMode) error {
return mkdirAll.MkdirAllMock(path, perm)
}
// PathRenameMock is a mock for the [PathRename] interface. // PathRenameMock is a mock for the [PathRename] interface.
type PathRenameMock struct { type PathRenameMock struct {
RenameMock func(oldpath, newpath string) error RenameMock func(oldpath, newpath string) error
@@ -156,4 +178,6 @@ var (
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil) _ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil) _ LoggerProvider = (*LoggerProviderMock)(nil)
_ MetricsProvider = (*MetricsProviderMock)(nil) _ MetricsProvider = (*MetricsProviderMock)(nil)
_ MkdirAll = (*MkdirAllMock)(nil)
_ PathRename = (*PathRenameMock)(nil)
) )

View File

@@ -2,6 +2,7 @@ package gotenberg
import ( import (
"context" "context"
"os"
"testing" "testing"
"go.uber.org/zap" "go.uber.org/zap"
@@ -52,6 +53,12 @@ func TestPDFEngineMock(t *testing.T) {
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil return nil
}, },
SplitMock: func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
return nil return nil
}, },
@@ -68,6 +75,16 @@ func TestPDFEngineMock(t *testing.T) {
t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err) t.Errorf("expected no error from PdfEngineMock.Merge, but got: %v", err)
} }
_, err = mock.Split(context.Background(), zap.NewNop(), SplitMode{}, "", "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Split, but got: %v", err)
}
err = mock.Flatten(context.Background(), zap.NewNop(), "")
if err != nil {
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
}
err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "") err = mock.Convert(context.Background(), zap.NewNop(), PdfFormats{}, "", "")
if err != nil { if err != nil {
t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err) t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err)
@@ -205,6 +222,19 @@ func TestMetricsProviderMock(t *testing.T) {
} }
} }
func TestMkdirAllMock(t *testing.T) {
mock := &MkdirAllMock{
MkdirAllMock: func(dir string, perm os.FileMode) error {
return nil
},
}
err := mock.MkdirAll("/foo", 0o755)
if err != nil {
t.Errorf("expected no error from MkdirAllMock.MkdirAll, but got: %v", err)
}
}
func TestPathRenameMock(t *testing.T) { func TestPathRenameMock(t *testing.T) {
mock := &PathRenameMock{ mock := &PathRenameMock{
RenameMock: func(oldpath, newpath string) error { RenameMock: func(oldpath, newpath string) error {

View File

@@ -12,6 +12,10 @@ var (
// PdfEngine interface is not supported by its current implementation. // PdfEngine interface is not supported by its current implementation.
ErrPdfEngineMethodNotSupported = errors.New("method not supported") ErrPdfEngineMethodNotSupported = errors.New("method not supported")
// ErrPdfSplitModeNotSupported is returned when the Split method of the
// PdfEngine interface does not sumport a requested PDF split mode.
ErrPdfSplitModeNotSupported = errors.New("split mode not supported")
// ErrPdfFormatNotSupported is returned when the Convert method of the // ErrPdfFormatNotSupported is returned when the Convert method of the
// PdfEngine interface does not support a requested PDF format conversion. // PdfEngine interface does not support a requested PDF format conversion.
ErrPdfFormatNotSupported = errors.New("PDF format not supported") ErrPdfFormatNotSupported = errors.New("PDF format not supported")
@@ -21,6 +25,30 @@ var (
ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value not supported") ErrPdfEngineMetadataValueNotSupported = errors.New("metadata value not supported")
) )
const (
// SplitModeIntervals represents a mode where a PDF is split at specific
// intervals.
SplitModeIntervals string = "intervals"
// SplitModePages represents a mode where a PDF is split at specific page
// ranges.
SplitModePages string = "pages"
)
// SplitMode gathers the data required to split a PDF into multiple parts.
type SplitMode struct {
// Mode is either "intervals" or "pages".
Mode string
// Span is either the intervals or the page ranges to extract, depending on
// the selected mode.
Span string
// Unify specifies whether to put extracted pages into a single file or as
// many files as there are page ranges. Only works with "pages" mode.
Unify bool
}
const ( const (
// PdfA1a represents the PDF/A-1a format. // PdfA1a represents the PDF/A-1a format.
PdfA1a string = "PDF/A-1a" PdfA1a string = "PDF/A-1a"
@@ -60,11 +88,22 @@ type PdfFormats struct {
// PdfEngine provides an interface for operations on PDFs. Implementations // PdfEngine provides an interface for operations on PDFs. Implementations
// can utilize various tools like PDFtk, or implement functionality directly in // can utilize various tools like PDFtk, or implement functionality directly in
// Go. // Go.
//
//nolint:dupl
type PdfEngine interface { type PdfEngine interface {
// Merge combines multiple PDFs into a single PDF. The resulting page order // Merge combines multiple PDFs into a single PDF. The resulting page order
// is determined by the order of files provided in inputPaths. // is determined by the order of files provided in inputPaths.
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
// Split splits a given PDF file.
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
// Flatten merges existing annotation appearances with page content,
// effectively deleting the original annotations. This process can flatten
// forms as well, as forms share a relationship with annotations. Note that
// this operation is irreversible.
Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error
// Convert transforms a given PDF to the specified formats defined in // Convert transforms a given PDF to the specified formats defined in
// PdfFormats. If no format, it does nothing. // PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error

View File

@@ -318,7 +318,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.logger = logger a.logger = logger
// File system. // File system.
a.fs = gotenberg.NewFileSystem() a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
return nil return nil
} }
@@ -456,14 +456,22 @@ func (a *Api) Start() error {
hardTimeout := a.timeout + (time.Duration(5) * time.Second) hardTimeout := a.timeout + (time.Duration(5) * time.Second)
// Basic auth?
var securityMiddleware echo.MiddlewareFunc
if a.basicAuthUsername != "" {
securityMiddleware = basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword)
} else {
securityMiddleware = func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
return next(c)
}
}
}
// Add the modules' routes and their specific middlewares. // Add the modules' routes and their specific middlewares.
for _, route := range a.routes { for _, route := range a.routes {
var middlewares []echo.MiddlewareFunc var middlewares []echo.MiddlewareFunc
middlewares = append(middlewares, securityMiddleware)
// Basic auth?
if a.basicAuthUsername != "" {
middlewares = append(middlewares, basicAuthMiddleware(a.basicAuthUsername, a.basicAuthPassword))
}
if route.IsMultipart { if route.IsMultipart {
middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg)) middlewares = append(middlewares, contextMiddleware(a.fs, a.timeout, a.bodyLimit, a.downloadFromCfg))
@@ -483,6 +491,24 @@ func (a *Api) Start() error {
) )
} }
// Root route.
a.srv.GET(
a.rootPath,
func(c echo.Context) error {
return c.HTML(http.StatusOK, `Hey, Gotenberg has no UI, it's an API. Head to the <a href="https://gotenberg.dev">documentation</a> to learn how to interact with it 🚀`)
},
securityMiddleware,
)
// Favicon route.
a.srv.GET(
fmt.Sprintf("%s%s", a.rootPath, "favicon.ico"),
func(c echo.Context) error {
return c.NoContent(http.StatusNoContent)
},
securityMiddleware,
)
// Let's not forget the health check routes... // Let's not forget the health check routes...
checks := append(a.healthChecks, health.WithTimeout(a.timeout)) checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...) checker := health.NewChecker(checks...)
@@ -509,6 +535,7 @@ func (a *Api) Start() error {
func(c echo.Context) error { func(c echo.Context) error {
return c.String(http.StatusOK, gotenberg.Version) return c.String(http.StatusOK, gotenberg.Version)
}, },
securityMiddleware,
) )
// Wait for all modules to be ready. // Wait for all modules to be ready.

View File

@@ -850,7 +850,7 @@ func TestApi_Start(t *testing.T) {
}, },
} }
mod.readyFn = tc.readyFn mod.readyFn = tc.readyFn
mod.fs = gotenberg.NewFileSystem() mod.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
mod.logger = zap.NewNop() mod.logger = zap.NewNop()
err := mod.Start() err := mod.Start()
@@ -866,15 +866,33 @@ func TestApi_Start(t *testing.T) {
return return
} }
// health requests. // root request.
recorder := httptest.NewRecorder() recorder := httptest.NewRecorder()
rootRequest := httptest.NewRequest(http.MethodGet, "/", nil)
rootRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, rootRequest)
if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
}
// favicon request.
recorder = httptest.NewRecorder()
faviconRequest := httptest.NewRequest(http.MethodGet, "/favicon.ico", nil)
faviconRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, faviconRequest)
if recorder.Code != http.StatusNoContent {
t.Errorf("expected %d status code but got %d", http.StatusNoContent, recorder.Code)
}
// health requests.
recorder = httptest.NewRecorder()
healthGetRequest := httptest.NewRequest(http.MethodGet, "/health", nil) healthGetRequest := httptest.NewRequest(http.MethodGet, "/health", nil)
mod.srv.ServeHTTP(recorder, healthGetRequest) mod.srv.ServeHTTP(recorder, healthGetRequest)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)
} }
recorder = httptest.NewRecorder()
healthHeadRequest := httptest.NewRequest(http.MethodHead, "/health", nil) healthHeadRequest := httptest.NewRequest(http.MethodHead, "/health", nil)
mod.srv.ServeHTTP(recorder, healthHeadRequest) mod.srv.ServeHTTP(recorder, healthHeadRequest)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
@@ -882,7 +900,9 @@ func TestApi_Start(t *testing.T) {
} }
// version request. // version request.
recorder = httptest.NewRecorder()
versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil) versionRequest := httptest.NewRequest(http.MethodGet, "/version", nil)
versionRequest.SetBasicAuth(mod.basicAuthUsername, mod.basicAuthPassword)
mod.srv.ServeHTTP(recorder, versionRequest) mod.srv.ServeHTTP(recorder, versionRequest)
if recorder.Code != http.StatusOK { if recorder.Code != http.StatusOK {
t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code) t.Errorf("expected %d status code but got %d", http.StatusOK, recorder.Code)

View File

@@ -47,6 +47,7 @@ type Context struct {
logger *zap.Logger logger *zap.Logger
echoCtx echo.Context echoCtx echo.Context
mkdirAll gotenberg.MkdirAll
pathRename gotenberg.PathRename pathRename gotenberg.PathRename
context.Context context.Context
} }
@@ -81,12 +82,6 @@ type downloadFrom struct {
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"` ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
} }
type osPathRename struct{}
func (o *osPathRename) Rename(oldpath, newpath string) error {
return os.Rename(oldpath, newpath)
}
// newContext returns a [Context] by parsing a "multipart/form-data" request. // 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) { 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(context.Background(), timeout)
@@ -112,7 +107,8 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
cancelled: false, cancelled: false,
logger: logger, logger: logger,
echoCtx: echoCtx, echoCtx: echoCtx,
pathRename: new(osPathRename), mkdirAll: new(gotenberg.OsMkdirAll),
pathRename: new(gotenberg.OsPathRename),
Context: processCtx, Context: processCtx,
} }
@@ -414,9 +410,21 @@ func (ctx *Context) GeneratePath(extension string) string {
return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension) return fmt.Sprintf("%s/%s%s", ctx.dirPath, uuid.New().String(), extension)
} }
// CreateSubDirectory creates a subdirectory within the context's working
// directory.
func (ctx *Context) CreateSubDirectory(dirName string) (string, error) {
path := fmt.Sprintf("%s/%s", ctx.dirPath, dirName)
err := ctx.mkdirAll.MkdirAll(path, 0o755)
if err != nil {
return "", fmt.Errorf("create sub-directory %s: %w", path, err)
}
return path, nil
}
// Rename is just a wrapper around [os.Rename], as we need to mock this // Rename is just a wrapper around [os.Rename], as we need to mock this
// behavior in our tests. // behavior in our tests.
func (ctx *Context) Rename(oldpath, newpath string) error { func (ctx *Context) Rename(oldpath, newpath string) error {
ctx.Log().Debug(fmt.Sprintf("rename %s to %s", oldpath, newpath))
err := ctx.pathRename.Rename(oldpath, newpath) err := ctx.pathRename.Rename(oldpath, newpath)
if err != nil { if err != nil {
return fmt.Errorf("rename path: %w", err) return fmt.Errorf("rename path: %w", err)
@@ -496,8 +504,3 @@ func (ctx *Context) OutputFilename(outputPath string) string {
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath)) return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))
} }
// Interface guard.
var (
_ gotenberg.PathRename = (*osPathRename)(nil)
)

View File

@@ -4,78 +4,22 @@ import (
"bytes" "bytes"
"context" "context"
"errors" "errors"
"fmt"
"io"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath"
"reflect" "reflect"
"strings" "strings"
"testing" "testing"
"time" "time"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func TestOsPathRename_Rename(t *testing.T) {
dirPath, err := gotenberg.NewFileSystem().MkdirAll()
if err != nil {
t.Fatalf("create working directory: %v", err)
}
path := "/tests/test/testdata/api/sample1.txt"
copyPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
in, err := os.Open(path)
if err != nil {
t.Fatalf("open file: %v", err)
}
defer func() {
err := in.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}()
out, err := os.Create(copyPath)
if err != nil {
t.Fatalf("create new file: %v", err)
}
defer func() {
err := out.Close()
if err != nil {
t.Fatalf("close new file: %v", err)
}
}()
_, err = io.Copy(out, in)
if err != nil {
t.Fatalf("copy file to new file: %v", err)
}
rename := new(osPathRename)
newPath := filepath.Join(dirPath, fmt.Sprintf("%s.txt", uuid.NewString()))
err = rename.Rename(copyPath, newPath)
if err != nil {
t.Errorf("expected no error but got: %v", err)
}
err = os.RemoveAll(dirPath)
if err != nil {
t.Fatalf("remove working directory: %v", err)
}
}
func TestNewContext(t *testing.T) { func TestNewContext(t *testing.T) {
defaultAllowList, err := regexp2.Compile("", 0) defaultAllowList, err := regexp2.Compile("", 0)
if err != nil { if err != nil {
@@ -548,7 +492,7 @@ func TestNewContext(t *testing.T) {
} }
handler := func(c echo.Context) error { handler := func(c echo.Context) error {
ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123") ctx, cancel, err := newContext(c, zap.NewNop(), gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), time.Duration(10)*time.Second, tc.bodyLimit, tc.downloadFromCfg, "Gotenberg-Trace", "123")
defer cancel() defer cancel()
// Context already cancelled. // Context already cancelled.
defer cancel() defer cancel()
@@ -647,6 +591,42 @@ func TestContext_FormData(t *testing.T) {
} }
} }
func TestContext_CreateSubDirectory(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *Context
expectError bool
}{
{
scenario: "failure",
ctx: &Context{mkdirAll: &gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return errors.New("cannot rename")
}}},
expectError: true,
},
{
scenario: "success",
ctx: &Context{mkdirAll: &gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}}},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.logger = zap.NewNop()
_, err := tc.ctx.CreateSubDirectory("foo")
if tc.expectError && err == nil {
t.Fatal("expected error but got none", err)
}
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
})
}
}
func TestContext_GeneratePath(t *testing.T) { func TestContext_GeneratePath(t *testing.T) {
ctx := &Context{ ctx := &Context{
dirPath: "/foo", dirPath: "/foo",
@@ -680,6 +660,7 @@ func TestContext_Rename(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.logger = zap.NewNop()
err := tc.ctx.Rename("", "") err := tc.ctx.Rename("", "")
if tc.expectError && err == nil { if tc.expectError && err == nil {
@@ -788,7 +769,7 @@ func TestContext_BuildOutputFile(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
dirPath, err := fs.MkdirAll() dirPath, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected no erro but got: %v", err) t.Fatalf("expected no erro but got: %v", err)

View File

@@ -48,6 +48,10 @@ func ParseError(err error) (int, string) {
return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests) return http.StatusTooManyRequests, http.StatusText(http.StatusTooManyRequests)
} }
if errors.Is(err, gotenberg.ErrPdfSplitModeNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues"
}
if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) { if errors.Is(err, gotenberg.ErrPdfFormatNotSupported) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues" return http.StatusBadRequest, "At least one PDF engine cannot process the requested PDF format, while others may have failed to convert due to different issues"
} }
@@ -157,9 +161,12 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
trace := c.Get("trace").(string) trace := c.Get("trace").(string)
rootPath := c.Get("rootPath").(string) rootPath := c.Get("rootPath").(string)
// Create the request logger and add it to our locals. // Create the application logger and add it to our locals.
reqLogger := logger.With(zap.String("trace", trace)) appLogger := logger.
c.Set("logger", reqLogger.Named(func() string { With(zap.String("log_type", "application")).
With(zap.String("trace", trace))
c.Set("logger", appLogger.Named(func() string {
return strings.ReplaceAll( return strings.ReplaceAll(
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""), strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
"/", "/",
@@ -173,6 +180,11 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
c.Error(err) c.Error(err)
} }
// Create the access logger.
accessLogger := logger.
With(zap.String("log_type", "access")).
With(zap.String("trace", trace))
for _, path := range disableLoggingForPaths { for _, path := range disableLoggingForPaths {
URI := fmt.Sprintf("%s%s", rootPath, path) URI := fmt.Sprintf("%s%s", rootPath, path)
@@ -208,9 +220,9 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
fields[11] = zap.Int64("bytes_out", c.Response().Size) fields[11] = zap.Int64("bytes_out", c.Response().Size)
if err != nil { if err != nil {
reqLogger.Error(err.Error(), fields...) accessLogger.Error(err.Error(), fields...)
} else { } else {
reqLogger.Info("request handled", fields...) accessLogger.Info("request handled", fields...)
} }
return nil return nil

View File

@@ -38,6 +38,11 @@ func TestParseError(t *testing.T) {
expectStatus: http.StatusTooManyRequests, expectStatus: http.StatusTooManyRequests,
expectMessage: http.StatusText(http.StatusTooManyRequests), expectMessage: http.StatusText(http.StatusTooManyRequests),
}, },
{
err: gotenberg.ErrPdfSplitModeNotSupported,
expectStatus: http.StatusBadRequest,
expectMessage: "At least one PDF engine cannot process the requested PDF split mode, while others may have failed to split due to different issues",
},
{ {
err: gotenberg.ErrPdfFormatNotSupported, err: gotenberg.ErrPdfFormatNotSupported,
expectStatus: http.StatusBadRequest, expectStatus: http.StatusBadRequest,
@@ -462,7 +467,7 @@ func TestContextMiddleware(t *testing.T) {
c.Set("trace", "foo") c.Set("trace", "foo")
c.Set("startTime", time.Now()) c.Set("startTime", time.Now())
err := contextMiddleware(gotenberg.NewFileSystem(), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c) err := contextMiddleware(gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)), time.Duration(10)*time.Second, 0, downloadFromConfig{})(tc.next)(c)
if tc.expectErr && err == nil { if tc.expectErr && err == nil {
t.Errorf("test %d: expected error but got: %v", i, err) t.Errorf("test %d: expected error but got: %v", i, err)

View File

@@ -86,6 +86,14 @@ func (ctx *ContextMock) SetEchoContext(c echo.Context) {
ctx.Context.echoCtx = c ctx.Context.echoCtx = c
} }
// SetMkdirAll sets the [gotenberg.MkdirAll].
//
// ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetMkdirAll(mkdirAll)
func (ctx *ContextMock) SetMkdirAll(mkdirAll gotenberg.MkdirAll) {
ctx.Context.mkdirAll = mkdirAll
}
// SetPathRename sets the [gotenberg.PathRename]. // SetPathRename sets the [gotenberg.PathRename].
// //
// ctx := &api.ContextMock{Context: &api.Context{}} // ctx := &api.ContextMock{Context: &api.Context{}}

View File

@@ -7,6 +7,8 @@ import (
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
func TestContextMock_SetDirPath(t *testing.T) { func TestContextMock_SetDirPath(t *testing.T) {
@@ -117,10 +119,23 @@ func TestContextMock_SetEchoContext(t *testing.T) {
} }
} }
func TestContextMock_SetMkdirAll(t *testing.T) {
mock := ContextMock{&Context{}}
expect := new(gotenberg.OsMkdirAll)
mock.SetMkdirAll(expect)
actual := mock.mkdirAll
if actual != expect {
t.Errorf("expected %v but got %v", expect, actual)
}
}
func TestContextMock_SetPathRename(t *testing.T) { func TestContextMock_SetPathRename(t *testing.T) {
mock := ContextMock{&Context{}} mock := ContextMock{&Context{}}
expect := new(osPathRename) expect := new(gotenberg.OsPathRename)
mock.SetPathRename(expect) mock.SetPathRename(expect)
actual := mock.pathRename actual := mock.pathRename

View File

@@ -15,6 +15,7 @@ import (
"github.com/chromedp/cdproto/runtime" "github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"github.com/shirou/gopsutil/v4/process"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -62,7 +63,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
b := &chromiumBrowser{ b := &chromiumBrowser{
initialCtx: context.Background(), initialCtx: context.Background(),
arguments: arguments, arguments: arguments,
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
} }
b.isStarted.Store(false) b.isStarted.Store(false)
@@ -162,21 +163,61 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
// Always remove the user profile directory created by Chromium. // Always remove the user profile directory created by Chromium.
copyUserProfileDirPath := b.userProfileDirPath copyUserProfileDirPath := b.userProfileDirPath
defer func(userProfileDirPath string) { expirationTime := time.Now()
defer func(userProfileDirPath string, expirationTime time.Time) {
// See:
// https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChrome/chrome-cleanup.sh
// https://github.com/SeleniumHQ/docker-selenium/blob/7216d060d86872afe853ccda62db0dfab5118dc7/NodeChromium/chrome-cleanup.sh
// Clean up stuck processes.
ps, err := process.Processes()
if err != nil {
logger.Error(fmt.Sprintf("list processes: %v", err))
} else {
for _, p := range ps {
func() {
cmdline, err := p.Cmdline()
if err != nil {
return
}
if !strings.Contains(cmdline, "chromium/chromium") && !strings.Contains(cmdline, "chrome/chrome") {
return
}
killCtx, cancel := context.WithTimeout(context.Background(), time.Second*5)
defer cancel()
err = p.KillWithContext(killCtx)
if err != nil {
logger.Error(fmt.Sprintf("kill process: %v", err))
} else {
logger.Debug(fmt.Sprintf("Chromium process %d killed", p.Pid))
}
}()
}
}
go func() { go func() {
// FIXME: Chromium seems to recreate the user profile directory // FIXME: Chromium seems to recreate the user profile directory
// right after its deletion if we do not wait a certain amount // right after its deletion if we do not wait a certain amount
// of time before deleting it. // of time before deleting it.
<-time.After(10 * time.Second) <-time.After(10 * time.Second)
err := os.RemoveAll(userProfileDirPath) err = os.RemoveAll(userProfileDirPath)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err)) logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err))
} else {
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
} }
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath)) // Also remove Chromium specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime)
if err != nil {
logger.Error(err.Error())
}
}() }()
}(copyUserProfileDirPath) }(copyUserProfileDirPath, expirationTime)
b.ctxMu.Lock() b.ctxMu.Lock()
defer b.ctxMu.Unlock() defer b.ctxMu.Unlock()

View File

@@ -263,7 +263,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
b.isStarted.Store(false) b.isStarted.Store(false)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -275,7 +275,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: true, noDeadline: true,
start: false, start: false,
expectError: true, expectError: true,
@@ -291,7 +291,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -308,7 +308,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -325,7 +325,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -357,7 +357,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -389,7 +389,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -424,7 +424,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -457,7 +457,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -495,7 +495,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -528,7 +528,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -554,7 +554,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -588,7 +588,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -621,7 +621,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -654,7 +654,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -688,7 +688,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -723,7 +723,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -758,7 +758,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -812,7 +812,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -845,7 +845,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -881,7 +881,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -914,7 +914,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -949,7 +949,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -984,7 +984,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1019,7 +1019,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1065,7 +1065,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1100,7 +1100,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1146,7 +1146,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1181,7 +1181,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1217,7 +1217,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1255,7 +1255,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1288,7 +1288,7 @@ func TestChromiumBrowser_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1421,7 +1421,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
b.isStarted.Store(false) b.isStarted.Store(false)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -1437,7 +1437,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: true, noDeadline: true,
start: false, start: false,
expectError: true, expectError: true,
@@ -1453,7 +1453,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -1470,7 +1470,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
b.isStarted.Store(true) b.isStarted.Store(true)
return b return b
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
noDeadline: false, noDeadline: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -1487,7 +1487,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1519,7 +1519,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1551,7 +1551,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1586,7 +1586,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1619,7 +1619,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1657,7 +1657,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1690,7 +1690,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1716,7 +1716,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1750,7 +1750,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1783,7 +1783,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1816,7 +1816,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1850,7 +1850,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1885,7 +1885,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1920,7 +1920,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -1974,7 +1974,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -2009,7 +2009,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -2042,7 +2042,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -2077,7 +2077,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -2112,7 +2112,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -2147,7 +2147,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -2193,7 +2193,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -2228,7 +2228,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -2274,7 +2274,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -2317,7 +2317,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -2365,7 +2365,7 @@ func TestChromiumBrowser_screenshot(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {

View File

@@ -352,7 +352,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
ID: "chromium", ID: "chromium",
FlagSet: func() *flag.FlagSet { FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("chromium", flag.ExitOnError) fs := flag.NewFlagSet("chromium", flag.ExitOnError)
fs.Int64("chromium-restart-after", 0, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature") 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-max-queue-size", 0, "Maximum request queue size for Chromium. 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.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.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.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")

View File

@@ -21,6 +21,7 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
) )
// FormDataChromiumOptions creates [Options] from the form data. Fallback to // FormDataChromiumOptions creates [Options] from the form data. Fallback to
@@ -316,39 +317,6 @@ func FormDataChromiumScreenshotOptions(ctx *api.Context) (*api.FormData, Screens
return form, screenshotOptions return form, screenshotOptions
} }
// FormDataChromiumPdfFormats creates [gotenberg.PdfFormats] from the form
// data. Fallback to default value if the considered key is not present.
func FormDataChromiumPdfFormats(form *api.FormData) gotenberg.PdfFormats {
var (
pdfa string
pdfua bool
)
form.
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false)
return gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
}
// FormDataPdfMetadata creates metadata object from the form data.
func FormDataPdfMetadata(form *api.FormData) map[string]interface{} {
var metadata map[string]interface{}
form.Custom("metadata", func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
return nil
})
return metadata
}
// convertUrlRoute returns an [api.Route] which can convert a URL to PDF. // convertUrlRoute returns an [api.Route] which can convert a URL to PDF.
func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return api.Route{ return api.Route{
@@ -358,8 +326,9 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx) form, options := FormDataChromiumPdfOptions(ctx)
pdfFormats := FormDataChromiumPdfFormats(form) mode := pdfengines.FormDataPdfSplitMode(form, false)
metadata := FormDataPdfMetadata(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
var url string var url string
err := form. err := form.
@@ -369,7 +338,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
if err != nil { if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err) return fmt.Errorf("convert URL to PDF: %w", err)
} }
@@ -418,8 +387,9 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx) form, options := FormDataChromiumPdfOptions(ctx)
pdfFormats := FormDataChromiumPdfFormats(form) mode := pdfengines.FormDataPdfSplitMode(form, false)
metadata := FormDataPdfMetadata(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
var inputPath string var inputPath string
err := form. err := form.
@@ -430,7 +400,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
} }
url := fmt.Sprintf("file://%s", inputPath) url := fmt.Sprintf("file://%s", inputPath)
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
if err != nil { if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err) return fmt.Errorf("convert HTML to PDF: %w", err)
} }
@@ -480,8 +450,9 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form, options := FormDataChromiumPdfOptions(ctx) form, options := FormDataChromiumPdfOptions(ctx)
pdfFormats := FormDataChromiumPdfFormats(form) mode := pdfengines.FormDataPdfSplitMode(form, false)
metadata := FormDataPdfMetadata(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
var ( var (
inputPath string inputPath string
@@ -501,7 +472,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err) return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
} }
err = convertUrl(ctx, chromium, engine, url, options, pdfFormats, metadata) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata)
if err != nil { if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err) return fmt.Errorf("convert markdown to PDF: %w", err)
} }
@@ -625,7 +596,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil return fmt.Sprintf("file://%s", inputPath), nil
} }
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error { func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]interface{}) error {
outputPath := ctx.GeneratePath(".pdf") outputPath := ctx.GeneratePath(".pdf")
err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options) err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options)
@@ -664,34 +635,39 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("convert to PDF: %w", err) return fmt.Errorf("convert to PDF: %w", err)
} }
// So far so good, the URL has been converted to PDF. outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
// Now, let's check if the client want to convert the resulting PDF
// to specific formats.
zeroValued := gotenberg.PdfFormats{}
if pdfFormats != zeroValued {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
if err != nil {
return fmt.Errorf("convert PDF: %w", err)
}
// Important: the output path is now the converted file.
outputPath = convertOutputPath
}
// Writes and potentially overrides metadata entries, if any.
if len(metadata) > 0 {
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
}
err = ctx.AddOutputPaths(outputPath)
if err != nil { if err != nil {
return fmt.Errorf("add output path: %w", err) return fmt.Errorf("split PDF: %w", err)
}
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDF(s): %w", err)
}
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
zeroValuedSplitMode := gotenberg.SplitMode{}
zeroValuedPdfFormats := gotenberg.PdfFormats{}
if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
// The PDF has been split and split parts have been converted to a
// specific format. We want to keep the split naming.
for i, convertOutputPath := range convertOutputPaths {
err = ctx.Rename(convertOutputPath, outputPaths[i])
if err != nil {
return fmt.Errorf("rename output path: %w", err)
}
}
} else {
outputPaths = convertOutputPaths
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
} }
return nil return nil

View File

@@ -603,125 +603,6 @@ func TestFormDataChromiumScreenshotOptions(t *testing.T) {
} }
} }
func TestFormDataChromiumPdfFormats(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
expectedPdfFormats gotenberg.PdfFormats
expectValidationError bool
}{
{
scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)},
expectedPdfFormats: gotenberg.PdfFormats{},
expectValidationError: false,
},
{
scenario: "pdfa and pdfua form fields",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"pdfa": {
"foo",
},
"pdfua": {
"true",
},
})
return ctx
}(),
expectedPdfFormats: gotenberg.PdfFormats{PdfA: "foo", PdfUa: true},
expectValidationError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
form := tc.ctx.Context.FormData()
actual := FormDataChromiumPdfFormats(form)
if !reflect.DeepEqual(actual, tc.expectedPdfFormats) {
t.Fatalf("expected %+v but got: %+v", tc.expectedPdfFormats, actual)
}
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
})
}
}
func TestFormDataPdfMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
expectedMetadata map[string]interface{}
expectValidationError bool
}{
{
scenario: "no metadata form field",
ctx: &api.ContextMock{Context: new(api.Context)},
expectedMetadata: nil,
expectValidationError: false,
},
{
scenario: "invalid metadata form field",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"metadata": {
"foo",
},
})
return ctx
}(),
expectedMetadata: nil,
expectValidationError: true,
},
{
scenario: "valid metadata form field",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"metadata": {
"{\"foo\":\"bar\"}",
},
})
return ctx
}(),
expectedMetadata: map[string]interface{}{
"foo": "bar",
},
expectValidationError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
form := tc.ctx.Context.FormData()
actual := FormDataPdfMetadata(form)
if !reflect.DeepEqual(actual, tc.expectedMetadata) {
t.Fatalf("expected %+v but got: %+v", tc.expectedMetadata, actual)
}
err := form.Validate()
if tc.expectValidationError && err == nil {
t.Fatal("expected validation error but got none", err)
}
if !tc.expectValidationError && err != nil {
t.Fatalf("expected no validation error but got: %v", err)
}
})
}
}
func TestConvertUrlRoute(t *testing.T) { func TestConvertUrlRoute(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
@@ -1547,6 +1428,7 @@ func TestConvertUrl(t *testing.T) {
api Api api Api
engine gotenberg.PdfEngine engine gotenberg.PdfEngine
options PdfOptions options PdfOptions
splitMode gotenberg.SplitMode
pdfFormats gotenberg.PdfFormats pdfFormats gotenberg.PdfFormats
metadata map[string]interface{} metadata map[string]interface{}
expectError bool expectError bool
@@ -1689,6 +1571,36 @@ func TestConvertUrl(t *testing.T) {
expectHttpError: false, expectHttpError: false,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "PDF engine split error",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
engine: &gotenberg.PdfEngineMock{SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
}},
options: DefaultPdfOptions(),
splitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "success with split mode",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
engine: &gotenberg.PdfEngineMock{SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
}},
options: DefaultPdfOptions(),
splitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 1,
},
{ {
scenario: "PDF engine convert error", scenario: "PDF engine convert error",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1719,6 +1631,27 @@ func TestConvertUrl(t *testing.T) {
expectHttpError: false, expectHttpError: false,
expectOutputPathsCount: 1, expectOutputPathsCount: 1,
}, },
{
scenario: "success with split mode and PDF formats",
ctx: &api.ContextMock{Context: new(api.Context)},
api: &ApiMock{PdfMock: func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
return nil
}},
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
options: DefaultPdfOptions(),
splitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"},
pdfFormats: gotenberg.PdfFormats{PdfA: gotenberg.PdfA1b},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 1,
},
{ {
scenario: "PDF engine write metadata error", scenario: "PDF engine write metadata error",
ctx: &api.ContextMock{Context: new(api.Context)}, ctx: &api.ContextMock{Context: new(api.Context)},
@@ -1778,7 +1711,13 @@ func TestConvertUrl(t *testing.T) {
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.options, tc.pdfFormats, tc.metadata) tc.ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
tc.ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.options, tc.splitMode, tc.pdfFormats, tc.metadata)
if tc.expectError && err == nil { if tc.expectError && err == nil {
t.Fatal("expected error but got none", err) t.Fatal("expected error but got none", err)

View File

@@ -58,6 +58,16 @@ func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPath
return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Split is not available in this implementation.
func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Flatten is not available in this implementation.
func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Convert is not available in this implementation. // Convert is not available in this implementation.
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)

View File

@@ -82,6 +82,24 @@ func TestExiftool_Merge(t *testing.T) {
} }
} }
func TestExiftool_Split(t *testing.T) {
engine := new(ExifTool)
_, err := engine.Split(context.Background(), zap.NewNop(), gotenberg.SplitMode{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestExiftool_Flatten(t *testing.T) {
engine := new(ExifTool)
err := engine.Flatten(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestExiftool_Convert(t *testing.T) { func TestExiftool_Convert(t *testing.T) {
engine := new(ExifTool) engine := new(ExifTool)
err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")
@@ -257,7 +275,7 @@ func TestExiftool_WriteMetadata(t *testing.T) {
var destinationPath string var destinationPath string
if tc.createCopy { if tc.createCopy {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll() outputDir, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected error no but got: %v", err) t.Fatalf("expected error no but got: %v", err)

View File

@@ -44,7 +44,7 @@ type libreOfficeProcess struct {
func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice { func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
p := &libreOfficeProcess{ p := &libreOfficeProcess{
arguments: arguments, arguments: arguments,
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
} }
p.isStarted.Store(false) p.isStarted.Store(false)
@@ -190,22 +190,23 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
// Always remove the user profile directory created by LibreOffice. // Always remove the user profile directory created by LibreOffice.
copyUserProfileDirPath := p.userProfileDirPath copyUserProfileDirPath := p.userProfileDirPath
defer func(userProfileDirPath string) { expirationTime := time.Now()
defer func(userProfileDirPath string, expirationTime time.Time) {
go func() { go func() {
err := os.RemoveAll(userProfileDirPath) err := os.RemoveAll(userProfileDirPath)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err)) logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
} else {
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
} }
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
// Also remove LibreOffice specific files in the temporary directory. // Also remove LibreOffice specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}) err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
if err != nil { if err != nil {
logger.Error(err.Error()) logger.Error(err.Error())
} }
}() }()
}(copyUserProfileDirPath) }(copyUserProfileDirPath, expirationTime)
p.cfgMu.Lock() p.cfgMu.Lock()
defer p.cfgMu.Unlock() defer p.cfgMu.Unlock()

View File

@@ -230,7 +230,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
p.isStarted.Store(false) p.isStarted.Store(false)
return p return p
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
cancelledCtx: false, cancelledCtx: false,
start: false, start: false,
expectError: true, expectError: true,
@@ -243,7 +243,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
p.isStarted.Store(true) p.isStarted.Store(true)
return p return p
}(), }(),
fs: gotenberg.NewFileSystem(), fs: gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)),
options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}}, options: Options{PdfFormats: gotenberg.PdfFormats{PdfA: "foo"}},
cancelledCtx: false, cancelledCtx: false,
start: false, start: false,
@@ -261,7 +261,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
), ),
options: Options{PageRanges: "foo"}, options: Options{PageRanges: "foo"},
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -291,7 +291,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
), ),
options: Options{Password: "foo"}, options: Options{Password: "foo"},
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -344,7 +344,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -372,7 +372,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -400,7 +400,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -452,7 +452,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -481,7 +481,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -510,7 +510,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -539,7 +539,7 @@ func TestLibreOfficeProcess_pdf(t *testing.T) {
}, },
), ),
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -625,7 +625,7 @@ func TestNonBasicLatinCharactersGuard(t *testing.T) {
{ {
scenario: "basic latin characters", scenario: "basic latin characters",
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {
@@ -646,7 +646,7 @@ func TestNonBasicLatinCharactersGuard(t *testing.T) {
{ {
scenario: "non-basic latin characters", scenario: "non-basic latin characters",
fs: func() *gotenberg.FileSystem { fs: func() *gotenberg.FileSystem {
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
err := os.MkdirAll(fs.WorkingDirPath(), 0o755) err := os.MkdirAll(fs.WorkingDirPath(), 0o755)
if err != nil { if err != nil {

View File

@@ -51,6 +51,16 @@ func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logge
return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Split is not available in this implementation.
func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Flatten is not available in this implementation.
func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return fmt.Errorf("Flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Convert converts the given PDF to a specific PDF format. Currently, only the // Convert converts the given PDF to a specific PDF format. Currently, only the
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another // PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported] // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]

View File

@@ -118,11 +118,30 @@ func TestLibreOfficePdfEngine_Merge(t *testing.T) {
} }
} }
func TestLibreOfficePdfEngine_Split(t *testing.T) {
engine := new(LibreOfficePdfEngine)
_, err := engine.Split(context.Background(), zap.NewNop(), gotenberg.SplitMode{}, "", "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_Flatten(t *testing.T) {
engine := new(LibreOfficePdfEngine)
err := engine.Flatten(context.Background(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestLibreOfficePdfEngine_Convert(t *testing.T) { func TestLibreOfficePdfEngine_Convert(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
api api.Uno api api.Uno
expectError bool expectError bool
expectedError error
}{ }{
{ {
scenario: "convert success", scenario: "convert success",
@@ -134,13 +153,14 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
expectError: false, expectError: false,
}, },
{ {
scenario: "invalid PDF format", scenario: "ErrInvalidPdfFormats",
api: &api.ApiMock{ api: &api.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error { PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options api.Options) error {
return api.ErrInvalidPdfFormats return api.ErrInvalidPdfFormats
}, },
}, },
expectError: true, expectError: true,
expectedError: gotenberg.ErrPdfFormatNotSupported,
}, },
{ {
scenario: "convert fail", scenario: "convert fail",
@@ -163,6 +183,10 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) {
if tc.expectError && err == nil { if tc.expectError && err == nil {
t.Fatal("expected error but got none") 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)
}
}) })
} }
} }

View File

@@ -13,6 +13,7 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" libreofficeapi "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
"github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
) )
// convertRoute returns an [api.Route] which can convert LibreOffice documents // convertRoute returns an [api.Route] which can convert LibreOffice documents
@@ -26,7 +27,13 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
defaultOptions := libreofficeapi.DefaultOptions() defaultOptions := libreofficeapi.DefaultOptions()
// Let's get the data from the form and validate them. form := ctx.FormData()
splitMode := pdfengines.FormDataPdfSplitMode(form, false)
pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false)
zeroValuedSplitMode := gotenberg.SplitMode{}
var ( var (
inputPaths []string inputPaths []string
password string password string
@@ -51,14 +58,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
quality int quality int
reduceImageResolution bool reduceImageResolution bool
maxImageResolution int maxImageResolution int
pdfa string
pdfua bool
nativePdfFormats bool nativePdfFormats bool
merge bool merge bool
metadata map[string]interface{} flatten bool
) )
err := ctx.FormData(). err := form.
MandatoryPaths(libreOffice.Extensions(), &inputPaths). MandatoryPaths(libreOffice.Extensions(), &inputPaths).
String("password", &password, defaultOptions.Password). String("password", &password, defaultOptions.Password).
Bool("landscape", &landscape, defaultOptions.Landscape). Bool("landscape", &landscape, defaultOptions.Landscape).
@@ -120,8 +125,6 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
maxImageResolution = intValue maxImageResolution = intValue
return nil return nil
}). }).
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false).
Bool("nativePdfFormats", &nativePdfFormats, true). Bool("nativePdfFormats", &nativePdfFormats, true).
Bool("merge", &merge, false). Bool("merge", &merge, false).
Custom("metadata", func(value string) error { Custom("metadata", func(value string) error {
@@ -133,17 +136,12 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
} }
return nil return nil
}). }).
Bool("flatten", &flatten, false).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
pdfFormats := gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
// Alright, let's convert each document to PDF.
outputPaths := make([]string, len(inputPaths)) outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths { for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf") outputPaths[i] = ctx.GeneratePath(".pdf")
@@ -172,7 +170,9 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
MaxImageResolution: maxImageResolution, MaxImageResolution: maxImageResolution,
} }
if nativePdfFormats { if nativePdfFormats && splitMode == zeroValuedSplitMode {
// Only apply natively given PDF formats if we're not
// splitting the PDF later.
options.PdfFormats = pdfFormats options.PdfFormats = pdfFormats
} }
@@ -206,11 +206,8 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
} }
} }
// So far so good, let's check if we have to merge the PDFs. if merge {
if len(outputPaths) > 1 && merge { outputPath, err := pdfengines.MergeStub(ctx, engine, outputPaths)
outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), outputPaths, outputPath)
if err != nil { if err != nil {
return fmt.Errorf("merge PDFs: %w", err) return fmt.Errorf("merge PDFs: %w", err)
} }
@@ -219,37 +216,61 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
outputPaths = []string{outputPath} outputPaths = []string{outputPath}
} }
// Let's check if the client want to convert each PDF to a specific if splitMode != zeroValuedSplitMode {
// PDF format. if !merge {
zeroValued := gotenberg.PdfFormats{} // document.docx -> document.docx.pdf, so that split naming
if !nativePdfFormats && pdfFormats != zeroValued { // document.docx_0.pdf, etc.
convertOutputPaths := make([]string, len(outputPaths)) for i, inputPath := range inputPaths {
outputPath := fmt.Sprintf("%s.pdf", inputPath)
for i, outputPath := range outputPaths { err = ctx.Rename(outputPaths[i], outputPath)
convertInputPath := outputPath if err != nil {
convertOutputPaths[i] = ctx.GeneratePath(".pdf") return fmt.Errorf("rename output path: %w", err)
}
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPaths[i]) outputPaths[i] = outputPath
if err != nil {
return fmt.Errorf("convert PDF: %w", err)
} }
} }
// Important: the output paths are now the converted files. outputPaths, err = pdfengines.SplitPdfStub(ctx, engine, splitMode, outputPaths)
outputPaths = convertOutputPaths if err != nil {
} return fmt.Errorf("split PDFs: %w", err)
// Writes and potentially overrides metadata entries, if any.
if len(metadata) > 0 {
for _, outputPath := range outputPaths {
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
} }
} }
if len(outputPaths) > 1 { if !nativePdfFormats || (nativePdfFormats && splitMode != zeroValuedSplitMode) {
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
if splitMode != zeroValuedSplitMode {
// The PDF has been split and split parts have been converted to
// specific formats. We want to keep the split naming.
for i, convertOutputPath := range convertOutputPaths {
err = ctx.Rename(convertOutputPath, outputPaths[i])
if err != nil {
return fmt.Errorf("rename output path: %w", err)
}
}
} else {
outputPaths = convertOutputPaths
}
}
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)
}
}
if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode {
// If .zip archive, document.docx -> document.docx.pdf. // If .zip archive, document.docx -> document.docx.pdf.
for i, inputPath := range inputPaths { for i, inputPath := range inputPaths {
outputPath := fmt.Sprintf("%s.pdf", inputPath) outputPath := fmt.Sprintf("%s.pdf", inputPath)
@@ -263,8 +284,6 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
} }
} }
// Last but not least, add the output paths to the context so that
// the API is able to send them as a response to the client.
err = ctx.AddOutputPaths(outputPaths...) err = ctx.AddOutputPaths(outputPaths...)
if err != nil { if err != nil {
return fmt.Errorf("add output paths: %w", err) return fmt.Errorf("add output paths: %w", err)

View File

@@ -3,7 +3,10 @@ package libreoffice
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net/http" "net/http"
"os"
"path/filepath"
"slices" "slices"
"testing" "testing"
@@ -300,6 +303,40 @@ func TestConvertRoute(t *testing.T) {
expectHttpError: false, expectHttpError: false,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "PDF engine split error",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{ {
scenario: "PDF engine convert error", scenario: "PDF engine convert error",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
@@ -366,17 +403,18 @@ func TestConvertRoute(t *testing.T) {
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{ {
scenario: "cannot rename many files", scenario: "PDF engine flatten error",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
"document.docx": "/document.docx", "document.docx": "/document.docx",
"document2.docx": "/document2.docx",
"document2.doc": "/document2.doc",
}) })
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { ctx.SetValues(map[string][]string{
return errors.New("cannot rename") "flatten": {
}}) "true",
},
})
ctx.SetCancelled(true)
return ctx return ctx
}(), }(),
libreOffice: &libreofficeapi.ApiMock{ libreOffice: &libreofficeapi.ApiMock{
@@ -384,7 +422,12 @@ func TestConvertRoute(t *testing.T) {
return nil return nil
}, },
ExtensionsMock: func() []string { ExtensionsMock: func() []string {
return []string{".docx", ".doc"} return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
}, },
}, },
expectError: true, expectError: true,
@@ -443,6 +486,9 @@ func TestConvertRoute(t *testing.T) {
"metadata": { "metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }", "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
}, },
"flatten": {
"true",
},
}) })
return ctx return ctx
}(), }(),
@@ -464,6 +510,9 @@ func TestConvertRoute(t *testing.T) {
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil return nil
}, },
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
}, },
expectError: false, expectError: false,
expectHttpError: false, expectHttpError: false,
@@ -491,6 +540,9 @@ func TestConvertRoute(t *testing.T) {
"metadata": { "metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }", "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
}, },
"flatten": {
"true",
},
}) })
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error { ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil return nil
@@ -515,6 +567,9 @@ func TestConvertRoute(t *testing.T) {
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil return nil
}, },
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
}, },
expectError: false, expectError: false,
expectHttpError: false, expectHttpError: false,
@@ -550,9 +605,173 @@ func TestConvertRoute(t *testing.T) {
expectHttpError: false, expectHttpError: false,
expectOutputPathsCount: 1, expectOutputPathsCount: 1,
}, },
{
scenario: "success with split (many files)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 4,
expectOutputPaths: []string{"/document_docx/document.docx_0.pdf", "/document_docx/document.docx_1.pdf", "/document2_docx/document2.docx_0.pdf", "/document2_docx/document2.docx_1.pdf"},
},
{
scenario: "success with merge and split",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"merge": {
"true",
},
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 2,
},
{
scenario: "success with split and native PDF/A & PDF/UA (many files)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"document.docx": "/document.docx",
"document2.docx": "/document2.docx",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
"pdfa": {
gotenberg.PdfA1b,
},
"pdfua": {
"true",
},
})
return ctx
}(),
libreOffice: &libreofficeapi.ApiMock{
PdfMock: func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options libreofficeapi.Options) error {
return nil
},
ExtensionsMock: func() []string {
return []string{".docx"}
},
},
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
return []string{
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 0,
),
fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, 1,
),
}, nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 4,
expectOutputPaths: []string{"/document_docx/document.docx_0.pdf", "/document_docx/document.docx_1.pdf", "/document2_docx/document2.docx_0.pdf", "/document2_docx/document2.docx_1.pdf"},
},
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop()) tc.ctx.SetLogger(zap.NewNop())
tc.ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
tc.ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
c := echo.New().NewContext(nil, nil) c := echo.New().NewContext(nil, nil)
c.Set("context", tc.ctx.Context) c.Set("context", tc.ctx.Context)

View File

@@ -2,6 +2,7 @@
// interface using the pdfcpu command-line tool. This package allows for: // interface using the pdfcpu command-line tool. This package allows for:
// //
// 1. The merging of PDF files. // 1. The merging of PDF files.
// 2. The splitting of PDF files.
// //
// See: https://github.com/pdfcpu/pdfcpu. // See: https://github.com/pdfcpu/pdfcpu.
package pdfcpu package pdfcpu

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"go.uber.org/zap" "go.uber.org/zap"
@@ -70,6 +71,47 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths
return fmt.Errorf("merge PDFs with pdfcpu: %w", err) return fmt.Errorf("merge PDFs with pdfcpu: %w", err)
} }
// Split splits a given PDF file.
func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
switch mode.Mode {
case gotenberg.SplitModeIntervals:
args = append(args, "split", "-mode", "span", inputPath, outputDirPath, mode.Span)
case gotenberg.SplitModePages:
if mode.Unify {
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
args = append(args, "trim", "-pages", mode.Span, inputPath, outputPath)
break
}
args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with pdfcpu: %w", err)
}
outputPaths, err := gotenberg.WalkDir(outputDirPath, ".pdf")
if err != nil {
return nil, fmt.Errorf("walk directory to find resulting PDFs from split with pdfcpu: %w", err)
}
return outputPaths, nil
}
// Flatten is not available in this implementation.
func (engine *PdfCpu) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Convert is not available in this implementation. // Convert is not available in this implementation.
func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)

View File

@@ -116,7 +116,7 @@ func TestPdfCpu_Merge(t *testing.T) {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
} }
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll() outputDir, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
@@ -142,6 +142,112 @@ func TestPdfCpu_Merge(t *testing.T) {
} }
} }
func TestPdfCpu_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (intervals)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 3,
},
{
scenario: "success (pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1"},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfCpu)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
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")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
if tc.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestPdfCpu_Flatten(t *testing.T) {
mod := new(PdfCpu)
err := mod.Flatten(context.TODO(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestPdfCpu_Convert(t *testing.T) { func TestPdfCpu_Convert(t *testing.T) {
mod := new(PdfCpu) mod := new(PdfCpu)
err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") err := mod.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")

View File

@@ -12,23 +12,29 @@ import (
) )
type multiPdfEngines struct { type multiPdfEngines struct {
mergeEngines []gotenberg.PdfEngine mergeEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine splitEngines []gotenberg.PdfEngine
readMedataEngines []gotenberg.PdfEngine flattenEngines []gotenberg.PdfEngine
writeMedataEngines []gotenberg.PdfEngine convertEngines []gotenberg.PdfEngine
readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine
} }
func newMultiPdfEngines( func newMultiPdfEngines(
mergeEngines, mergeEngines,
splitEngines,
flattenEngines,
convertEngines, convertEngines,
readMetadataEngines, readMetadataEngines,
writeMedataEngines []gotenberg.PdfEngine, writeMetadataEngines []gotenberg.PdfEngine,
) *multiPdfEngines { ) *multiPdfEngines {
return &multiPdfEngines{ return &multiPdfEngines{
mergeEngines: mergeEngines, mergeEngines: mergeEngines,
convertEngines: convertEngines, splitEngines: splitEngines,
readMedataEngines: readMetadataEngines, flattenEngines: flattenEngines,
writeMedataEngines: writeMedataEngines, convertEngines: convertEngines,
readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines,
} }
} }
@@ -57,6 +63,69 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) return fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
} }
type splitResult struct {
outputPaths []string
err error
}
// Split tries to split at intervals a given PDF thanks to its children. If the
// context is done, it stops and returns an error.
func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var err error
var mu sync.Mutex // to safely append errors.
resultChan := make(chan splitResult, len(multi.splitEngines))
for _, engine := range multi.splitEngines {
go func(engine gotenberg.PdfEngine) {
outputPaths, err := engine.Split(ctx, logger, mode, inputPath, outputDirPath)
resultChan <- splitResult{outputPaths: outputPaths, err: err}
}(engine)
}
for range multi.splitEngines {
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
mu.Unlock()
} else {
return result.outputPaths, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
}
// Flatten merges existing annotation appearances with page content, thanks to
// its children. If the context is done, it stops and returns an error
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.flattenEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Flatten(ctx, logger, inputPath)
}(engine)
select {
case mergeErr := <-errChan:
errored := multierr.AppendInto(&err, mergeErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
}
// Convert converts the given PDF to a specific PDF format. thanks to its // Convert converts the given PDF to a specific PDF format. thanks to its
// children. If the context is done, it stops and returns an error. // children. If the context is done, it stops and returns an error.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
@@ -91,16 +160,16 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
var err error var err error
var mu sync.Mutex // to safely append errors. var mu sync.Mutex // to safely append errors.
resultChan := make(chan readMetadataResult, len(multi.readMedataEngines)) resultChan := make(chan readMetadataResult, len(multi.readMetadataEngines))
for _, engine := range multi.readMedataEngines { for _, engine := range multi.readMetadataEngines {
go func(engine gotenberg.PdfEngine) { go func(engine gotenberg.PdfEngine) {
metadata, err := engine.ReadMetadata(ctx, logger, inputPath) metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
resultChan <- readMetadataResult{metadata: metadata, err: err} resultChan <- readMetadataResult{metadata: metadata, err: err}
}(engine) }(engine)
} }
for range multi.readMedataEngines { for range multi.readMetadataEngines {
select { select {
case result := <-resultChan: case result := <-resultChan:
if result.err != nil { if result.err != nil {
@@ -122,7 +191,7 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
for _, engine := range multi.writeMedataEngines { for _, engine := range multi.writeMetadataEngines {
go func(engine gotenberg.PdfEngine) { go func(engine gotenberg.PdfEngine) {
errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath) errChan <- engine.WriteMetadata(ctx, logger, metadata, inputPath)
}(engine) }(engine)

View File

@@ -19,25 +19,22 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}{ }{
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
[]gotenberg.PdfEngine{ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil return nil
}, },
}, },
}, },
nil, },
nil,
nil,
),
ctx: context.Background(), ctx: context.Background(),
expectError: false, expectError: false,
}, },
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
[]gotenberg.PdfEngine{ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -49,17 +46,14 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}, },
}, },
}, },
nil, },
nil,
nil,
),
ctx: context.Background(), ctx: context.Background(),
expectError: false, expectError: false,
}, },
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
[]gotenberg.PdfEngine{ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -71,27 +65,21 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
}, },
}, },
}, },
nil, },
nil,
nil,
),
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
}, },
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
[]gotenberg.PdfEngine{ mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil return nil
}, },
}, },
}, },
nil, },
nil,
nil,
),
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
@@ -115,6 +103,188 @@ func TestMultiPdfEngines_Merge(t *testing.T) {
} }
} }
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) { func TestMultiPdfEngines_Convert(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
@@ -124,25 +294,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
}{ }{
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, convertEngines: []gotenberg.PdfEngine{
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil return nil
}, },
}, },
}, },
nil, },
nil,
),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, convertEngines: []gotenberg.PdfEngine{
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -154,16 +320,13 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
}, },
}, },
}, },
nil, },
nil,
),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, convertEngines: []gotenberg.PdfEngine{
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -175,26 +338,21 @@ func TestMultiPdfEngines_Convert(t *testing.T) {
}, },
}, },
}, },
nil, },
nil,
),
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
}, },
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, convertEngines: []gotenberg.PdfEngine{
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil return nil
}, },
}, },
}, },
nil, },
nil,
),
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
@@ -227,26 +385,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
}{ }{
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, readMetadataEngines: []gotenberg.PdfEngine{
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil return make(map[string]interface{}), nil
}, },
}, },
}, },
nil, },
),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, readMetadataEngines: []gotenberg.PdfEngine{
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
@@ -258,16 +411,13 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
}, },
}, },
}, },
nil, },
),
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, readMetadataEngines: []gotenberg.PdfEngine{
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return nil, errors.New("foo") return nil, errors.New("foo")
@@ -279,25 +429,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
}, },
}, },
}, },
nil, },
),
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
}, },
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, readMetadataEngines: []gotenberg.PdfEngine{
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) { ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]interface{}, error) {
return make(map[string]interface{}), nil return make(map[string]interface{}), nil
}, },
}, },
}, },
nil, },
),
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()
@@ -330,27 +476,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
}{ }{
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, writeMetadataEngines: []gotenberg.PdfEngine{
nil,
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil return nil
}, },
}, },
}, },
), },
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "at least one engine does not return an error", scenario: "at least one engine does not return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, writeMetadataEngines: []gotenberg.PdfEngine{
nil,
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -362,16 +502,13 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
}, },
}, },
}, },
), },
ctx: context.Background(), ctx: context.Background(),
}, },
{ {
scenario: "all engines return an error", scenario: "all engines return an error",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, writeMetadataEngines: []gotenberg.PdfEngine{
nil,
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo") return errors.New("foo")
@@ -383,24 +520,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
}, },
}, },
}, },
), },
ctx: context.Background(), ctx: context.Background(),
expectError: true, expectError: true,
}, },
{ {
scenario: "context expired", scenario: "context expired",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, writeMetadataEngines: []gotenberg.PdfEngine{
nil,
nil,
[]gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{ &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil return nil
}, },
}, },
}, },
), },
ctx: func() context.Context { ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background()) ctx, cancel := context.WithCancel(context.Background())
cancel() cancel()

View File

@@ -27,12 +27,14 @@ func init() {
// the [api.Router] interface to expose relevant PDF processing routes if // the [api.Router] interface to expose relevant PDF processing routes if
// enabled. // enabled.
type PdfEngines struct { type PdfEngines struct {
mergeNames []string mergeNames []string
convertNames []string splitNames []string
readMetadataNames []string flattenNames []string
writeMedataNames []string convertNames []string
engines []gotenberg.PdfEngine readMetadataNames []string
disableRoutes bool writeMetadataNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
} }
// Descriptor returns a PdfEngines' module descriptor. // Descriptor returns a PdfEngines' module descriptor.
@@ -42,6 +44,8 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
FlagSet: func() *flag.FlagSet { FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("pdfengines", flag.ExitOnError) fs := flag.NewFlagSet("pdfengines", flag.ExitOnError)
fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all") fs.StringSlice("pdfengines-merge-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the merge feature - empty means all")
fs.StringSlice("pdfengines-split-engines", []string{"pdfcpu", "qpdf", "pdftk"}, "Set the PDF engines and their order for the split feature - empty means all")
fs.StringSlice("pdfengines-flatten-engines", []string{"qpdf"}, "Set the PDF engines and their order for the flatten feature - empty means all")
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all") fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all") fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
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-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
@@ -64,6 +68,8 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags() flags := ctx.ParsedFlags()
mergeNames := flags.MustStringSlice("pdfengines-merge-engines") mergeNames := flags.MustStringSlice("pdfengines-merge-engines")
splitNames := flags.MustStringSlice("pdfengines-split-engines")
flattenNames := flags.MustStringSlice("pdfengines-flatten-engines")
convertNames := flags.MustStringSlice("pdfengines-convert-engines") convertNames := flags.MustStringSlice("pdfengines-convert-engines")
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines") readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines") writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
@@ -98,6 +104,16 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.mergeNames = mergeNames mod.mergeNames = mergeNames
} }
mod.splitNames = defaultNames
if len(splitNames) > 0 {
mod.splitNames = splitNames
}
mod.flattenNames = defaultNames
if len(flattenNames) > 0 {
mod.flattenNames = flattenNames
}
mod.convertNames = defaultNames mod.convertNames = defaultNames
if len(convertNames) > 0 { if len(convertNames) > 0 {
mod.convertNames = convertNames mod.convertNames = convertNames
@@ -108,9 +124,9 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.readMetadataNames = readMetadataNames mod.readMetadataNames = readMetadataNames
} }
mod.writeMedataNames = defaultNames mod.writeMetadataNames = defaultNames
if len(writeMetadataNames) > 0 { if len(writeMetadataNames) > 0 {
mod.writeMedataNames = writeMetadataNames mod.writeMetadataNames = writeMetadataNames
} }
return nil return nil
@@ -161,9 +177,11 @@ func (mod *PdfEngines) Validate() error {
} }
findNonExistingEngines(mod.mergeNames) findNonExistingEngines(mod.mergeNames)
findNonExistingEngines(mod.splitNames)
findNonExistingEngines(mod.flattenNames)
findNonExistingEngines(mod.convertNames) findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames) findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMedataNames) findNonExistingEngines(mod.writeMetadataNames)
if len(nonExistingEngines) == 0 { if len(nonExistingEngines) == 0 {
return nil return nil
@@ -177,9 +195,11 @@ func (mod *PdfEngines) Validate() error {
func (mod *PdfEngines) SystemMessages() []string { func (mod *PdfEngines) SystemMessages() []string {
return []string{ return []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")), fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")), fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")), fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
} }
} }
@@ -201,9 +221,11 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
return newMultiPdfEngines( return newMultiPdfEngines(
engines(mod.mergeNames), engines(mod.mergeNames),
engines(mod.splitNames),
engines(mod.flattenNames),
engines(mod.convertNames), engines(mod.convertNames),
engines(mod.readMetadataNames), engines(mod.readMetadataNames),
engines(mod.writeMedataNames), engines(mod.writeMetadataNames),
), nil ), nil
} }
@@ -222,6 +244,8 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
return []api.Route{ return []api.Route{
mergeRoute(engine), mergeRoute(engine),
splitRoute(engine),
flattenRoute(engine),
convertRoute(engine), convertRoute(engine),
readMetadataRoute(engine), readMetadataRoute(engine),
writeMetadataRoute(engine), writeMetadataRoute(engine),

View File

@@ -26,6 +26,8 @@ func TestPdfEngines_Provision(t *testing.T) {
scenario string scenario string
ctx *gotenberg.Context ctx *gotenberg.Context
expectedMergePdfEngines []string expectedMergePdfEngines []string
expectedSplitPdfEngines []string
expectedFlattenPdfEngines []string
expectedConvertPdfEngines []string expectedConvertPdfEngines []string
expectedReadMetadataPdfEngines []string expectedReadMetadataPdfEngines []string
expectedWriteMetadataPdfEngines []string expectedWriteMetadataPdfEngines []string
@@ -66,6 +68,8 @@ func TestPdfEngines_Provision(t *testing.T) {
) )
}(), }(),
expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"}, expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"},
expectedSplitPdfEngines: []string{"pdfcpu", "qpdf", "pdftk"},
expectedFlattenPdfEngines: []string{"qpdf"},
expectedConvertPdfEngines: []string{"libreoffice-pdfengine"}, expectedConvertPdfEngines: []string{"libreoffice-pdfengine"},
expectedReadMetadataPdfEngines: []string{"exiftool"}, expectedReadMetadataPdfEngines: []string{"exiftool"},
expectedWriteMetadataPdfEngines: []string{"exiftool"}, expectedWriteMetadataPdfEngines: []string{"exiftool"},
@@ -107,7 +111,7 @@ func TestPdfEngines_Provision(t *testing.T) {
} }
fs := new(PdfEngines).Descriptor().FlagSet fs := new(PdfEngines).Descriptor().FlagSet
err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"}) err := fs.Parse([]string{"--pdfengines-merge-engines=b", "--pdfengines-split-engines=a", "--pdfengines-flatten-engines=c", "--pdfengines-convert-engines=b", "--pdfengines-read-metadata-engines=a", "--pdfengines-write-metadata-engines=a"})
if err != nil { if err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)
} }
@@ -125,6 +129,8 @@ func TestPdfEngines_Provision(t *testing.T) {
}(), }(),
expectedMergePdfEngines: []string{"b"}, expectedMergePdfEngines: []string{"b"},
expectedSplitPdfEngines: []string{"a"},
expectedFlattenPdfEngines: []string{"c"},
expectedConvertPdfEngines: []string{"b"}, expectedConvertPdfEngines: []string{"b"},
expectedReadMetadataPdfEngines: []string{"a"}, expectedReadMetadataPdfEngines: []string{"a"},
expectedWriteMetadataPdfEngines: []string{"a"}, expectedWriteMetadataPdfEngines: []string{"a"},
@@ -182,6 +188,10 @@ func TestPdfEngines_Provision(t *testing.T) {
t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames)) t.Fatalf("expected %d merge names but got %d", len(tc.expectedMergePdfEngines), len(mod.mergeNames))
} }
if len(tc.expectedFlattenPdfEngines) != len(mod.flattenNames) {
t.Fatalf("expected %d flatten names but got %d", len(tc.expectedFlattenPdfEngines), len(mod.flattenNames))
}
if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) { if len(tc.expectedConvertPdfEngines) != len(mod.convertNames) {
t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames)) t.Fatalf("expected %d convert names but got %d", len(tc.expectedConvertPdfEngines), len(mod.convertNames))
} }
@@ -190,8 +200,8 @@ func TestPdfEngines_Provision(t *testing.T) {
t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames)) t.Fatalf("expected %d read metadata names but got %d", len(tc.expectedReadMetadataPdfEngines), len(mod.readMetadataNames))
} }
if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMedataNames) { if len(tc.expectedWriteMetadataPdfEngines) != len(mod.writeMetadataNames) {
t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMedataNames)) t.Fatalf("expected %d write metadata names but got %d", len(tc.expectedWriteMetadataPdfEngines), len(mod.writeMetadataNames))
} }
for index, name := range mod.mergeNames { for index, name := range mod.mergeNames {
@@ -200,6 +210,12 @@ func TestPdfEngines_Provision(t *testing.T) {
} }
} }
for index, name := range mod.splitNames {
if name != tc.expectedSplitPdfEngines[index] {
t.Fatalf("expected split name at index %d to be %s, but got: %s", index, name, tc.expectedSplitPdfEngines[index])
}
}
for index, name := range mod.convertNames { for index, name := range mod.convertNames {
if name != tc.expectedConvertPdfEngines[index] { if name != tc.expectedConvertPdfEngines[index] {
t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index]) t.Fatalf("expected convert name at index %d to be %s, but got: %s", index, name, tc.expectedConvertPdfEngines[index])
@@ -212,7 +228,7 @@ func TestPdfEngines_Provision(t *testing.T) {
} }
} }
for index, name := range mod.writeMedataNames { for index, name := range mod.writeMetadataNames {
if name != tc.expectedWriteMetadataPdfEngines[index] { if name != tc.expectedWriteMetadataPdfEngines[index] {
t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index]) t.Fatalf("expected write metadat name at index %d to be %s, but got: %s", index, name, tc.expectedWriteMetadataPdfEngines[index])
} }
@@ -280,11 +296,11 @@ func TestPdfEngines_Validate(t *testing.T) {
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
mod := PdfEngines{ mod := PdfEngines{
mergeNames: tc.names, mergeNames: tc.names,
convertNames: tc.names, convertNames: tc.names,
readMetadataNames: tc.names, readMetadataNames: tc.names,
writeMedataNames: tc.names, writeMetadataNames: tc.names,
engines: tc.engines, engines: tc.engines,
} }
err := mod.Validate() err := mod.Validate()
@@ -303,20 +319,24 @@ func TestPdfEngines_Validate(t *testing.T) {
func TestPdfEngines_SystemMessages(t *testing.T) { func TestPdfEngines_SystemMessages(t *testing.T) {
mod := new(PdfEngines) mod := new(PdfEngines)
mod.mergeNames = []string{"foo", "bar"} mod.mergeNames = []string{"foo", "bar"}
mod.splitNames = []string{"foo", "bar"}
mod.convertNames = []string{"foo", "bar"} mod.convertNames = []string{"foo", "bar"}
mod.readMetadataNames = []string{"foo", "bar"} mod.readMetadataNames = []string{"foo", "bar"}
mod.writeMedataNames = []string{"foo", "bar"} mod.writeMetadataNames = []string{"foo", "bar"}
expectedMessages := 6
messages := mod.SystemMessages() messages := mod.SystemMessages()
if len(messages) != 4 { if len(messages) != expectedMessages {
t.Errorf("expected one and only one message, but got %d", len(messages)) t.Errorf("expected %d message(s), but got %d", expectedMessages, len(messages))
} }
expect := []string{ expect := []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")), fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")),
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")), fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")), fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
} }
for i, message := range messages { for i, message := range messages {
@@ -328,10 +348,11 @@ func TestPdfEngines_SystemMessages(t *testing.T) {
func TestPdfEngines_PdfEngine(t *testing.T) { func TestPdfEngines_PdfEngine(t *testing.T) {
mod := PdfEngines{ mod := PdfEngines{
mergeNames: []string{"foo", "bar"}, mergeNames: []string{"foo", "bar"},
convertNames: []string{"foo", "bar"}, splitNames: []string{"foo", "bar"},
readMetadataNames: []string{"foo", "bar"}, convertNames: []string{"foo", "bar"},
writeMedataNames: []string{"foo", "bar"}, readMetadataNames: []string{"foo", "bar"},
writeMetadataNames: []string{"foo", "bar"},
engines: func() []gotenberg.PdfEngine { engines: func() []gotenberg.PdfEngine {
engine1 := &struct { engine1 := &struct {
gotenberg.ModuleMock gotenberg.ModuleMock
@@ -370,7 +391,7 @@ func TestPdfEngines_Routes(t *testing.T) {
}{ }{
{ {
scenario: "routes not disabled", scenario: "routes not disabled",
expectRoutes: 4, expectRoutes: 6,
disableRoutes: false, disableRoutes: false,
}, },
{ {

View File

@@ -6,6 +6,8 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strconv"
"strings"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
@@ -13,6 +15,245 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
) )
// FormDataPdfSplitMode creates a [gotenberg.SplitMode] from the form data.
func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMode {
var (
mode string
span string
unify bool
)
splitModeFunc := func(value string) error {
if value != "" && value != gotenberg.SplitModeIntervals && value != gotenberg.SplitModePages {
return fmt.Errorf("wrong value, expected either '%s' or '%s'", gotenberg.SplitModeIntervals, gotenberg.SplitModePages)
}
mode = value
return nil
}
splitSpanFunc := func(value string) error {
value = strings.Join(strings.Fields(value), "")
if mode == gotenberg.SplitModeIntervals {
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 1 {
return errors.New("value is inferior to 1")
}
}
span = value
return nil
}
if mandatory {
form.
MandatoryCustom("splitMode", func(value string) error {
return splitModeFunc(value)
}).
MandatoryCustom("splitSpan", func(value string) error {
return splitSpanFunc(value)
})
} else {
form.
Custom("splitMode", func(value string) error {
return splitModeFunc(value)
}).
Custom("splitSpan", func(value string) error {
return splitSpanFunc(value)
})
}
form.
Bool("splitUnify", &unify, false).
Custom("splitUnify", func(value string) error {
if value != "" && unify && mode != gotenberg.SplitModePages {
return fmt.Errorf("unify is not available for split mode '%s'", mode)
}
return nil
})
return gotenberg.SplitMode{
Mode: mode,
Span: span,
Unify: unify,
}
}
// FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data.
// Fallback to default value if the considered key is not present.
func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
var (
pdfa string
pdfua bool
)
form.
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false)
return gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
}
// FormDataPdfMetadata creates metadata object from the form data.
func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]interface{} {
var metadata map[string]interface{}
metadataFunc := func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
return nil
}
if mandatory {
form.MandatoryCustom("metadata", func(value string) error {
return metadataFunc(value)
})
} else {
form.Custom("metadata", func(value string) error {
return metadataFunc(value)
})
}
return metadata
}
// 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) {
if len(inputPaths) == 0 {
return "", errors.New("no input paths")
}
if len(inputPaths) == 1 {
return inputPaths[0], nil
}
outputPath := ctx.GeneratePath(".pdf")
err := engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
if err != nil {
return "", fmt.Errorf("merge %d PDFs: %w", len(inputPaths), err)
}
return outputPath, nil
}
// SplitPdfStub splits a list of PDF files based on [gotenberg.SplitMode].
// It returns a list of output paths or the list of provided input paths if no
// split requested.
func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.SplitMode, inputPaths []string) ([]string, error) {
zeroValued := gotenberg.SplitMode{}
if mode == zeroValued {
return inputPaths, nil
}
var outputPaths []string
for _, inputPath := range inputPaths {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
outputDirPath, err := ctx.CreateSubDirectory(strings.ReplaceAll(filepath.Base(filenameNoExt), ".", "_"))
if err != nil {
return nil, fmt.Errorf("create subdirectory from input path: %w", err)
}
paths, err := engine.Split(ctx, ctx.Log(), mode, inputPath, outputDirPath)
if err != nil {
return nil, fmt.Errorf("split PDF '%s': %w", inputPath, err)
}
// Keep the original filename.
for i, path := range paths {
var newPath string
if mode.Unify && mode.Mode == gotenberg.SplitModePages {
newPath = fmt.Sprintf(
"%s/%s.pdf",
outputDirPath, filenameNoExt,
)
} else {
newPath = fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, i,
)
}
err = ctx.Rename(path, newPath)
if err != nil {
return nil, fmt.Errorf("rename path: %w", err)
}
outputPaths = append(outputPaths, newPath)
if mode.Unify && mode.Mode == gotenberg.SplitModePages {
break
}
}
}
return outputPaths, nil
}
// FlattenStub merges annotation appearances with page content for each given
// PDF, effectively deleting the original annotations.
func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) error {
for _, inputPath := range inputPaths {
err := engine.Flatten(ctx, ctx.Log(), inputPath)
if err != nil {
return fmt.Errorf("flatten '%s': %w", inputPath, err)
}
}
return nil
}
// ConvertStub transforms a given PDF to the specified formats defined in
// [gotenberg.PdfFormats]. If no format, it does nothing and returns the input
// paths.
func ConvertStub(ctx *api.Context, engine gotenberg.PdfEngine, formats gotenberg.PdfFormats, inputPaths []string) ([]string, error) {
zeroValued := gotenberg.PdfFormats{}
if formats == zeroValued {
return inputPaths, nil
}
outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf")
err := engine.Convert(ctx, ctx.Log(), formats, inputPath, outputPaths[i])
if err != nil {
return nil, fmt.Errorf("convert '%s': %w", inputPath, err)
}
}
return outputPaths, nil
}
// 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 {
if len(metadata) == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.WriteMetadata(ctx, ctx.Log(), metadata, inputPath)
if err != nil {
return fmt.Errorf("write metadata into '%s': %w", inputPath, err)
}
}
return nil
}
// mergeRoute returns an [api.Route] which can merge PDFs. // mergeRoute returns an [api.Route] which can merge PDFs.
func mergeRoute(engine gotenberg.PdfEngine) api.Route { func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{ return api.Route{
@@ -22,75 +263,148 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them. form := ctx.FormData()
var ( pdfFormats := FormDataPdfFormats(form)
inputPaths []string metadata := FormDataPdfMetadata(form, false)
pdfa string
pdfua bool
metadata map[string]interface{}
)
err := ctx.FormData(). var inputPaths []string
var flatten bool
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
String("pdfa", &pdfa, ""). Bool("flatten", &flatten, false).
Bool("pdfua", &pdfua, false).
Custom("metadata", func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
return nil
}).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
pdfFormats := gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
// Alright, let's merge the PDFs.
outputPath := ctx.GeneratePath(".pdf") outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath) err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
if err != nil { if err != nil {
return fmt.Errorf("merge PDFs: %w", err) return fmt.Errorf("merge PDFs: %w", err)
} }
// So far so good, the PDFs are merged into one unique PDF. outputPaths, err := ConvertStub(ctx, engine, pdfFormats, []string{outputPath})
// Now, let's check if the client want to convert this result PDF
// to specific PDF formats.
zeroValued := gotenberg.PdfFormats{}
if pdfFormats != zeroValued {
convertInputPath := outputPath
convertOutputPath := ctx.GeneratePath(".pdf")
err = engine.Convert(ctx, ctx.Log(), pdfFormats, convertInputPath, convertOutputPath)
if err != nil {
return fmt.Errorf("convert PDF: %w", err)
}
// Important: the output path is now the converted file.
outputPath = convertOutputPath
}
// Writes and potentially overrides metadata entries, if any.
if len(metadata) > 0 {
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, outputPath)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
}
// Last but not least, add the output path to the context so that
// the API is able to send it as a response to the client.
err = ctx.AddOutputPaths(outputPath)
if err != nil { if err != nil {
return fmt.Errorf("add output path: %w", err) return fmt.Errorf("convert PDF: %w", err)
}
err = WriteMetadataStub(ctx, engine, metadata, outputPaths)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
if flatten {
err = FlattenStub(ctx, engine, outputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// splitRoute returns an [api.Route] which can extract pages from a PDF.
func splitRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/split",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
mode := FormDataPdfSplitMode(form, true)
pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false)
var inputPaths []string
var flatten bool
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Bool("flatten", &flatten, false).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
outputPaths, err := SplitPdfStub(ctx, engine, mode, inputPaths)
if err != nil {
return fmt.Errorf("split PDFs: %w", err)
}
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil {
return fmt.Errorf("convert PDFs: %w", err)
}
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)
}
}
zeroValuedSplitMode := gotenberg.SplitMode{}
zeroValuedPdfFormats := gotenberg.PdfFormats{}
if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
// Rename the files to keep the split naming.
for i, convertOutputPath := range convertOutputPaths {
err = ctx.Rename(convertOutputPath, outputPaths[i])
if err != nil {
return fmt.Errorf("rename output path: %w", err)
}
}
}
err = ctx.AddOutputPaths(outputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// flattenRoute returns an [api.Route] which can flatten PDFs.
func flattenRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/flatten",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = FlattenStub(ctx, engine, inputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
} }
return nil return nil
@@ -108,27 +422,17 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them. form := ctx.FormData()
var ( pdfFormats := FormDataPdfFormats(form)
inputPaths []string
pdfa string
pdfua bool
)
err := ctx.FormData(). var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
String("pdfa", &pdfa, "").
Bool("pdfua", &pdfua, false).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
pdfFormats := gotenberg.PdfFormats{
PdfA: pdfa,
PdfUa: pdfua,
}
zeroValued := gotenberg.PdfFormats{} zeroValued := gotenberg.PdfFormats{}
if pdfFormats == zeroValued { if pdfFormats == zeroValued {
return api.WrapError( return api.WrapError(
@@ -140,18 +444,14 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
) )
} }
// Alright, let's convert the PDFs. outputPaths, err := ConvertStub(ctx, engine, pdfFormats, inputPaths)
outputPaths := make([]string, len(inputPaths)) if err != nil {
for i, inputPath := range inputPaths { return fmt.Errorf("convert PDFs: %w", err)
outputPaths[i] = ctx.GeneratePath(".pdf") }
err = engine.Convert(ctx, ctx.Log(), pdfFormats, inputPath, outputPaths[i]) if len(outputPaths) > 1 {
if err != nil { // If .zip archive, keep the original filename.
return fmt.Errorf("convert PDF: %w", err) for i, inputPath := range inputPaths {
}
if len(outputPaths) > 1 {
// If .zip archive, keep the original filename.
err = ctx.Rename(outputPaths[i], inputPath) err = ctx.Rename(outputPaths[i], inputPath)
if err != nil { if err != nil {
return fmt.Errorf("rename output path: %w", err) return fmt.Errorf("rename output path: %w", err)
@@ -161,8 +461,6 @@ func convertRoute(engine gotenberg.PdfEngine) api.Route {
} }
} }
// Last but not least, add the output paths to the context so that
// the API is able to send them as a response to the client.
err = ctx.AddOutputPaths(outputPaths...) err = ctx.AddOutputPaths(outputPaths...)
if err != nil { if err != nil {
return fmt.Errorf("add output paths: %w", err) return fmt.Errorf("add output paths: %w", err)
@@ -182,9 +480,7 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them.
var inputPaths []string var inputPaths []string
err := ctx.FormData(). err := ctx.FormData().
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate() Validate()
@@ -192,7 +488,6 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
// Alright, let's read the metadata.
res := make(map[string]map[string]interface{}, len(inputPaths)) res := make(map[string]map[string]interface{}, len(inputPaths))
for _, inputPath := range inputPaths { for _, inputPath := range inputPaths {
metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath) metadata, err := engine.ReadMetadata(ctx, ctx.Log(), inputPath)
@@ -223,41 +518,22 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
// Let's get the data from the form and validate them. form := ctx.FormData()
var ( metadata := FormDataPdfMetadata(form, true)
inputPaths []string
metadata map[string]interface{}
)
err := ctx.FormData(). var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
MandatoryCustom("metadata", func(value string) error {
if len(value) > 0 {
err := json.Unmarshal([]byte(value), &metadata)
if err != nil {
return fmt.Errorf("unmarshal metadata: %w", err)
}
}
if len(metadata) == 0 {
return errors.New("no metadata")
}
return nil
}).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
// Alright, let's convert the PDFs. err = WriteMetadataStub(ctx, engine, metadata, inputPaths)
for _, inputPath := range inputPaths { if err != nil {
err = engine.WriteMetadata(ctx, ctx.Log(), metadata, inputPath) return fmt.Errorf("write metadata: %w", err)
if err != nil {
return fmt.Errorf("write metadata: %w", err)
}
} }
// Last but not least, add the output paths to the context so that
// the API is able to send them as a response to the client.
err = ctx.AddOutputPaths(inputPaths...) err = ctx.AddOutputPaths(inputPaths...)
if err != nil { if err != nil {
return fmt.Errorf("add output paths: %w", err) return fmt.Errorf("add output paths: %w", err)

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
// interface using the PDFtk command-line tool. This package allows for: // interface using the PDFtk command-line tool. This package allows for:
// //
// 1. The merging of PDF files. // 1. The merging of PDF files.
// 2. The splitting of PDF files.
// //
// The path to the PDFtk binary must be specified using the PDFTK_BIN_PATH // The path to the PDFtk binary must be specified using the PDFTK_BIN_PATH
// environment variable. // environment variable.

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"go.uber.org/zap" "go.uber.org/zap"
@@ -51,6 +52,34 @@ func (engine *PdfTk) Validate() error {
return nil return nil
} }
// Split splits a given PDF file.
func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
switch mode.Mode {
case gotenberg.SplitModePages:
if !mode.Unify {
return nil, fmt.Errorf("split PDFs using mode '%s' without unify with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
args = append(args, inputPath, "cat", mode.Span, "output", outputPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with PDFtk: %w", err)
}
return []string{outputPath}, nil
}
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string var args []string
@@ -70,6 +99,11 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths [
return fmt.Errorf("merge PDFs with PDFtk: %w", err) return fmt.Errorf("merge PDFs with PDFtk: %w", err)
} }
// Flatten is not available in this implementation.
func (engine *PdfTk) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Convert is not available in this implementation. // Convert is not available in this implementation.
func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)

View File

@@ -116,7 +116,7 @@ func TestPdfTk_Merge(t *testing.T) {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
} }
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll() outputDir, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
@@ -142,6 +142,105 @@ func TestPdfTk_Merge(t *testing.T) {
} }
} }
func TestPdfCpu_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfSplitModeNotSupported (no unify with pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1", Unify: false},
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(PdfTk)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
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")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
if tc.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestPdfTk_Flatten(t *testing.T) {
engine := new(PdfTk)
err := engine.Flatten(context.TODO(), zap.NewNop(), "")
if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) {
t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err)
}
}
func TestPdfTk_Convert(t *testing.T) { func TestPdfTk_Convert(t *testing.T) {
engine := new(PdfTk) engine := new(PdfTk)
err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")

View File

@@ -2,6 +2,8 @@
// interface using the QPDF command-line tool. This package allows for: // interface using the QPDF command-line tool. This package allows for:
// //
// 1. The merging of PDF files. // 1. The merging of PDF files.
// 2. The splitting of PDF files.
// 3. Flattening of PDF files
// //
// The path to the QPDF binary must be specified using the QPDK_BIN_PATH // The path to the QPDF binary must be specified using the QPDK_BIN_PATH
// environment variable. // environment variable.

View File

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"path/filepath"
"go.uber.org/zap" "go.uber.org/zap"
@@ -45,12 +46,40 @@ func (engine *QPdf) Provision(ctx *gotenberg.Context) error {
func (engine *QPdf) Validate() error { func (engine *QPdf) Validate() error {
_, err := os.Stat(engine.binPath) _, err := os.Stat(engine.binPath)
if os.IsNotExist(err) { if os.IsNotExist(err) {
return fmt.Errorf("QPdf binary path does not exist: %w", err) return fmt.Errorf("QPDF binary path does not exist: %w", err)
} }
return nil return nil
} }
// Split splits a given PDF file.
func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
var args []string
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
switch mode.Mode {
case gotenberg.SplitModePages:
if !mode.Unify {
return nil, fmt.Errorf("split PDFs using mode '%s' without unify with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
args = append(args, inputPath, "--pages", ".", mode.Span, "--", outputPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with QPDF: %w", err)
}
return []string{outputPath}, nil
}
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
var args []string var args []string
@@ -72,6 +101,28 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
return fmt.Errorf("merge PDFs with QPDF: %w", err) return fmt.Errorf("merge PDFs with QPDF: %w", err)
} }
// 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 = append(args, "--generate-appearances")
args = append(args, "--flatten-annotations=all")
args = append(args, "--replace-input")
args = append(args, 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 nil
}
return fmt.Errorf("flatten PDFs with QPDF: %w", err)
}
// Convert is not available in this implementation. // Convert is not available in this implementation.
func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)

View File

@@ -3,6 +3,8 @@ package qpdf
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"io"
"os" "os"
"reflect" "reflect"
"testing" "testing"
@@ -116,7 +118,7 @@ func TestQPdf_Merge(t *testing.T) {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
} }
fs := gotenberg.NewFileSystem() fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll() outputDir, err := fs.MkdirAll()
if err != nil { if err != nil {
t.Fatalf("expected error but got: %v", err) t.Fatalf("expected error but got: %v", err)
@@ -142,6 +144,191 @@ func TestQPdf_Merge(t *testing.T) {
} }
} }
func TestQPdf_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
mode gotenberg.SplitMode
inputPath string
expectError bool
expectedError error
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "ErrPdfSplitModeNotSupported",
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "ErrPdfSplitModeNotSupported (no unify with pages)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1", Unify: false},
expectError: true,
expectedError: gotenberg.ErrPdfSplitModeNotSupported,
expectOutputPathsCount: 0,
},
{
scenario: "invalid context",
ctx: nil,
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "",
expectError: true,
expectOutputPathsCount: 0,
},
{
scenario: "success (pages & unify)",
ctx: context.TODO(),
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
expectError: false,
expectOutputPathsCount: 1,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(QPdf)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
outputPaths, err := engine.Split(tc.ctx, zap.NewNop(), tc.mode, tc.inputPath, outputDir)
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")
}
if tc.expectedError != nil && !errors.Is(err, tc.expectedError) {
t.Fatalf("expected error %v but got: %v", tc.expectedError, err)
}
if tc.expectOutputPathsCount != len(outputPaths) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(outputPaths))
}
})
}
}
func TestQPdf_Flatten(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx context.Context
inputPath string
createCopy bool
expectError bool
}{
{
scenario: "invalid context",
ctx: nil,
expectError: true,
},
{
scenario: "invalid input path",
ctx: context.TODO(),
inputPath: "foo.pdf",
expectError: true,
},
{
scenario: "success",
ctx: context.TODO(),
inputPath: "/tests/test/testdata/pdfengines/sample3.pdf",
createCopy: true,
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
engine := new(QPdf)
err := engine.Provision(nil)
if err != nil {
t.Fatalf("expected error but got: %v", err)
}
var destinationPath string
if tc.createCopy {
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
outputDir, err := fs.MkdirAll()
if err != nil {
t.Fatalf("expected error no but got: %v", err)
}
defer func() {
err = os.RemoveAll(fs.WorkingDirPath())
if err != nil {
t.Fatalf("expected no error while cleaning up but got: %v", err)
}
}()
destinationPath = fmt.Sprintf("%s/copy_temp.pdf", outputDir)
source, err := os.Open(tc.inputPath)
if err != nil {
t.Fatalf("open source file: %v", err)
}
defer func(source *os.File) {
err := source.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}(source)
destination, err := os.Create(destinationPath)
if err != nil {
t.Fatalf("create destination file: %v", err)
}
defer func(destination *os.File) {
err := destination.Close()
if err != nil {
t.Fatalf("close file: %v", err)
}
}(destination)
_, err = io.Copy(destination, source)
if err != nil {
t.Fatalf("copy source into destination: %v", err)
}
} else {
destinationPath = tc.inputPath
}
err = engine.Flatten(tc.ctx, zap.NewNop(), destinationPath)
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 TestQPdf_Convert(t *testing.T) { func TestQPdf_Convert(t *testing.T) {
engine := new(QPdf) engine := new(QPdf)
err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") err := engine.Convert(context.TODO(), zap.NewNop(), gotenberg.PdfFormats{}, "", "")

View File

@@ -1,84 +0,0 @@
#!/bin/bash
set -e
# Args.
GOLANG_VERSION="$1"
GOTENBERG_VERSION="$2"
GOTENBERG_USER_GID="$3"
GOTENBERG_USER_UID="$4"
NOTO_COLOR_EMOJI_VERSION="$5"
PDFTK_VERSION="$6"
PDFCPU_VERSION="$7"
DOCKER_REGISTRY="$8"
DOCKER_REPOSITORY="$9"
LINUX_AMD64_RELEASE="${10}"
# Find out if given version is "semver".
GOTENBERG_VERSION="${GOTENBERG_VERSION//v}"
IFS='.' read -ra SEMVER <<< "$GOTENBERG_VERSION"
VERSION_LENGTH=${#SEMVER[@]}
TAGS=()
TAGS_CLOUD_RUN=()
if [ "$VERSION_LENGTH" -eq 3 ]; then
MAJOR="${SEMVER[0]}"
MINOR="${SEMVER[1]}"
PATCH="${SEMVER[2]}"
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest")
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR")
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR")
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:latest-cloudrun")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR-cloudrun")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR-cloudrun")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$MAJOR.$MINOR.$PATCH-cloudrun")
else
# Normalizes version.
GOTENBERG_VERSION="${GOTENBERG_VERSION// /-}"
GOTENBERG_VERSION="$(echo "$GOTENBERG_VERSION" | tr -cd '[:alnum:]._\-')"
if [[ "$GOTENBERG_VERSION" =~ ^[\.\-] ]]; then
GOTENBERG_VERSION="_${GOTENBERG_VERSION#?}"
fi
if [ "${#GOTENBERG_VERSION}" -gt 128 ]; then
GOTENBERG_VERSION="${GOTENBERG_VERSION:0:128}"
fi
TAGS+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION")
TAGS_CLOUD_RUN+=("-t" "$DOCKER_REGISTRY/$DOCKER_REPOSITORY:$GOTENBERG_VERSION-cloudrun")
fi
# Multi-arch build takes a lot of time.
if [ "$LINUX_AMD64_RELEASE" = true ]; then
PLATFORM_FLAG="--platform linux/amd64"
else
PLATFORM_FLAG="--platform linux/amd64,linux/arm64,linux/386,linux/arm/v7"
fi
docker buildx build \
--build-arg GOLANG_VERSION="$GOLANG_VERSION" \
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
--build-arg GOTENBERG_USER_GID="$GOTENBERG_USER_GID" \
--build-arg GOTENBERG_USER_UID="$GOTENBERG_USER_UID" \
--build-arg NOTO_COLOR_EMOJI_VERSION="$NOTO_COLOR_EMOJI_VERSION" \
--build-arg PDFTK_VERSION="$PDFTK_VERSION" \
--build-arg PDFCPU_VERSION="$PDFCPU_VERSION" \
$PLATFORM_FLAG \
"${TAGS[@]}" \
--push \
-f build/Dockerfile .
# Cloud Run variant.
# Only linux/amd64! See https://github.com/gotenberg/gotenberg/issues/505#issuecomment-1264679278.
docker buildx build \
--build-arg DOCKER_REGISTRY="$DOCKER_REGISTRY" \
--build-arg DOCKER_REPOSITORY="$DOCKER_REPOSITORY" \
--build-arg GOTENBERG_VERSION="$GOTENBERG_VERSION" \
--platform linux/amd64 \
"${TAGS_CLOUD_RUN[@]}" \
--push \
-f build/Dockerfile.cloudrun .

View File

@@ -19,6 +19,7 @@ ENV CGO_ENABLED=1
COPY --from=golang /usr/local/go /usr/local/go COPY --from=golang /usr/local/go /usr/local/go
RUN apt-get update -qq &&\ RUN apt-get update -qq &&\
apt-get upgrade -yqq &&\
apt-get install -y -qq --no-install-recommends \ apt-get install -y -qq --no-install-recommends \
sudo \ sudo \
# gcc for cgo. # gcc for cgo.

BIN
test/testdata/pdfengines/sample3.pdf vendored Normal file

Binary file not shown.