diff --git a/build/Dockerfile b/build/Dockerfile index f4dbd190..9ae2dfbd 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -131,6 +131,8 @@ RUN \ # See https://github.com/gotenberg/gotenberg/pull/273. curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/pdftk-java/pdftk/-/jobs/$PDFTK_VERSION/artifacts/raw/build/libs/pdftk-all.jar" &&\ chmod a+x /usr/bin/pdftk-all.jar &&\ + # Download QPDF. + DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf &&\ # See https://github.com/nextcloud/docker/issues/380. mkdir -p /usr/share/man/man1mkdir -p /usr/share/man/man1 &&\ # Cleanup. @@ -142,7 +144,8 @@ RUN \ chromium --version &&\ libreoffice --version &&\ unoconv --version &&\ - pdftk --version + pdftk --version &&\ + qpdf --version # Copy the Gotenberg binary from the builder stage. COPY --from=builder /home/gotenberg /usr/bin/ @@ -152,6 +155,7 @@ ENV GC_EXCLUDE_SUBSTR "hsperfdata_root,hsperfdata_gotenberg" ENV CHROMIUM_BIN_PATH /usr/bin/chromium ENV UNOCONV_BIN_PATH /usr/bin/unoconv ENV PDFTK_BIN_PATH /usr/bin/pdftk +ENV QPDF_BIN_PATH /usr/bin/qpdf USER gotenberg WORKDIR /home/gotenberg diff --git a/pkg/modules/qpdf/doc.go b/pkg/modules/qpdf/doc.go new file mode 100644 index 00000000..c0c59c3e --- /dev/null +++ b/pkg/modules/qpdf/doc.go @@ -0,0 +1,3 @@ +// Package qpdf provides a module which abstracts the CLI tool QPDF and +// implements the gotenberg.PDFEngine interface. +package qpdf diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go new file mode 100644 index 00000000..d735a94d --- /dev/null +++ b/pkg/modules/qpdf/qpdf.go @@ -0,0 +1,117 @@ +package qpdf + +import ( + "context" + "errors" + "fmt" + "os" + "sync" + + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" + "go.uber.org/zap" +) + +func init() { + gotenberg.MustRegisterModule(QPDF{}) +} + +// QPDF abstracts the CLI tool QPDF and implements the gotenberg.QPDF +// interface. +type QPDF struct { + binPath string +} + +// Descriptor returns a QPDF's module descriptor. +func (QPDF) Descriptor() gotenberg.ModuleDescriptor { + return gotenberg.ModuleDescriptor{ + ID: "qpdf", + New: func() gotenberg.Module { return new(QPDF) }, + } +} + +// Provision sets the modules properties. It returns an error if the +// environment variable QPDF_BIN_PATH is not set. +func (engine *QPDF) Provision(_ *gotenberg.Context) error { + binPath, ok := os.LookupEnv("QPDF_BIN_PATH") + if !ok { + return errors.New("QPDF_BIN_PATH environment variable is not set") + } + + engine.binPath = binPath + + return nil +} + +// Validate validates the module properties. +func (engine QPDF) Validate() error { + _, err := os.Stat(engine.binPath) + if os.IsNotExist(err) { + return fmt.Errorf("QPDF binary path does not exist: %w", err) + } + + return nil +} + +// Metrics returns the metrics. +func (engine QPDF) Metrics() ([]gotenberg.Metric, error) { + return []gotenberg.Metric{ + { + Name: "qpdf_active_instances_count", + Description: "Current number of active QPDF instances.", + Read: func() float64 { + activeInstancesCountMu.RLock() + defer activeInstancesCountMu.RUnlock() + + return activeInstancesCount + }, + }, + }, nil +} + +// Merge merges the given PDFs into a unique PDF. +func (engine QPDF) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { + var args []string + args = append(args, "--empty") + args = append(args, "--pages") + args = append(args, inputPaths...) + args = append(args, "--", outputPath) + + cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) + if err != nil { + return fmt.Errorf("create command: %w", err) + } + + activeInstancesCountMu.Lock() + activeInstancesCount += 1 + activeInstancesCountMu.Unlock() + + err = cmd.Exec() + + activeInstancesCountMu.Lock() + activeInstancesCount -= 1 + activeInstancesCountMu.Unlock() + + if err == nil { + return nil + } + + return fmt.Errorf("merge PDFs with QPDF: %w", err) +} + +// Convert is not available for this PDF engine. +func (engine QPDF) Convert(_ context.Context, _ *zap.Logger, format, _, _ string) error { + return fmt.Errorf("convert PDF to '%s' with QPDF: %w", format, gotenberg.ErrPDFEngineMethodNotAvailable) +} + +var ( + activeInstancesCount float64 + activeInstancesCountMu sync.RWMutex +) + +var ( + _ gotenberg.Module = (*QPDF)(nil) + _ gotenberg.Provisioner = (*QPDF)(nil) + _ gotenberg.Validator = (*QPDF)(nil) + _ gotenberg.MetricsProvider = (*QPDF)(nil) + _ gotenberg.PDFEngine = (*QPDF)(nil) +) diff --git a/pkg/modules/qpdf/qpdf_test.go b/pkg/modules/qpdf/qpdf_test.go new file mode 100644 index 00000000..2238b7b0 --- /dev/null +++ b/pkg/modules/qpdf/qpdf_test.go @@ -0,0 +1,152 @@ +package qpdf + +import ( + "context" + "errors" + "os" + "reflect" + "testing" + + "github.com/gotenberg/gotenberg/v7/pkg/gotenberg" + "go.uber.org/zap" +) + +func TestQPDF_Descriptor(t *testing.T) { + descriptor := QPDF{}.Descriptor() + + actual := reflect.TypeOf(descriptor.New()) + expect := reflect.TypeOf(new(QPDF)) + + if actual != expect { + t.Errorf("expected '%s' but got '%s'", expect, actual) + } +} + +func TestQPDF_Provision(t *testing.T) { + mod := new(QPDF) + ctx := gotenberg.NewContext(gotenberg.ParsedFlags{}, nil) + + err := mod.Provision(ctx) + if err != nil { + t.Errorf("expected no error but got: %v", err) + } +} + +func TestQPDF_Validate(t *testing.T) { + for i, tc := range []struct { + binPath string + expectErr bool + }{ + { + expectErr: true, + }, + { + binPath: "/foo", + expectErr: true, + }, + { + binPath: os.Getenv("QPDF_BIN_PATH"), + }, + } { + mod := new(QPDF) + mod.binPath = tc.binPath + err := mod.Validate() + + if tc.expectErr && err == nil { + t.Errorf("test %d: expected error but got: %v", i, err) + } + + if !tc.expectErr && err != nil { + t.Errorf("test %d: expected no error but got: %v", i, err) + } + } +} + +func TestQPDF_Metrics(t *testing.T) { + metrics, err := new(QPDF).Metrics() + if err != nil { + t.Errorf("expected no error but got: %v", err) + } + + if len(metrics) != 1 { + t.Errorf("expected %d metrics, but got %d", 1, len(metrics)) + } + + actual := metrics[0].Read() + if actual != 0 { + t.Errorf("expected %d QPDF instances, but got %f", 0, actual) + } +} + +func TestQPDF_Merge(t *testing.T) { + for i, tc := range []struct { + ctx context.Context + inputPaths []string + expectErr bool + }{ + { + ctx: context.TODO(), + inputPaths: []string{ + "/tests/test/testdata/pdfengines/sample1.pdf", + }, + }, + { + ctx: context.TODO(), + inputPaths: []string{ + "/tests/test/testdata/pdfengines/sample1.pdf", + "/tests/test/testdata/pdfengines/sample2.pdf", + }, + }, + { + ctx: nil, + expectErr: true, + }, + { + ctx: context.TODO(), + inputPaths: []string{ + "foo", + }, + expectErr: true, + }, + } { + func() { + mod := new(QPDF) + + err := mod.Provision(nil) + if err != nil { + t.Fatalf("test %d: expected error but got: %v", i, err) + } + + outputDir, err := gotenberg.MkdirAll() + if err != nil { + t.Fatalf("test %d: expected error but got: %v", i, err) + } + + defer func() { + err := os.RemoveAll(outputDir) + if err != nil { + t.Fatalf("test %d: expected no error but got: %v", i, err) + } + }() + + err = mod.Merge(tc.ctx, zap.NewNop(), tc.inputPaths, outputDir+"/foo.pdf") + + if tc.expectErr && err == nil { + t.Errorf("test %d: expected error but got: %v", i, err) + } + + if !tc.expectErr && err != nil { + t.Errorf("test %d: expected no error but got: %v", i, err) + } + }() + } +} + +func TestQPDF_Convert(t *testing.T) { + mod := new(QPDF) + err := mod.Convert(context.TODO(), zap.NewNop(), "", "", "") + + if !errors.Is(err, gotenberg.ErrPDFEngineMethodNotAvailable) { + t.Errorf("expected error %v, but got: %v", gotenberg.ErrPDFEngineMethodNotAvailable, err) + } +} diff --git a/pkg/standard/imports.go b/pkg/standard/imports.go index 8d1b25d8..1046aacb 100644 --- a/pkg/standard/imports.go +++ b/pkg/standard/imports.go @@ -13,5 +13,6 @@ import ( _ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdfengines" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/pdftk" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/prometheus" + _ "github.com/gotenberg/gotenberg/v7/pkg/modules/qpdf" _ "github.com/gotenberg/gotenberg/v7/pkg/modules/webhook" )