Compare commits

...

8 Commits

Author SHA1 Message Date
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
Julien Neuhart
1b49b35365 fix(exiftool): convert interface{} array to string array 2024-11-21 09:55:10 +01:00
Julien Neuhart
333ab7066c chore(libreoffice): remove non-relevant comment about old gc module 2024-11-19 14:25:07 +01:00
45 changed files with 2791 additions and 717 deletions

View File

@@ -73,6 +73,7 @@ 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_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 +142,7 @@ 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_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) \

37
go.mod
View File

@@ -6,7 +6,7 @@ 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-20241222144035-c16d098c0fb6
github.com/chromedp/chromedp v0.11.2 github.com/chromedp/chromedp v0.11.2
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
@@ -14,28 +14,31 @@ require (
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.5
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.31.0 // indirect
golang.org/x/net v0.31.0 golang.org/x/net v0.33.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.28.0 // indirect
golang.org/x/term v0.26.0 golang.org/x/term v0.27.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.11
)
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.1 // 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/mailru/easyjson v0.9.0 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-colorable v0.1.13 // 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.61.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
github.com/yusufpapurcu/wmi v1.2.4 // indirect
golang.org/x/time v0.8.0 // indirect golang.org/x/time v0.8.0 // indirect
google.golang.org/protobuf v1.35.2 // indirect google.golang.org/protobuf v1.36.1 // indirect
) )

74
go.sum
View File

@@ -11,8 +11,8 @@ 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-20241222144035-c16d098c0fb6 h1:dAUcp/W5RpJSZW/HksEHfAAoMBIvSFFIwslAFEte+6g=
github.com/chromedp/cdproto v0.0.0-20241110205750-a72e6703cd9b/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM= github.com/chromedp/cdproto v0.0.0-20241222144035-c16d098c0fb6/go.mod h1:4XqMl3iIW08jtieURWL6Tt5924w21pxirC6th662XUM=
github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0= github.com/chromedp/chromedp v0.11.2 h1:ZRHTh7DjbNTlfIv3NFTbB7eVeu5XCNkgrpcGSpn2oX0=
github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8= github.com/chromedp/chromedp v0.11.2/go.mod h1:lr8dFRLKsdTTWb75C/Ttol2vnBKOSnt0BW8R9Xaupi8=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
@@ -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.1 h1:sdRKd6plj7KYW33EH5As6YKfe8m9zbN9JMrOjNVF/BE=
github.com/ebitengine/purego v0.8.1/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,14 +66,16 @@ 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/mailru/easyjson v0.9.0 h1:PrnmzHw7262yW8sTBwxi1PdJA3Iw/EKBa8psRf7d9a4=
github.com/mailru/easyjson v0.9.0/go.mod h1:1+xMtQp2MRNVL/V1bOzuP3aP8VNwRW55fQUto+XFtTU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
@@ -88,28 +93,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.61.0 h1:3gv/GThfX0cV2lpO7gkTUwZru38mxevy90Bj8YFSRQQ=
github.com/prometheus/common v0.60.1/go.mod h1:h0LYf1R1deLSKtD4Vdg8gy4RuOvENW2J/h19V5NADQw= github.com/prometheus/common v0.61.0/go.mod h1:zr29OCN/2BsJRaFwG8QOBr41D6kkchKbpeNH7pAjb/s=
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/shirou/gopsutil/v4 v4.24.11 h1:WaU9xqGFKvFfsUv94SXcUPD7rCkU0vr/asVdQOBZNj8=
github.com/shirou/gopsutil/v4 v4.24.11/go.mod h1:s4D/wg+ag4rG0WO7AiTj2BeYCRhym0vM7DHbZRxnIT8=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/spf13/pflag v1.0.5/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 +135,35 @@ 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.31.0 h1:ihbySMvVjLAeSH1IbfcRTkD/iNscyz8rGzjF/E5hV6U=
golang.org/x/crypto v0.29.0/go.mod h1:+F4F4N5hv6v38hfeYwTdx20oUvLLc+QfrE9Ax9HtgRg= golang.org/x/crypto v0.31.0/go.mod h1:kDsLvtWBEx7MV9tJOj9bnXsPbxwJQ6csT/x4KIN4Ssk=
golang.org/x/net v0.31.0 h1:68CPQngjLL0r2AlUKiSxtQFKvzRVbnzLwMUn5SzcLHo= golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
golang.org/x/net v0.31.0/go.mod h1:P4fl1q7dY2hnZFxEk4pPSkDHF+QqjitcnDjUQyMM+pM= golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
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-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
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.28.0 h1:Fksou7UEQUWlKvIdsqzJmUmCX3cZuD2+P3XyyzwMhlA=
golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.28.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.26.0 h1:WEQa6V3Gja/BhNxg540hBip/kkaYtRg3cxg4oXSw4AU= golang.org/x/term v0.27.0 h1:WP60Sv1nlK1T6SupCHbXzSaN0b9wUmsPoRS9b61A23Q=
golang.org/x/term v0.26.0/go.mod h1:Si5m1o57C5nBNQo5z1iq+XDijt21BDBDp2bK0QI8e3E= golang.org/x/term v0.27.0/go.mod h1:iMsnZpn0cago0GOrHO2+Y7u7JPn5AylBrcoWkElMTSM=
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.8.0 h1:9i3RxcPv3PZnitoVGMPDKZSq1xW1gK1Xy3ArNOGZfEg=
golang.org/x/time v0.8.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/time v0.8.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.1 h1:yBPeRvTftaleIgM3PZ/WBIZ7XM/eEYAaEyCwvyjq/gk=
google.golang.org/protobuf v1.35.2/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE= google.golang.org/protobuf v1.36.1/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"
) )
@@ -36,6 +37,7 @@ func (mod *ValidatorMock) Validate() error {
// PdfEngineMock is a mock for the [PdfEngine] interface. // PdfEngineMock is a mock for the [PdfEngine] interface.
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)
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 +47,10 @@ 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) 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 +143,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 +171,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,9 @@ 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
},
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 +72,11 @@ 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.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 +214,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"
@@ -65,6 +93,9 @@ type PdfEngine interface {
// 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)
// 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
} }

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()

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"
} }

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,60 @@ 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
go func() { go func() {
// 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.Info(fmt.Sprintf("Chromium process %d killed", p.Pid))
}
}()
}
}
// 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

