From 71911cb1a702265a1848d3070f6e2b71449dd4a0 Mon Sep 17 00:00:00 2001 From: Piyush Srivastava <41116518+piyushsriv@users.noreply.github.com> Date: Fri, 16 Feb 2024 18:46:50 +0000 Subject: [PATCH] feat(ExifTool): add capability to overwrite metadata of the PDF generated (#776) * add new metadata functions to pdf engine interface * add exiftool module with relevant test cases * use exiftool to overwrite metadata in libreoffice * use exiftool to overwrite metadata in chromium * fix linter issues * fix more linter issues * more test cases for better coverage * remove utils * minor changes * remove metadata from pdfoptions * correct indentation * read/write metadata one file at a time. --- build/Dockerfile | 4 +- go.mod | 29 +- go.sum | 41 +- pkg/gotenberg/mocks.go | 14 +- pkg/gotenberg/mocks_test.go | 16 + pkg/gotenberg/pdfengine.go | 6 + pkg/modules/chromium/routes.go | 36 +- pkg/modules/chromium/routes_test.go | 38 +- pkg/modules/exiftool/doc.go | 8 + pkg/modules/exiftool/exiftool.go | 194 ++++++++ pkg/modules/exiftool/exiftool_test.go | 418 ++++++++++++++++++ .../libreoffice/pdfengine/pdfengine.go | 10 + .../libreoffice/pdfengine/pdfengine_test.go | 18 + pkg/modules/libreoffice/routes.go | 34 +- pkg/modules/libreoffice/routes_test.go | 309 +++++++++++++ pkg/modules/pdfcpu/pdfcpu.go | 10 + pkg/modules/pdfcpu/pdfcpu_test.go | 18 + pkg/modules/pdfengines/multi.go | 46 ++ pkg/modules/pdfengines/multi_test.go | 166 +++++++ pkg/modules/pdftk/pdftk.go | 10 + pkg/modules/pdftk/pdftk_test.go | 18 + pkg/modules/qpdf/qpdf.go | 10 + pkg/modules/qpdf/qpdf_test.go | 18 + pkg/standard/imports.go | 1 + 24 files changed, 1433 insertions(+), 39 deletions(-) create mode 100644 pkg/modules/exiftool/doc.go create mode 100644 pkg/modules/exiftool/exiftool.go create mode 100644 pkg/modules/exiftool/exiftool_test.go diff --git a/build/Dockerfile b/build/Dockerfile index 45d6712c..7c75cb4d 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -171,12 +171,13 @@ RUN \ echo '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \ chmod +x /usr/bin/pdftk &&\ apt-get update -qq &&\ - DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\ + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\ # See https://github.com/nextcloud/docker/issues/380. mkdir -p /usr/share/man/man1 &&\ # Verify installations. pdftk --version &&\ qpdf --version &&\ + exiftool --version &&\ # Cleanup. rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* @@ -195,6 +196,7 @@ ENV LIBREOFFICE_BIN_PATH /usr/lib/libreoffice/program/soffice.bin ENV UNOCONVERTER_BIN_PATH /usr/bin/unoconverter ENV PDFTK_BIN_PATH /usr/bin/pdftk ENV QPDF_BIN_PATH /usr/bin/qpdf +ENV EXIFTOOL_BIN_PATH /usr/bin/exiftool USER gotenberg WORKDIR /home/gotenberg diff --git a/go.mod b/go.mod index f551af80..873b5227 100644 --- a/go.mod +++ b/go.mod @@ -1,11 +1,12 @@ module github.com/gotenberg/gotenberg/v8 -go 1.22 +go 1.21 require ( github.com/alexliesenfeld/health v0.8.0 github.com/andybalholm/brotli v1.1.0 // indirect - github.com/chromedp/cdproto v0.0.0-20240312231614-1e5096e63154 + github.com/barasher/go-exiftool v1.10.0 + github.com/chromedp/cdproto v0.0.0-20240226204813-532e667d868f github.com/chromedp/chromedp v0.9.5 github.com/golang/snappy v0.0.4 // indirect github.com/google/uuid v1.6.0 @@ -19,7 +20,7 @@ require ( github.com/mholt/archiver/v3 v3.5.1 github.com/microcosm-cc/bluemonday v1.0.26 github.com/nwaples/rardecode v1.1.3 // indirect - github.com/pdfcpu/pdfcpu v0.7.0 + github.com/pdfcpu/pdfcpu v0.6.0 github.com/pierrec/lz4/v4 v4.1.21 // indirect github.com/prometheus/client_golang v1.19.0 github.com/russross/blackfriday/v2 v2.1.0 @@ -27,22 +28,26 @@ require ( github.com/ulikunitz/xz v0.5.11 // indirect go.uber.org/multierr v1.11.0 go.uber.org/zap v1.27.0 - golang.org/x/crypto v0.21.0 // indirect + golang.org/x/crypto v0.20.0 // indirect golang.org/x/image v0.15.0 // indirect - golang.org/x/net v0.22.0 + golang.org/x/net v0.21.0 golang.org/x/sync v0.6.0 - golang.org/x/sys v0.18.0 // indirect - golang.org/x/term v0.18.0 + golang.org/x/sys v0.17.0 // indirect + golang.org/x/term v0.17.0 golang.org/x/text v0.14.0 ) -require github.com/dlclark/regexp2 v1.11.0 +require ( + github.com/dlclark/regexp2 v1.11.0 + github.com/stretchr/testify v1.8.4 +) require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/cespare/xxhash/v2 v2.2.0 // indirect github.com/chromedp/sysutil v1.0.0 // indirect + github.com/davecgh/go-spew v1.1.1 // indirect github.com/dsnet/compress v0.0.2-0.20210315054119-f66993602bf5 // indirect github.com/gobwas/httphead v0.1.0 // indirect github.com/gobwas/pool v0.2.1 // indirect @@ -55,13 +60,15 @@ require ( github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-runewidth v0.0.15 // indirect github.com/pkg/errors v0.9.1 // indirect + github.com/pmezard/go-difflib v1.0.0 // indirect github.com/prometheus/client_model v0.6.0 // indirect - github.com/prometheus/common v0.50.0 // indirect - github.com/prometheus/procfs v0.13.0 // indirect + github.com/prometheus/common v0.49.0 // indirect + github.com/prometheus/procfs v0.12.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect github.com/valyala/bytebufferpool v1.0.0 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 // indirect - google.golang.org/protobuf v1.33.0 // indirect + google.golang.org/protobuf v1.32.0 // indirect gopkg.in/yaml.v2 v2.4.0 // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 98a55202..231b0dc5 100644 --- a/go.sum +++ b/go.sum @@ -5,17 +5,20 @@ github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1 github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/barasher/go-exiftool v1.10.0 h1:f5JY5jc42M7tzR6tbL9508S2IXdIcG9QyieEXNMpIhs= +github.com/barasher/go-exiftool v1.10.0/go.mod h1:F9s/a3uHSM8YniVfwF+sbQUtP8Gmh9nyzigNF+8vsWo= 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/cespare/xxhash/v2 v2.2.0 h1:DC2CZ1Ep5Y4k3ZQ899DldepgrayRUGE6BBZ/cd9Cj44= github.com/cespare/xxhash/v2 v2.2.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chromedp/cdproto v0.0.0-20240202021202-6d0b6a386732/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= -github.com/chromedp/cdproto v0.0.0-20240312231614-1e5096e63154 h1:jeAmkzyOAQBPRmZMhX+i/CJv0VViLkHk1nF0qx8s0Mk= -github.com/chromedp/cdproto v0.0.0-20240312231614-1e5096e63154/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= +github.com/chromedp/cdproto v0.0.0-20240226204813-532e667d868f h1:jODunjTDQHm0Srs2IsfcS3hOmNLUN7Spag3NJZQra2g= +github.com/chromedp/cdproto v0.0.0-20240226204813-532e667d868f/go.mod h1:GKljq0VrfU4D5yc+2qA6OVr8pmO/MBbPEWqWQ/oqGEs= github.com/chromedp/chromedp v0.9.5 h1:viASzruPJOiThk7c5bueOUY91jGLJVximoEMGoH93rg= github.com/chromedp/chromedp v0.9.5/go.mod h1:D4I2qONslauw/C7INoCir1BJkSwBYMyZgx8X276z3+Y= github.com/chromedp/sysutil v1.0.0 h1:+ZxhTpfpZlmchB58ih/LBHX52ky7w2VhQVKQMucy3Ic= github.com/chromedp/sysutil v1.0.0/go.mod h1:kgWmDdq8fTzXYcKIBqIYvRRTnYb9aNS9moAV0xufSww= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= @@ -87,8 +90,8 @@ github.com/nwaples/rardecode v1.1.3 h1:cWCaZwfM5H7nAD6PyEdcVnczzV8i/JtotnyW/dD9l github.com/nwaples/rardecode v1.1.3/go.mod h1:5DzqNKiOdpKKBH87u8VlvAnPZMXcGRhxWkRpHbbfGS0= 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/pdfcpu/pdfcpu v0.7.0 h1:cd7/z7hAyyDuzdciKfNZyQ3TYreJza2DsuPdIHYURcA= -github.com/pdfcpu/pdfcpu v0.7.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo= +github.com/pdfcpu/pdfcpu v0.6.0 h1:z4kARP5bcWa39TTYMcN/kjBnm7MvhTWjXgeYmkdAGMI= +github.com/pdfcpu/pdfcpu v0.6.0/go.mod h1:kmpD0rk8YnZj0l3qSeGBlAB+XszHUgNv//ORH/E7EYo= 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.21/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4= @@ -100,10 +103,10 @@ github.com/prometheus/client_golang v1.19.0 h1:ygXvpU1AoN1MhdzckN+PyD9QJOSD4x7km github.com/prometheus/client_golang v1.19.0/go.mod h1:ZRM9uEAypZakd+q/x7+gmsvXdURP+DABIEIjnmDdp+k= github.com/prometheus/client_model v0.6.0 h1:k1v3CzpSRUTrKMppY35TLwPvxHqBu0bYgxZzqGIgaos= github.com/prometheus/client_model v0.6.0/go.mod h1:NTQHnmxFpouOD0DpvP4XujX3CdOAGQPoaGhyTchlyt8= -github.com/prometheus/common v0.50.0 h1:YSZE6aa9+luNa2da6/Tik0q0A5AbR+U003TItK57CPQ= -github.com/prometheus/common v0.50.0/go.mod h1:wHFBCEVWVmHMUpg7pYcOm2QUR/ocQdYSJVQJKnHc3xQ= -github.com/prometheus/procfs v0.13.0 h1:GqzLlQyfsPbaEHaQkO7tbDlriv/4o5Hudv6OXHGKX7o= -github.com/prometheus/procfs v0.13.0/go.mod h1:cd4PFCR54QLnGKPaKGA6l+cfuNXtht43ZKY6tow0Y1g= +github.com/prometheus/common v0.49.0 h1:ToNTdK4zSnPVJmh698mGFkDor9wBI/iGaJy5dbH1EgI= +github.com/prometheus/common v0.49.0/go.mod h1:Kxm+EULxRbUkjGU6WFsQqo3ORzB4tyKvlWFOE9mB2sE= +github.com/prometheus/procfs v0.12.0 h1:jluTpSng7V9hY0O2R9DzzJHYb2xULk9VTR1V1R/k6Bo= +github.com/prometheus/procfs v0.12.0/go.mod h1:pcuDEFsWDnvcgNzo4EEweacyhjeA9Zk3cnaOZAZEfOo= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= @@ -113,9 +116,11 @@ github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/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.5.0 h1:1zr/of2m5FGMsad5YfcqgdqdWrIhu+EBEJRhR1U7z/c= github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= github.com/ulikunitz/xz v0.5.8/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= @@ -134,26 +139,26 @@ go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/zap v1.27.0 h1:aJMhYGrd5QSmlpLMr2MftRKl7t8J8PTZPA732ud/XR8= go.uber.org/zap v1.27.0/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= -golang.org/x/crypto v0.21.0 h1:X31++rzVUdKhX5sWmSOFZxx8UW/ldWx55cbf08iNAMA= -golang.org/x/crypto v0.21.0/go.mod h1:0BP7YvVV9gBbVKyeTG0Gyn+gZm94bibOW5BjDEYAOMs= +golang.org/x/crypto v0.20.0 h1:jmAMJJZXr5KiCw05dfYK9QnqaqKLYXijU23lsEdcQqg= +golang.org/x/crypto v0.20.0/go.mod h1:Xwo95rrVNIoSMx9wa1JroENMToLWn3RNVrTBpLHgZPQ= golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8= golang.org/x/image v0.15.0/go.mod h1:HUYqC05R2ZcZ3ejNQsIHQDQiwWM4JBqmm6MKANTp4LE= -golang.org/x/net v0.22.0 h1:9sGLhx7iRIHEiX0oAJ3MRZMUCElJgy7Br1nO+AMN3Tc= -golang.org/x/net v0.22.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.21.0 h1:AQyQV4dYCvJ7vGmJyKki9+PBdyvhkSd8EIx/qb0AYv4= +golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44= golang.org/x/sync v0.6.0 h1:5BMeUDZ7vkXGfEr1x9B4bRcTH4lpkTkpdh0T/J+qjbQ= golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.16.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/sys v0.18.0 h1:DBdB3niSjOA/O0blCZBqDefyWNYveAYMNF1Wum0DYQ4= -golang.org/x/sys v0.18.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= -golang.org/x/term v0.18.0 h1:FcHjZXDMxI8mM3nwhX9HlKop4C0YQvCVCdwYl2wOtE8= -golang.org/x/term v0.18.0/go.mod h1:ILwASektA3OnRv7amZ1xhE/KTR+u50pbXfZ03+6Nx58= +golang.org/x/sys v0.17.0 h1:25cE3gD+tdBA7lp7QfhuV+rJiE9YXTcS3VG1SqssI/Y= +golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.17.0 h1:mkTF7LCd6WGJNL3K1Ad7kwxNfYAW6a8a8QqtMblp/4U= +golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk= golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/protobuf v1.33.0 h1:uNO2rsAINq/JlFpSdYEKIZ0uKD/R9cpdv0T+yoGwGmI= -google.golang.org/protobuf v1.33.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= +google.golang.org/protobuf v1.32.0 h1:pPC6BG5ex8PDFnkbrGU3EixyhKcQ2aDuBS36lqK/C7I= +google.golang.org/protobuf v1.32.0/go.mod h1:c6P6GXX6sHbq/GpV6MGZEdwhWPcYBgnhAHhKbcUYpos= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index 7188e05f..380fab1a 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -35,8 +35,10 @@ func (mod *ValidatorMock) Validate() error { // PdfEngineMock is a mock for the [PdfEngine] interface. type PdfEngineMock struct { - MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error - ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error + MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, 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, metadata map[string]interface{}) error + WriteMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error } func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { @@ -47,6 +49,14 @@ func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, fo return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath) } +func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string, metadata map[string]interface{}) error { + return engine.ReadMetadataMock(ctx, logger, inputPath, metadata) +} + +func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + return engine.WriteMetadataMock(ctx, logger, inputPath, newMetadata) +} + // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface. type PdfEngineProviderMock struct { PdfEngineMock func() (PdfEngine, error) diff --git a/pkg/gotenberg/mocks_test.go b/pkg/gotenberg/mocks_test.go index 9eb52bed..fa67c3a1 100644 --- a/pkg/gotenberg/mocks_test.go +++ b/pkg/gotenberg/mocks_test.go @@ -55,6 +55,12 @@ func TestPDFEngineMock(t *testing.T) { ConvertMock: func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error { return nil }, + ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, metadata map[string]interface{}) error { + return nil + }, + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + return nil + }, } err := mock.Merge(context.Background(), zap.NewNop(), nil, "") @@ -66,6 +72,16 @@ func TestPDFEngineMock(t *testing.T) { if err != nil { t.Errorf("expected no error from PdfEngineMock.Convert, but got: %v", err) } + + err = mock.ReadMetadataMock(context.Background(), zap.NewNop(), "", map[string]interface{}{}) + if err != nil { + t.Errorf("expected no error from PdfEngineMock.ReadMetadata, but got: %v", err) + } + + err = mock.WriteMetadataMock(context.Background(), zap.NewNop(), "", map[string]interface{}{}) + if err != nil { + t.Errorf("expected no error from PdfEngineMock.WriteMetadata but got: %v", err) + } } func TestPDFEngineProviderMock(t *testing.T) { diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index 9775ce35..f70d7d2d 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -64,6 +64,12 @@ type PdfEngine interface { // Convert transforms a given PDF to the specified formats defined in // PdfFormats. If no format, it does nothing. Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error + + // ReadMetadata extracts the metadata of a given PDF file and load them into the provided metadata object. + ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string, metadata map[string]interface{}) error + + // WriteMetadata writes the metadata into a given PDF file. + WriteMetadata(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error } // PdfEngineProvider offers an interface to instantiate a [PdfEngine]. diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index e9f0f65b..39ee13bb 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -233,6 +233,23 @@ func FormDataChromiumPdfFormats(form *api.FormData) gotenberg.PdfFormats { } } +// FormDataMetadata creates metadata object from the form data. +func FormDataMetadata(form *api.FormData) map[string]interface{} { + var metadata map[string]interface{} + + form.Custom("metadata", func(value string) error { + metadata = map[string]interface{}{} + if len(value) > 0 { + err := json.Unmarshal([]byte(value), &metadata) + if err != nil { + return err + } + } + return nil + }) + return metadata +} + // convertUrlRoute returns an [api.Route] which can convert a URL to PDF. func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return api.Route{ @@ -243,6 +260,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) pdfFormats := FormDataChromiumPdfFormats(form) + metadata := FormDataMetadata(form) var url string err := form. @@ -252,7 +270,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - err = convertUrl(ctx, chromium, engine, url, pdfFormats, options) + err = convertUrl(ctx, chromium, engine, url, pdfFormats, options, metadata) if err != nil { return fmt.Errorf("convert URL to PDF: %w", err) } @@ -302,6 +320,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) pdfFormats := FormDataChromiumPdfFormats(form) + metadata := FormDataMetadata(form) var inputPath string err := form. @@ -312,7 +331,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } url := fmt.Sprintf("file://%s", inputPath) - err = convertUrl(ctx, chromium, engine, url, pdfFormats, options) + err = convertUrl(ctx, chromium, engine, url, pdfFormats, options, metadata) if err != nil { return fmt.Errorf("convert HTML to PDF: %w", err) } @@ -363,6 +382,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { ctx := c.Get("context").(*api.Context) form, options := FormDataChromiumPdfOptions(ctx) pdfFormats := FormDataChromiumPdfFormats(form) + metadata := FormDataMetadata(form) var ( inputPath string @@ -382,7 +402,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("transform markdown file(s) to HTML: %w", err) } - err = convertUrl(ctx, chromium, engine, url, pdfFormats, options) + err = convertUrl(ctx, chromium, engine, url, pdfFormats, options, metadata) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) } @@ -506,7 +526,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) return fmt.Sprintf("file://%s", inputPath), nil } -func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options PdfOptions) error { +func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, pdfFormats gotenberg.PdfFormats, options PdfOptions, metadata map[string]interface{}) error { outputPath := ctx.GeneratePath(".pdf") err := chromium.Pdf(ctx, ctx.Log(), url, outputPath, options) @@ -562,6 +582,14 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url outputPath = convertOutputPath } + // Writes and potentially overrides metadata entries, if any. + if len(metadata) > 0 { + err = engine.WriteMetadata(ctx, ctx.Log(), outputPath, metadata) + if err != nil { + return fmt.Errorf("write metadata: %w", err) + } + } + err = ctx.AddOutputPaths(outputPath) if err != nil { return fmt.Errorf("add output path: %w", err) diff --git a/pkg/modules/chromium/routes_test.go b/pkg/modules/chromium/routes_test.go index 3ae9cf78..b67bccd4 100644 --- a/pkg/modules/chromium/routes_test.go +++ b/pkg/modules/chromium/routes_test.go @@ -1225,6 +1225,7 @@ func TestConvertUrl(t *testing.T) { engine gotenberg.PdfEngine pdfFormats gotenberg.PdfFormats options PdfOptions + metadata map[string]interface{} expectError bool expectHttpError bool expectHttpStatus int @@ -1385,10 +1386,45 @@ func TestConvertUrl(t *testing.T) { expectHttpError: false, expectOutputPathsCount: 1, }, + { + scenario: "error with metadata write", + 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{WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + return fmt.Errorf("error writing metadata to %s: %w", "foo.pdf", errors.New("foo")) + }}, + options: DefaultPdfOptions(), + metadata: map[string]interface{}{ + "Creator": "foo", + "Producer": "bar", + }, + expectError: true, + expectHttpError: false, + }, + { + scenario: "success with metadata write", + 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{WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + return nil + }}, + options: DefaultPdfOptions(), + metadata: map[string]interface{}{ + "Creator": "foo", + "Producer": "bar", + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) - err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.pdfFormats, tc.options) + err := convertUrl(tc.ctx.Context, tc.api, tc.engine, "", tc.pdfFormats, tc.options, tc.metadata) if tc.expectError && err == nil { t.Fatal("expected error but got none", err) diff --git a/pkg/modules/exiftool/doc.go b/pkg/modules/exiftool/doc.go new file mode 100644 index 00000000..7d9e750d --- /dev/null +++ b/pkg/modules/exiftool/doc.go @@ -0,0 +1,8 @@ +// Package exiftool provides an implementation of the gotenberg.PdfEngine +// interface using the ExifTool command-line tool. This package allows for reading and +// writing of metadata, but does not support the merging of PDF files +// nor conversion to specific PDF formats. The path to the exiftool binary must be +// specified using the EXIFTOOL_BIN_PATH environment variable. +// +// See: https://exiftool.org. +package exiftool diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go new file mode 100644 index 00000000..6958f659 --- /dev/null +++ b/pkg/modules/exiftool/exiftool.go @@ -0,0 +1,194 @@ +package exiftool + +import ( + "context" + "errors" + "fmt" + "net/http" + "os" + + "github.com/barasher/go-exiftool" + "go.uber.org/zap" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" + "github.com/gotenberg/gotenberg/v8/pkg/modules/api" +) + +func init() { + gotenberg.MustRegisterModule(new(ExifTool)) +} + +// MetadataValueTypeError is constructed when metadata value types cannot be processed. +// The underlying library used in this implementation supports the writing of a limited +// number of metadata value types. +// +// For example, according to https://exiftool.org/TagNames/PDF.html metadata can be a boolean, +// i.e. "value format... may be string, date, integer, real, boolean or name". +// Furthermore, a native boolean type is also supported by JSON and Go. However, the +// underlying library does not currently support writing native Go boolean (bool) types. +// Therefore, an instance of this struct is created when a boolean metadata entry is supplied. +// +// The struct contains a key/value map corresponding to individual invalid metadata entries supplied by a consumer. +// This allows a helpful error message to be produced for API consumers. +// See API.WriteMetadata for more information on valid metadata value types. +type MetadataValueTypeError struct { + Entries map[string]interface{} +} + +// Error returns a helpful error message. +func (e *MetadataValueTypeError) Error() string { + return fmt.Sprintf("invalid metadata value types supplied - identified by Entries: %s", e.Entries) +} + +// GetKeys returns an array of keys with corresponding invalid value types, +func (e *MetadataValueTypeError) GetKeys() []string { + keys := make([]string, len(e.Entries)) + i := 0 + for key := range e.Entries { + keys[i] = key + i++ + } + return keys +} + +// ExifTool abstracts the CLI tool ExifTool and implements the [gotenberg.PdfEngine] interface . +type ExifTool struct { + binPath string +} + +// Descriptor returns ExifTool's module descriptor. +func (engine *ExifTool) Descriptor() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ + ID: "exiftool", + New: func() gotenberg.Module { return new(ExifTool) }, + } +} + +// Provision sets the module properties. It returns an error if +// - the environment variable EXIFTOOL_BIN_PATH is not set +// - there is an error creating an instance of exiftool.ExifTool +func (engine *ExifTool) Provision(ctx *gotenberg.Context) error { + binPath, ok := os.LookupEnv("EXIFTOOL_BIN_PATH") + if !ok { + return errors.New("EXIFTOOL_BIN_PATH environment variable is not set") + } + + engine.binPath = binPath + + return nil +} + +// Validate validates the module properties. +func (engine *ExifTool) Validate() error { + _, err := os.Stat(engine.binPath) + if os.IsNotExist(err) { + return fmt.Errorf("ExifTool binary path does not exist: %w", err) + } + + return nil +} + +// Merge is not available in this implementation. +func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// Convert is not available in this implementation. +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 PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) +} + +// ReadMetadata reads the metadata of the given PDF files. +func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string, metadata map[string]interface{}) error { + logger.Debug(fmt.Sprintf("reading metadata of file: %s", inputPath)) + + exifTool, err := exiftool.NewExiftool() + if err != nil { + fmt.Printf("Error intializing ExifTool: %v\n", err) + return err + } + + fileMetadataInfos := exifTool.ExtractMetadata([]string{inputPath}...) + + if fileMetadataInfos[0].Err != nil { + return fmt.Errorf("error reading metadata to following file: %+v", fileMetadataInfos[0]) + } + + // load into metadata + for k, v := range fileMetadataInfos[0].Fields { + metadata[k] = v + } + + return exifTool.Close() +} + +// WriteMetadata write the metadata to the given PDF files. +func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + logger.Debug(fmt.Sprintf("writing new metadata %s to %s", newMetadata, inputPath)) + + exifTool, err := exiftool.NewExiftool() + if err != nil { + fmt.Printf("Error intializing ExifTool: %v\n", err) + return err + } + + fileMetadataInfos := exifTool.ExtractMetadata([]string{inputPath}...) + + // there is only file metadata info + if fileMetadataInfos[0].Err != nil { + return fmt.Errorf("error reading metadata to following file: %+v", fileMetadataInfos[0]) + } + + // Metadata values can only be specific value types. + // An error is returned if metadata with an invalid type is requested. + metadataValueErrors := MetadataValueTypeError{ + Entries: make(map[string]interface{}), + } + + // transform metadata + for key, value := range newMetadata { + switch val := value.(type) { + case string: + fileMetadataInfos[0].SetString(key, val) + case int: + fileMetadataInfos[0].SetInt(key, int64(val)) + case int64: + fileMetadataInfos[0].SetInt(key, val) + case float32: + fileMetadataInfos[0].SetFloat(key, float64(val)) + case float64: + fileMetadataInfos[0].SetFloat(key, val) + case []string: + fileMetadataInfos[0].SetStrings(key, val) + // TODO: support more complex cases, e.g. arrays and nested objects (limitations in underlying library) + default: + metadataValueErrors.Entries[key] = value + } + } + logger.Debug(fmt.Sprintf("writing metadata %s to %s", fileMetadataInfos[0].Fields, fileMetadataInfos[0].File)) + + if len(metadataValueErrors.Entries) > 0 { + return api.WrapError( + fmt.Errorf("write metadata: %w", err), + api.NewSentinelHttpError( + http.StatusBadRequest, + fmt.Sprintf("Invalid metdata value types supplied by keys '%s'", metadataValueErrors.GetKeys())), + ) + } + + exifTool.WriteMetadata(fileMetadataInfos) + + if fileMetadataInfos[0].Err != nil { + return fmt.Errorf("error writing metadata to following file: %+v", fileMetadataInfos[0]) + } + + return exifTool.Close() +} + +// Interface guards. +var ( + _ gotenberg.Module = (*ExifTool)(nil) + _ gotenberg.Provisioner = (*ExifTool)(nil) + _ gotenberg.Validator = (*ExifTool)(nil) + _ gotenberg.PdfEngine = (*ExifTool)(nil) +) diff --git a/pkg/modules/exiftool/exiftool_test.go b/pkg/modules/exiftool/exiftool_test.go new file mode 100644 index 00000000..eefa3b63 --- /dev/null +++ b/pkg/modules/exiftool/exiftool_test.go @@ -0,0 +1,418 @@ +package exiftool + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "reflect" + "testing" + + "github.com/stretchr/testify/assert" + "go.uber.org/zap" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" +) + +func TestMetadataValueTypeError_Error(t *testing.T) { + instance := MetadataValueTypeError{ + Entries: map[string]interface{}{ + "foo": "foo", + }, + } + + assert.True(t, len(instance.Error()) > 0) +} + +func TestMetadataValueTypeError_GetKeys(t *testing.T) { + for i, tc := range []struct { + instance MetadataValueTypeError + expect []string + }{ + { + instance: MetadataValueTypeError{ + Entries: map[string]interface{}{}, + }, + expect: []string{}, + }, + { + instance: MetadataValueTypeError{ + Entries: map[string]interface{}{ + "foo": "foo", + }, + }, + expect: []string{"foo"}, + }, + { + instance: MetadataValueTypeError{ + Entries: map[string]interface{}{ + "foo": "foo", + "bar": float64(123), + "baz": 4.56, + "qux": true, + "quux": nil, + }, + }, + expect: []string{"foo", "bar", "baz", "qux", "quux"}, + }, + } { + actual := tc.instance.GetKeys() + + if !assert.ElementsMatch(t, actual, tc.expect) { + t.Errorf("test %d: expected %+v but got: %+v", i, tc.expect, actual) + } + } +} + +func TestExifTool_Descriptor(t *testing.T) { + descriptor := new(ExifTool).Descriptor() + + actual := reflect.TypeOf(descriptor.New()) + expect := reflect.TypeOf(new(ExifTool)) + + if actual != expect { + t.Errorf("expected '%s' but got '%s'", expect, actual) + } +} + +func TestExifTool_Provision(t *testing.T) { + engine := new(ExifTool) + ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil) + + err := engine.Provision(ctx) + if err != nil { + t.Errorf("expected no error but got: %v", err) + } +} + +func TestExifTool_Validate(t *testing.T) { + for _, tc := range []struct { + scenario string + binPath string + expectError bool + }{ + { + scenario: "empty bin path", + binPath: "", + expectError: true, + }, + { + scenario: "bin path does not exist", + binPath: "/foo", + expectError: true, + }, + { + scenario: "validate success", + binPath: os.Getenv("EXIFTOOL_BIN_PATH"), + expectError: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + engine := new(ExifTool) + engine.binPath = tc.binPath + err := engine.Validate() + + 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 TestExiftool_Merge(t *testing.T) { + engine := new(ExifTool) + err := engine.Merge(context.Background(), zap.NewNop(), nil, "") + + if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) + } +} + +func TestExiftool_Convert(t *testing.T) { + engine := new(ExifTool) + err := engine.Convert(context.Background(), zap.NewNop(), gotenberg.PdfFormats{}, "", "") + + if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) + } +} + +func TestExiftool_ReadMetadata(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx context.Context + inputPath string + subset map[string]interface{} + expectError bool + expectDiff bool + }{ + { + scenario: "invalid input path", + ctx: context.TODO(), + inputPath: "foo", + expectError: true, + }, + { + scenario: "single file success", + ctx: context.TODO(), + inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", + subset: map[string]interface{}{ + "FileName": "sample1.pdf", + "FileTypeExtension": "pdf", + "MIMEType": "application/pdf", + "PDFVersion": 1.4, + "PageCount": float64(3), + "CreateDate": "2018:12:06 17:50:06+00:00", + "ModifyDate": "2018:12:06 17:50:06+00:00", + "Directory": "/tests/test/testdata/pdfengines", + "FileType": "PDF", + "Linearized": "No", + "Creator": "Chromium", + "Producer": "Skia/PDF m70", + "SourceFile": "/tests/test/testdata/pdfengines/sample1.pdf", + }, + }, + { + scenario: "single file incorrect metadata", + ctx: context.TODO(), + inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", + subset: map[string]interface{}{ + "FileName": "sample1.pdf", + "FileTypeExtension": "pdf", + "MIMEType": "application/pdf", + "PDFVersion": 1.4, + "PageCount": float64(3), + "CreateDate": "2018:12:06 17:50:06+00:00", + "ModifyDate": "2018:12:06 17:50:06+00:00", + "Directory": "/tests/test/testdata/pdfengines", + "FileType": "PDF", + "Linearized": "No", + "Creator": "INVALID", + "Producer": "Skia/PDF m70", + "SourceFile": "/tests/test/testdata/pdfengines/sample1.pdf", + }, + expectDiff: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + engine := new(ExifTool) + err := engine.Provision(nil) + if err != nil { + t.Fatalf("expected error but got: %v", err) + } + + actualMetadata := map[string]interface{}{} + err = engine.ReadMetadata(tc.ctx, zap.NewNop(), tc.inputPath, actualMetadata) + 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.subset != nil && err == nil { + if !tc.expectDiff && !isMapSubset(actualMetadata, tc.subset) { + t.Errorf("test: %s: expected: %+v to be a subset of: %+v at path: %s", + tc.scenario, tc.subset, actualMetadata, tc.inputPath) + } else if tc.expectDiff && isMapSubset(actualMetadata, tc.subset) { + t.Errorf("test: %s: expected: %+v to be not be a subset of: %+v at path: %s", + tc.scenario, tc.subset, actualMetadata, tc.inputPath) + } + } + }) + } +} + +func TestExiftool_WriteMetadata(t *testing.T) { + for _, tc := range []struct { + scenario string + ctx context.Context + inputPath string + newMetadata map[string]interface{} + contains map[string]interface{} + expectError bool + expectDiff bool + }{ + { + scenario: "single file success", + ctx: context.TODO(), + inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", + newMetadata: map[string]interface{}{ + "Producer": "foo", + }, + contains: map[string]interface{}{ + "Producer": "foo", + }, + expectError: false, + expectDiff: false, + }, + { + scenario: "single file not same metadata", + ctx: context.TODO(), + inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", + newMetadata: map[string]interface{}{ + "Producer": "foo", + }, + contains: map[string]interface{}{ + "Producer": "foobar", + }, + expectError: false, + expectDiff: true, + }, + { + scenario: "single file unknown type", + ctx: context.TODO(), + inputPath: "/tests/test/testdata/pdfengines/sample1.pdf", + newMetadata: map[string]interface{}{ + "foo": map[string]string{}, + }, + expectError: true, + expectDiff: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + engine := new(ExifTool) + err := engine.Provision(nil) + if err != nil { + t.Fatalf("expected error but got: %v", err) + } + + fs := gotenberg.NewFileSystem() + 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) + } + }() + + copyPath := fmt.Sprintf("%s/copy_temp.pdf", outputDir) + // open the source file + source, err := os.Open(tc.inputPath) + if err != nil { + t.Fatalf("error in opening file: %v", err) + } + + // create the destination file + destination, err := os.Create(copyPath) + if err != nil { + t.Fatalf("error in creating file: %v", err) + } + + // copy the contents of source to destination file + _, err = io.Copy(destination, source) + if err != nil { + t.Fatalf("error in copying file: %v", err) + } + + err = source.Close() + if err != nil { + t.Fatalf("error in source file close: %v", err) + } + err = destination.Close() + if err != nil { + t.Fatalf("error in destination file close: %v", err) + } + + // write metadata to new copy files + err = engine.WriteMetadata(tc.ctx, zap.NewNop(), copyPath, tc.newMetadata) + 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 err == nil { + readMetadata := map[string]interface{}{} + readErr := engine.ReadMetadata(tc.ctx, zap.NewNop(), copyPath, readMetadata) + if tc.contains != nil && readErr == nil { + // match metadata + if !tc.expectDiff && !isMapSubset(readMetadata, tc.contains) { + t.Errorf("test: %s: expected: %+v to be a subset of: %+v at path: %s", + tc.scenario, tc.contains, readMetadata, copyPath) + } else if tc.expectDiff && isMapSubset(readMetadata, tc.contains) { + t.Errorf("test: %s: expected: %+v to be not be a subset of: %+v at path: %s", + tc.scenario, tc.contains, readMetadata, copyPath) + } + } + } + }) + } +} + +func isMapSubset(mapSet interface{}, mapSubset interface{}) bool { + mapSetValue := reflect.ValueOf(mapSet) + mapSubsetValue := reflect.ValueOf(mapSubset) + + if mapSetValue.Kind() != reflect.Map || mapSubsetValue.Kind() != reflect.Map { + return false + } + if reflect.TypeOf(mapSetValue) != reflect.TypeOf(mapSubsetValue) { + return false + } + if len(mapSubsetValue.MapKeys()) == 0 { + return true + } + + iterMapSubset := mapSubsetValue.MapRange() + + for iterMapSubset.Next() { + k := iterMapSubset.Key() + v := iterMapSubset.Value() + + v2 := mapSetValue.MapIndex(k) + + if !v2.IsValid() { + return false + } + if isValueKind(v, reflect.Slice) && isValueKind(v2, reflect.Slice) { + vSlice := convertSlice(v) + v2Slice := convertSlice(v2) + if !equal(vSlice, v2Slice) { + return false + } + } else if v.Interface() != v2.Interface() { + return false + } + } + + return true +} + +func isValueKind(value reflect.Value, kind reflect.Kind) bool { + return reflect.TypeOf(value.Interface()).Kind() == kind +} + +func convertSlice(value reflect.Value) []interface{} { + slice := make([]interface{}, reflect.ValueOf(value.Interface()).Len()) + for i := range slice { + slice = append(slice, reflect.ValueOf(value.Interface()).Index(i).Interface()) + } + return slice +} + +// equal tells whether a and b contain the same elements. +// A nil argument is equivalent to an empty slice. +func equal(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i, v := range a { + if v != b[i] { + return false + } + } + return true +} diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index dbc5e648..43776f4d 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -71,6 +71,16 @@ func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Log return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, err) } +// ReadMetadata is not available in this implementation. +func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPaths string, metadata map[string]interface{}) error { + return fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// WriteMetadata is not available in this implementation. +func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, inputPaths string, newMetadata map[string]interface{}) error { + return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*LibreOfficePdfEngine)(nil) diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go index 6772c796..1b0740da 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine_test.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine_test.go @@ -166,3 +166,21 @@ func TestLibreOfficePdfEngine_Convert(t *testing.T) { }) } } + +func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) { + engine := new(LibreOfficePdfEngine) + err := engine.ReadMetadata(context.Background(), zap.NewNop(), "", nil) + + if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) + } +} + +func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) { + engine := new(LibreOfficePdfEngine) + err := engine.WriteMetadata(context.Background(), zap.NewNop(), "", nil) + + if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) + } +} diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 3b730c8f..f16d0cdc 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -1,6 +1,7 @@ package libreoffice import ( + "encoding/json" "errors" "fmt" "net/http" @@ -27,22 +28,33 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap inputPaths []string landscape bool nativePageRanges string + exportFormFields bool pdfa string pdfua bool nativePdfFormats bool merge bool - exportFormFields bool + metadata map[string]interface{} ) err := ctx.FormData(). MandatoryPaths(libreOffice.Extensions(), &inputPaths). Bool("landscape", &landscape, false). String("nativePageRanges", &nativePageRanges, ""). + Bool("exportFormFields", &exportFormFields, true). String("pdfa", &pdfa, ""). Bool("pdfua", &pdfua, false). Bool("nativePdfFormats", &nativePdfFormats, true). Bool("merge", &merge, false). - Bool("exportFormFields", &exportFormFields, true). + Custom("metadata", func(value string) error { + metadata = map[string]interface{}{} + if len(value) > 0 { + err := json.Unmarshal([]byte(value), &metadata) + if err != nil { + return err + } + } + return nil + }). Validate() if err != nil { return fmt.Errorf("validate form data: %w", err) @@ -116,6 +128,14 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap outputPath = convertOutputPath } + // Writes and potentially overrides metadata entries, if any. + if len(metadata) > 0 { + err = engine.WriteMetadata(ctx, ctx.Log(), outputPath, metadata) + if err != nil { + return fmt.Errorf("write metadata failure: %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) @@ -160,6 +180,16 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap } } + // Writes and potentially overrides metadata entries, if any. + if len(metadata) > 0 { + for _, outputPath := range outputPaths { + err = engine.WriteMetadata(ctx, ctx.Log(), outputPath, metadata) + 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(outputPaths...) diff --git a/pkg/modules/libreoffice/routes_test.go b/pkg/modules/libreoffice/routes_test.go index 67ab078f..6b514780 100644 --- a/pkg/modules/libreoffice/routes_test.go +++ b/pkg/modules/libreoffice/routes_test.go @@ -546,6 +546,315 @@ func TestConvertRoute(t *testing.T) { expectHttpError: false, expectOutputPathsCount: 1, }, + { + scenario: "success with metadata (single file)", + 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{ + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + 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{ + 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, inputPath string, newMetadata map[string]interface{}) error { + return nil + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, + { + scenario: "success with metadata (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{ + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + 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{ + 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, inputPath string, newMetadata map[string]interface{}) error { + return nil + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 2, + }, + { + scenario: "error with metadata (single file)", + 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{ + "metadata": { + // invalid json + "{\"Creator\"", + }, + }) + 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"} + }, + }, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "error with metadata (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{ + "metadata": { + // invalid json + "{\"Creator\"", + }, + }) + 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"} + }, + }, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "error with metadata write failure (single file)", + 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{ + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + 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{ + 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, inputPath string, newMetadata map[string]interface{}) error { + return errors.New("foo") + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "error with metadata write failure (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{ + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + 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{ + 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, inputPath string, newMetadata map[string]interface{}) error { + return errors.New("foo") + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, + { + scenario: "success merge with metadata", + 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", + }, + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + 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 + }, + 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, inputPath string, newMetadata map[string]interface{}) error { + return nil + }, + }, + expectError: false, + expectHttpError: false, + expectOutputPathsCount: 1, + }, + { + scenario: "error merge with metadata", + 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", + }, + "metadata": { + // invalid json + "{\"Creator", + }, + }) + 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"} + }, + }, + expectError: true, + expectHttpError: true, + expectHttpStatus: http.StatusBadRequest, + expectOutputPathsCount: 0, + }, + { + scenario: "error merge with metadata write failure", + 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", + }, + "metadata": { + "{\"Creator\": \"foo\", \"Producer\": \"bar\" }", + }, + }) + 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 + }, + 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, inputPath string, newMetadata map[string]interface{}) error { + return errors.New("foo") + }, + }, + expectError: true, + expectHttpError: false, + expectOutputPathsCount: 0, + }, } { t.Run(tc.scenario, func(t *testing.T) { tc.ctx.SetLogger(zap.NewNop()) diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index b16b5d02..1e2bf1a7 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -54,6 +54,16 @@ func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats g return fmt.Errorf("convert PDF to '%+v' with PDFcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) } +// ReadMetadata is not available in this implementation. +func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPaths string, metadata map[string]interface{}) error { + return fmt.Errorf("read PDF metadata with PDFcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// WriteMetadata is not available in this implementation. +func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, inputPaths string, newMetadata map[string]interface{}) error { + return fmt.Errorf("write PDF metadata with PDFcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*PdfCpu)(nil) diff --git a/pkg/modules/pdfcpu/pdfcpu_test.go b/pkg/modules/pdfcpu/pdfcpu_test.go index b0da23f8..a189521c 100644 --- a/pkg/modules/pdfcpu/pdfcpu_test.go +++ b/pkg/modules/pdfcpu/pdfcpu_test.go @@ -102,3 +102,21 @@ func TestPdfCpu_Convert(t *testing.T) { t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) } } + +func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) { + engine := new(PdfCpu) + err := engine.ReadMetadata(context.Background(), zap.NewNop(), "", nil) + + if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) + } +} + +func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) { + engine := new(PdfCpu) + err := engine.WriteMetadata(context.Background(), zap.NewNop(), "", nil) + + if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) + } +} diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index b25a7ac4..81c9b67a 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -70,6 +70,52 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err) } +func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPaths string, metadata map[string]interface{}) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.engines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.ReadMetadata(ctx, logger, inputPaths, metadata) + }(engine) + + select { + case readMetadataErr := <-errChan: + errored := multierr.AppendInto(&err, readMetadataErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return fmt.Errorf("read PDF metadata with multi PDF engines: %w", err) +} + +func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, inputPaths string, newMetadata map[string]interface{}) error { + var err error + errChan := make(chan error, 1) + + for _, engine := range multi.engines { + go func(engine gotenberg.PdfEngine) { + errChan <- engine.WriteMetadata(ctx, logger, inputPaths, newMetadata) + }(engine) + + select { + case writeMetadataErr := <-errChan: + errored := multierr.AppendInto(&err, writeMetadataErr) + if !errored { + return nil + } + case <-ctx.Done(): + return ctx.Err() + } + } + + return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) +} + // Interface guards. var ( _ gotenberg.PdfEngine = (*multiPdfEngines)(nil) diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go index 05eb6fd5..038bb5ea 100644 --- a/pkg/modules/pdfengines/multi_test.go +++ b/pkg/modules/pdfengines/multi_test.go @@ -177,3 +177,169 @@ func TestMultiPdfEngines_Convert(t *testing.T) { }) } } + +func TestMultiPdfEngines_ReadMetadata(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: newMultiPdfEngines( + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, metadata map[string]interface{}) error { + return nil + }, + }, + ), + ctx: context.Background(), + }, + { + scenario: "at least one engine does not return an error", + engine: newMultiPdfEngines( + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, metadata map[string]interface{}) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, metadata map[string]interface{}) error { + return nil + }, + }, + ), + ctx: context.Background(), + }, + { + scenario: "all engines return an error", + engine: newMultiPdfEngines( + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, metadata map[string]interface{}) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, metadata map[string]interface{}) error { + return errors.New("foo") + }, + }, + ), + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: newMultiPdfEngines( + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, metadata map[string]interface{}) error { + return nil + }, + }, + ), + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + err := tc.engine.ReadMetadata(tc.ctx, zap.NewNop(), "", nil) + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestMultiPdfEngines_WriteMetadata(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: newMultiPdfEngines( + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + return nil + }, + }, + ), + ctx: context.Background(), + }, + { + scenario: "at least one engine does not return an error", + engine: newMultiPdfEngines( + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + return nil + }, + }, + ), + ctx: context.Background(), + }, + { + scenario: "all engines return an error", + engine: newMultiPdfEngines( + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + return errors.New("foo") + }, + }, + ), + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: newMultiPdfEngines( + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, inputPath string, newMetadata map[string]interface{}) error { + return nil + }, + }, + ), + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + err := tc.engine.WriteMetadata(tc.ctx, zap.NewNop(), "", nil) + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 6fcf5fa9..a9aaf0db 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -75,6 +75,16 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats go return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) } +// ReadMetadata is not available in this implementation. +func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPaths string, metadata map[string]interface{}) error { + return fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// WriteMetadata is not available in this implementation. +func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, inputPaths string, newMetadata map[string]interface{}) error { + return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + // Interface guards. var ( _ gotenberg.Module = (*PdfTk)(nil) diff --git a/pkg/modules/pdftk/pdftk_test.go b/pkg/modules/pdftk/pdftk_test.go index 06648b5c..9d3b86e0 100644 --- a/pkg/modules/pdftk/pdftk_test.go +++ b/pkg/modules/pdftk/pdftk_test.go @@ -148,3 +148,21 @@ func TestPdfTk_Convert(t *testing.T) { t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) } } + +func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) { + engine := new(PdfTk) + err := engine.ReadMetadata(context.Background(), zap.NewNop(), "", nil) + + if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) + } +} + +func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) { + engine := new(PdfTk) + err := engine.WriteMetadata(context.Background(), zap.NewNop(), "", nil) + + if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) + } +} diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index d256e25e..c65af8d9 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -77,6 +77,16 @@ func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats got return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) } +// ReadMetadata is not available in this implementation. +func (engine *QPdf) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPaths string, metadata map[string]interface{}) error { + return fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + +// WriteMetadata is not available in this implementation. +func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, inputPaths string, newMetadata map[string]interface{}) error { + return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) +} + var ( _ gotenberg.Module = (*QPdf)(nil) _ gotenberg.Provisioner = (*QPdf)(nil) diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go index c030fe24..ef0fe174 100644 --- a/pkg/modules/qpdf/qpdf_test.go +++ b/pkg/modules/qpdf/qpdf_test.go @@ -148,3 +148,21 @@ func TestQPdf_Convert(t *testing.T) { t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) } } + +func TestLibreOfficePdfEngine_ReadMetadata(t *testing.T) { + engine := new(QPdf) + err := engine.ReadMetadata(context.Background(), zap.NewNop(), "", nil) + + if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) + } +} + +func TestLibreOfficePdfEngine_WriteMetadata(t *testing.T) { + engine := new(QPdf) + err := engine.WriteMetadata(context.Background(), zap.NewNop(), "", nil) + + if !errors.Is(err, gotenberg.ErrPdfEngineMethodNotSupported) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPdfEngineMethodNotSupported, err) + } +} diff --git a/pkg/standard/imports.go b/pkg/standard/imports.go index 436f4bab..de45e9d8 100644 --- a/pkg/standard/imports.go +++ b/pkg/standard/imports.go @@ -4,6 +4,7 @@ import ( // Standard Gotenberg modules. _ "github.com/gotenberg/gotenberg/v8/pkg/modules/api" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/chromium" + _ "github.com/gotenberg/gotenberg/v8/pkg/modules/exiftool" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/pdfengine"