@@ -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

@@ -5,6 +5,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"os" "os"
"reflect"
"github.com/barasher/go-exiftool" "github.com/barasher/go-exiftool"
"go.uber.org/zap" "go.uber.org/zap"
@@ -57,6 +58,11 @@ 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)
}
// 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)
@@ -109,6 +115,17 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
fileMetadata[0].SetString(key, val) fileMetadata[0].SetString(key, val)
case []string: case []string:
fileMetadata[0].SetStrings(key, val) fileMetadata[0].SetStrings(key, val)
case []interface{}:
// See https://github.com/gotenberg/gotenberg/issues/1048.
strings := make([]string, len(val))
for i, entry := range val {
if str, ok := entry.(string); ok {
strings[i] = str
continue
}
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
}
fileMetadata[0].SetStrings(key, strings)
case bool: case bool:
fileMetadata[0].SetString(key, fmt.Sprintf("%t", val)) fileMetadata[0].SetString(key, fmt.Sprintf("%t", val))
case int: case int:
@@ -122,7 +139,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
// TODO: support more complex cases, e.g., arrays and nested objects // TODO: support more complex cases, e.g., arrays and nested objects
// (limitations in underlying library). // (limitations in underlying library).
default: default:
return fmt.Errorf("write PDF metadata with ExifTool: %w", gotenberg.ErrPdfEngineMetadataValueNotSupported) return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
} }
} }

View File

@@ -82,6 +82,15 @@ 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_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{}, "", "")
@@ -162,7 +171,20 @@ func TestExiftool_WriteMetadata(t *testing.T) {
expectError: true, expectError: true,
}, },
{ {
scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported", scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (not string array)",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Unsupported": []interface{}{
"foo",
1,
},
},
expectError: true,
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
},
{
scenario: "gotenberg.ErrPdfEngineMetadataValueNotSupported (default)",
createCopy: true, createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{ metadata: map[string]interface{}{
@@ -171,6 +193,24 @@ func TestExiftool_WriteMetadata(t *testing.T) {
expectError: true, expectError: true,
expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported, expectedError: gotenberg.ErrPdfEngineMetadataValueNotSupported,
}, },
{
scenario: "success (interface array to string array)",
createCopy: true,
inputPath: "/tests/test/testdata/pdfengines/sample1.pdf",
metadata: map[string]interface{}{
"Keywords": []interface{}{
"first",
"second",
},
},
expectMetadata: map[string]interface{}{
"Keywords": []interface{}{
"first",
"second",
},
},
expectError: false,
},
{ {
scenario: "success", scenario: "success",
createCopy: true, createCopy: true,
@@ -226,7 +266,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()
@@ -365,13 +366,6 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
return ErrRuntimeException return ErrRuntimeException
} }
// Possible errors:
// 1. LibreOffice failed for some reason.
// 2. Context done.
//
// On the second scenario, LibreOffice might not have time to remove some
// of its temporary files, as it has been killed without warning. The
// garbage collector will delete them for us (if the module is loaded).
return fmt.Errorf("convert to PDF: %w", err) return fmt.Errorf("convert to PDF: %w", err)
} }

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,11 @@ 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)
}
// 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,21 @@ 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_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 +144,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 +174,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,11 @@ 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{}
) )
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 +124,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 {
@@ -138,12 +140,6 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
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 +168,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 +204,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 +214,54 @@ 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 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 +275,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 {
@@ -365,32 +402,6 @@ func TestConvertRoute(t *testing.T) {
expectHttpError: false, expectHttpError: false,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "cannot rename 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",
"document2.doc": "/document2.doc",
})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return errors.New("cannot rename")
}})
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", ".doc"}
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{ {
scenario: "cannot add output paths", scenario: "cannot add output paths",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
@@ -550,9 +561,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,42 @@ 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
}
// 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,103 @@ 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_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

@@ -13,6 +13,7 @@ import (
type multiPdfEngines struct { type multiPdfEngines struct {
mergeEngines []gotenberg.PdfEngine mergeEngines []gotenberg.PdfEngine
splitEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine convertEngines []gotenberg.PdfEngine
readMedataEngines []gotenberg.PdfEngine readMedataEngines []gotenberg.PdfEngine
writeMedataEngines []gotenberg.PdfEngine writeMedataEngines []gotenberg.PdfEngine
@@ -20,12 +21,14 @@ type multiPdfEngines struct {
func newMultiPdfEngines( func newMultiPdfEngines(
mergeEngines, mergeEngines,
splitEngines,
convertEngines, convertEngines,
readMetadataEngines, readMetadataEngines,
writeMedataEngines []gotenberg.PdfEngine, writeMedataEngines []gotenberg.PdfEngine,
) *multiPdfEngines { ) *multiPdfEngines {
return &multiPdfEngines{ return &multiPdfEngines{
mergeEngines: mergeEngines, mergeEngines: mergeEngines,
splitEngines: splitEngines,
convertEngines: convertEngines, convertEngines: convertEngines,
readMedataEngines: readMetadataEngines, readMedataEngines: readMetadataEngines,
writeMedataEngines: writeMedataEngines, writeMedataEngines: writeMedataEngines,
@@ -57,6 +60,44 @@ 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)
}
// 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 {

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,97 @@ 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_Convert(t *testing.T) { func TestMultiPdfEngines_Convert(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
@@ -124,25 +203,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 +229,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 +247,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 +294,21 @@ func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
}{ }{
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, readMedataEngines: []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, readMedataEngines: []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 +320,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, readMedataEngines: []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 +338,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, readMedataEngines: []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 +385,21 @@ func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
}{ }{
{ {
scenario: "nominal behavior", scenario: "nominal behavior",
engine: newMultiPdfEngines( engine: &multiPdfEngines{
nil, writeMedataEngines: []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, writeMedataEngines: []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 +411,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, writeMedataEngines: []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 +429,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, writeMedataEngines: []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

@@ -28,6 +28,7 @@ func init() {
// enabled. // enabled.
type PdfEngines struct { type PdfEngines struct {
mergeNames []string mergeNames []string
splitNames []string
convertNames []string convertNames []string
readMetadataNames []string readMetadataNames []string
writeMedataNames []string writeMedataNames []string
@@ -42,6 +43,7 @@ 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-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 +66,7 @@ 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")
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 +101,11 @@ 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.convertNames = defaultNames mod.convertNames = defaultNames
if len(convertNames) > 0 { if len(convertNames) > 0 {
mod.convertNames = convertNames mod.convertNames = convertNames
@@ -161,6 +169,7 @@ func (mod *PdfEngines) Validate() error {
} }
findNonExistingEngines(mod.mergeNames) findNonExistingEngines(mod.mergeNames)
findNonExistingEngines(mod.splitNames)
findNonExistingEngines(mod.convertNames) findNonExistingEngines(mod.convertNames)
findNonExistingEngines(mod.readMetadataNames) findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMedataNames) findNonExistingEngines(mod.writeMedataNames)
@@ -177,6 +186,7 @@ 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("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 medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
@@ -201,6 +211,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
return newMultiPdfEngines( return newMultiPdfEngines(
engines(mod.mergeNames), engines(mod.mergeNames),
engines(mod.splitNames),
engines(mod.convertNames), engines(mod.convertNames),
engines(mod.readMetadataNames), engines(mod.readMetadataNames),
engines(mod.writeMedataNames), engines(mod.writeMedataNames),
@@ -222,6 +233,7 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
return []api.Route{ return []api.Route{
mergeRoute(engine), mergeRoute(engine),
splitRoute(engine),
convertRoute(engine), convertRoute(engine),
readMetadataRoute(engine), readMetadataRoute(engine),
writeMetadataRoute(engine), writeMetadataRoute(engine),

View File

@@ -26,6 +26,7 @@ func TestPdfEngines_Provision(t *testing.T) {
scenario string scenario string
ctx *gotenberg.Context ctx *gotenberg.Context
expectedMergePdfEngines []string expectedMergePdfEngines []string
expectedSplitPdfEngines []string
expectedConvertPdfEngines []string expectedConvertPdfEngines []string
expectedReadMetadataPdfEngines []string expectedReadMetadataPdfEngines []string
expectedWriteMetadataPdfEngines []string expectedWriteMetadataPdfEngines []string
@@ -66,6 +67,7 @@ func TestPdfEngines_Provision(t *testing.T) {
) )
}(), }(),
expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"}, expectedMergePdfEngines: []string{"qpdf", "pdfcpu", "pdftk"},
expectedSplitPdfEngines: []string{"pdfcpu", "qpdf", "pdftk"},
expectedConvertPdfEngines: []string{"libreoffice-pdfengine"}, expectedConvertPdfEngines: []string{"libreoffice-pdfengine"},
expectedReadMetadataPdfEngines: []string{"exiftool"}, expectedReadMetadataPdfEngines: []string{"exiftool"},
expectedWriteMetadataPdfEngines: []string{"exiftool"}, expectedWriteMetadataPdfEngines: []string{"exiftool"},
@@ -107,7 +109,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-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 +127,7 @@ func TestPdfEngines_Provision(t *testing.T) {
}(), }(),
expectedMergePdfEngines: []string{"b"}, expectedMergePdfEngines: []string{"b"},
expectedSplitPdfEngines: []string{"a"},
expectedConvertPdfEngines: []string{"b"}, expectedConvertPdfEngines: []string{"b"},
expectedReadMetadataPdfEngines: []string{"a"}, expectedReadMetadataPdfEngines: []string{"a"},
expectedWriteMetadataPdfEngines: []string{"a"}, expectedWriteMetadataPdfEngines: []string{"a"},
@@ -200,6 +203,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])
@@ -303,17 +312,19 @@ 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.writeMedataNames = []string{"foo", "bar"}
messages := mod.SystemMessages() messages := mod.SystemMessages()
if len(messages) != 4 { if len(messages) != 5 {
t.Errorf("expected one and only one message, but got %d", len(messages)) t.Errorf("expected one and only one message, but got %d", 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("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 medata engines - %s", strings.Join(mod.writeMedataNames[:], " ")),
@@ -329,6 +340,7 @@ 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"},
splitNames: []string{"foo", "bar"},
convertNames: []string{"foo", "bar"}, convertNames: []string{"foo", "bar"},
readMetadataNames: []string{"foo", "bar"}, readMetadataNames: []string{"foo", "bar"},
writeMedataNames: []string{"foo", "bar"}, writeMedataNames: []string{"foo", "bar"},
@@ -370,7 +382,7 @@ func TestPdfEngines_Routes(t *testing.T) {
}{ }{
{ {
scenario: "routes not disabled", scenario: "routes not disabled",
expectRoutes: 4, expectRoutes: 5,
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,232 @@ 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
}
// 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 +250,96 @@ 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
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
String("pdfa", &pdfa, "").
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)
}
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
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
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)
}
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 return nil
@@ -108,27 +357,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 +379,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 +396,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 +415,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 +423,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 +453,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)

View File

@@ -3,12 +3,16 @@ package pdfengines
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os"
"reflect"
"slices" "slices"
"strings" "strings"
"testing" "testing"
"github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap" "go.uber.org/zap"
@@ -16,6 +20,594 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
) )
func TestFormDataPdfSplitMode(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
mandatory bool
expectedSplitMode gotenberg.SplitMode
expectValidationError bool
}{
{
scenario: "no custom form fields",
ctx: &api.ContextMock{Context: new(api.Context)},
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{},
expectValidationError: false,
},
{
scenario: "no custom form fields (mandatory)",
ctx: &api.ContextMock{Context: new(api.Context)},
mandatory: true,
expectedSplitMode: gotenberg.SplitMode{},
expectValidationError: true,
},
{
scenario: "invalid splitMode",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"foo",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{},
expectValidationError: true,
},
{
scenario: "invalid splitSpan (intervals)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"intervals",
},
"splitSpan": {
"1-2",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals},
expectValidationError: true,
},
{
scenario: "splitSpan inferior to 1 (intervals)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"intervals",
},
"splitSpan": {
"-1",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals},
expectValidationError: true,
},
{
scenario: "invalid splitUnify (intervals)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"intervals",
},
"splitSpan": {
"1",
},
"splitUnify": {
"true",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1", Unify: true},
expectValidationError: true,
},
{
scenario: "valid form fields (intervals)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"intervals",
},
"splitSpan": {
"1",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
expectValidationError: false,
},
{
scenario: "valid form fields (pages)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"pages",
},
"splitSpan": {
"1-2",
},
"splitUnify": {
"true",
},
})
return ctx
}(),
mandatory: false,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
expectValidationError: false,
},
{
scenario: "valid form fields (mandatory)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetValues(map[string][]string{
"splitMode": {
"intervals",
},
"splitSpan": {
"1",
},
})
return ctx
}(),
mandatory: true,
expectedSplitMode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
expectValidationError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
tc.ctx.SetLogger(zap.NewNop())
form := tc.ctx.Context.FormData()
actual := FormDataPdfSplitMode(form, tc.mandatory)
if !reflect.DeepEqual(actual, tc.expectedSplitMode) {
t.Fatalf("expected %+v but got: %+v", tc.expectedSplitMode, 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 TestFormDataPdfFormats(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 := FormDataPdfFormats(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
mandatory bool
expectedMetadata map[string]interface{}
expectValidationError bool
}{
{
scenario: "no metadata form field",
ctx: &api.ContextMock{Context: new(api.Context)},
mandatory: false,
expectedMetadata: nil,
expectValidationError: false,
},
{
scenario: "no metadata form field (mandatory)",
ctx: &api.ContextMock{Context: new(api.Context)},
mandatory: true,
expectedMetadata: nil,
expectValidationError: true,
},
{
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
}(),
mandatory: false,
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
}(),
mandatory: false,
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, tc.mandatory)
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 TestMergeStub(t *testing.T) {
for _, tc := range []struct {
scenario string
engine gotenberg.PdfEngine
inputPaths []string
expectError bool
}{
{
scenario: "no input path (nil)",
inputPaths: nil,
expectError: true,
},
{
scenario: "no input path (empty)",
inputPaths: make([]string, 0),
expectError: true,
},
{
scenario: "only one input path",
inputPaths: []string{"my.pdf"},
expectError: false,
},
{
scenario: "merge error",
engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
inputPaths: []string{"my.pdf", "my2.pdf"},
expectError: true,
},
{
scenario: "merge success",
engine: &gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
inputPaths: []string{"my.pdf", "my2.pdf"},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
_, err := MergeStub(new(api.Context), tc.engine, tc.inputPaths)
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 TestSplitPdfStub(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
engine gotenberg.PdfEngine
mode gotenberg.SplitMode
expectError bool
}{
{
scenario: "no split mode",
mode: gotenberg.SplitMode{},
ctx: &api.ContextMock{Context: new(api.Context)},
expectError: false,
},
{
scenario: "cannot create subdirectory",
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return errors.New("cannot create subdirectory")
}})
return ctx
}(),
expectError: true,
},
{
scenario: "split error",
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
return ctx
}(),
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,
},
{
scenario: "rename error",
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return errors.New("cannot rename")
}})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
},
expectError: true,
},
{
scenario: "success (intervals)",
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModeIntervals, Span: "1"},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
},
expectError: false,
},
{
scenario: "success (pages)",
mode: gotenberg.SplitMode{Mode: gotenberg.SplitModePages, Span: "1-2", Unify: true},
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetMkdirAll(&gotenberg.MkdirAllMock{MkdirAllMock: func(path string, perm os.FileMode) error {
return nil
}})
ctx.SetPathRename(&gotenberg.PathRenameMock{RenameMock: func(oldpath, newpath string) error {
return nil
}})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
dirPath := fmt.Sprintf("%s/%s", os.TempDir(), uuid.NewString())
tc.ctx.SetDirPath(dirPath)
tc.ctx.SetLogger(zap.NewNop())
_, err := SplitPdfStub(tc.ctx.Context, tc.engine, tc.mode, []string{"my.pdf", "my2.pdf"})
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 TestConvertStub(t *testing.T) {
for _, tc := range []struct {
scenario string
engine gotenberg.PdfEngine
pdfFormats gotenberg.PdfFormats
expectError bool
}{
{
scenario: "no PDF formats",
pdfFormats: gotenberg.PdfFormats{},
expectError: false,
},
{
scenario: "convert error",
engine: &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
pdfFormats: gotenberg.PdfFormats{
PdfA: gotenberg.PdfA3b,
PdfUa: true,
},
expectError: true,
},
{
scenario: "convert success",
engine: &gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
pdfFormats: gotenberg.PdfFormats{
PdfA: gotenberg.PdfA3b,
PdfUa: true,
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
_, err := ConvertStub(new(api.Context), tc.engine, tc.pdfFormats, []string{"my.pdf", "my2.pdf"})
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 TestWriteMetadataStub(t *testing.T) {
for _, tc := range []struct {
scenario string
engine gotenberg.PdfEngine
metadata map[string]interface{}
expectError bool
}{
{
scenario: "no metadata (nil)",
metadata: nil,
expectError: false,
},
{
scenario: "no metadata (empty)",
metadata: make(map[string]interface{}, 0),
expectError: false,
},
{
scenario: "write metadata error",
engine: &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
},
metadata: map[string]interface{}{"foo": "bar"},
expectError: true,
},
{
scenario: "write metadata success",
engine: &gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
metadata: map[string]interface{}{"foo": "bar"},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := WriteMetadataStub(new(api.Context), tc.engine, tc.metadata, []string{"my.pdf", "my2.pdf"})
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 TestMergeHandler(t *testing.T) { func TestMergeHandler(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
@@ -221,6 +813,248 @@ func TestMergeHandler(t *testing.T) {
} }
} }
func TestSplitHandler(t *testing.T) {
for _, tc := range []struct {
scenario string
ctx *api.ContextMock
engine gotenberg.PdfEngine
expectError bool
expectHttpError bool
expectHttpStatus int
expectOutputPathsCount int
expectOutputPaths []string
}{
{
scenario: "missing at least one mandatory file",
ctx: &api.ContextMock{Context: new(api.Context)},
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "no split mode",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
return ctx
}(),
expectError: true,
expectHttpError: true,
expectHttpStatus: http.StatusBadRequest,
expectOutputPathsCount: 0,
},
{
scenario: "error from PDF engine (split)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
return ctx
}(),
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: "error from PDF engine (convert)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
"pdfua": {
"true",
},
})
return ctx
}(),
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 errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "error from PDF engine (write metadata)",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "cannot add output paths",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
})
ctx.SetCancelled(true)
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{inputPath}, nil
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{
scenario: "success",
ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{
"file.pdf": "/file.pdf",
})
ctx.SetValues(map[string][]string{
"splitMode": {
gotenberg.SplitModeIntervals,
},
"splitSpan": {
"1",
},
"pdfua": {
"true",
},
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
})
return ctx
}(),
engine: &gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return []string{"file_split_1.pdf", "file_split_2.pdf"}, nil
},
ConvertMock: func(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
},
expectError: false,
expectHttpError: false,
expectOutputPathsCount: 2,
expectOutputPaths: []string{"/file/file_0.pdf", "/file/file_1.pdf"},
},
} {
t.Run(tc.scenario, func(t *testing.T) {
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.Set("context", tc.ctx.Context)
err := splitRoute(tc.engine).Handler(c)
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)
}
var httpErr api.HttpError
isHttpError := errors.As(err, &httpErr)
if tc.expectHttpError && !isHttpError {
t.Errorf("expected an HTTP error but got: %v", err)
}
if !tc.expectHttpError && isHttpError {
t.Errorf("expected no HTTP error but got one: %v", httpErr)
}
if err != nil && tc.expectHttpError && isHttpError {
status, _ := httpErr.HttpError()
if status != tc.expectHttpStatus {
t.Errorf("expected %d as HTTP status code but got %d", tc.expectHttpStatus, status)
}
}
if tc.expectOutputPathsCount != len(tc.ctx.OutputPaths()) {
t.Errorf("expected %d output paths but got %d", tc.expectOutputPathsCount, len(tc.ctx.OutputPaths()))
}
for _, path := range tc.expectOutputPaths {
if !slices.Contains(tc.ctx.OutputPaths(), path) {
t.Errorf("expected '%s' in output paths %v", path, tc.ctx.OutputPaths())
}
}
})
}
}
func TestConvertHandler(t *testing.T) { func TestConvertHandler(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string

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

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,96 @@ 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_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,7 @@
// 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.
// //
// 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

View File

@@ -116,7 +116,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 +142,96 @@ 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_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{}, "", "")