feat(pdfengines): add flatten form field to split route

This commit is contained in:
Julien Neuhart
2025-01-28 17:15:14 +01:00
parent b418f1eb05
commit 1d1b4fb04d
6 changed files with 102 additions and 22 deletions

View File

@@ -98,9 +98,10 @@ type PdfEngine interface {
// Split splits a given PDF file. // Split splits a given PDF file.
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
// Flatten merges existing annotation appearances with page content, effectively deleting the original annotations. // Flatten merges existing annotation appearances with page content,
// This process can flatten forms as well, as forms share a relationship with annotations. // effectively deleting the original annotations. This process can flatten
// Note that this operation is irreversible. // forms as well, as forms share a relationship with annotations. Note that
// this operation is irreversible.
Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error
// Convert transforms a given PDF to the specified formats defined in // Convert transforms a given PDF to the specified formats defined in

View File

@@ -101,9 +101,8 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod
return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err) return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
} }
// Flatten merges existing annotation appearances with page content, effectively deleting the original annotations. // Flatten merges existing annotation appearances with page content, thanks to
// This process can flatten forms as well, as forms share a relationship with annotations. // its children. If the context is done, it stops and returns an error
// Note that this operation is irreversible.
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)

View File

@@ -202,10 +202,8 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
return outputPaths, nil return outputPaths, nil
} }
// FlattenStub merges annotation appearances with page content for each given PDF // FlattenStub merges annotation appearances with page content for each given
// in the input paths, effectively deleting the original annotations. It generates // PDF, effectively deleting the original annotations.
// new output paths for the flattened PDFs and returns them. If an error occurs
// during the flattening process, it returns the error.
func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) error { func FlattenStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) error {
for _, inputPath := range inputPaths { for _, inputPath := range inputPaths {
err := engine.Flatten(ctx, ctx.Log(), inputPath) err := engine.Flatten(ctx, ctx.Log(), inputPath)
@@ -327,8 +325,10 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
metadata := FormDataPdfMetadata(form, false) metadata := FormDataPdfMetadata(form, false)
var inputPaths []string var inputPaths []string
var flatten bool
err := form. err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
Bool("flatten", &flatten, false).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
@@ -349,6 +349,13 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("write metadata: %w", err) return fmt.Errorf("write metadata: %w", err)
} }
if flatten {
err = FlattenStub(ctx, engine, convertOutputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
}
zeroValuedSplitMode := gotenberg.SplitMode{} zeroValuedSplitMode := gotenberg.SplitMode{}
zeroValuedPdfFormats := gotenberg.PdfFormats{} zeroValuedPdfFormats := gotenberg.PdfFormats{}
if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats { if mode != zeroValuedSplitMode && pdfFormats != zeroValuedPdfFormats {
@@ -392,7 +399,7 @@ func flattenRoute(engine gotenberg.PdfEngine) api.Route {
err = FlattenStub(ctx, engine, inputPaths) err = FlattenStub(ctx, engine, inputPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert PDFs: %w", err) return fmt.Errorf("flatten PDFs: %w", err)
} }
err = ctx.AddOutputPaths(inputPaths...) err = ctx.AddOutputPaths(inputPaths...)

View File

@@ -503,6 +503,45 @@ func TestSplitPdfStub(t *testing.T) {
} }
} }
func TestFlattenStub(t *testing.T) {
for _, tc := range []struct {
scenario string
engine gotenberg.PdfEngine
expectError bool
}{
{
scenario: "flatten error",
engine: &gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
},
},
expectError: true,
},
{
scenario: "flatten success",
engine: &gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
},
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := FlattenStub(new(api.Context), tc.engine, []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) { func TestConvertStub(t *testing.T) {
for _, tc := range []struct { for _, tc := range []struct {
scenario string scenario string
@@ -647,7 +686,7 @@ func TestMergeHandler(t *testing.T) {
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{ {
scenario: "PDF engine merge error", scenario: "error from PDF engine (merge)",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
@@ -666,7 +705,7 @@ func TestMergeHandler(t *testing.T) {
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{ {
scenario: "PDF engine convert error", scenario: "error from PDF engine (convert)",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
@@ -693,7 +732,7 @@ func TestMergeHandler(t *testing.T) {
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{ {
scenario: "PDF engine write metadata error", scenario: "error from PDF engine (write metadata)",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
@@ -720,7 +759,7 @@ func TestMergeHandler(t *testing.T) {
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{ {
scenario: "PDF engine flatten error", scenario: "error from PDF engine (flatten)",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
ctx := &api.ContextMock{Context: new(api.Context)} ctx := &api.ContextMock{Context: new(api.Context)}
ctx.SetFiles(map[string]string{ ctx.SetFiles(map[string]string{
@@ -728,9 +767,6 @@ func TestMergeHandler(t *testing.T) {
"file2.pdf": "/file2.pdf", "file2.pdf": "/file2.pdf",
}) })
ctx.SetValues(map[string][]string{ ctx.SetValues(map[string][]string{
"metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
},
"flatten": { "flatten": {
"true", "true",
}, },
@@ -741,9 +777,6 @@ func TestMergeHandler(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
}, },
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil
},
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error { FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo") return errors.New("foo")
}, },
@@ -975,6 +1008,38 @@ func TestSplitHandler(t *testing.T) {
expectHttpError: false, expectHttpError: false,
expectOutputPathsCount: 0, expectOutputPathsCount: 0,
}, },
{
scenario: "error from PDF engine (flatten)",
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",
},
"flatten": {
"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
},
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return errors.New("foo")
},
},
expectError: true,
expectHttpError: false,
expectOutputPathsCount: 0,
},
{ {
scenario: "cannot add output paths", scenario: "cannot add output paths",
ctx: func() *api.ContextMock { ctx: func() *api.ContextMock {
@@ -1022,6 +1087,9 @@ func TestSplitHandler(t *testing.T) {
"metadata": { "metadata": {
"{\"Creator\": \"foo\", \"Producer\": \"bar\" }", "{\"Creator\": \"foo\", \"Producer\": \"bar\" }",
}, },
"flatten": {
"true",
},
}) })
return ctx return ctx
}(), }(),
@@ -1035,6 +1103,9 @@ func TestSplitHandler(t *testing.T) {
WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error { WriteMetadataMock: func(ctx context.Context, logger *zap.Logger, metadata map[string]interface{}, inputPath string) error {
return nil return nil
}, },
FlattenMock: func(ctx context.Context, logger *zap.Logger, inputPath string) error {
return nil
},
}, },
expectError: false, expectError: false,
expectHttpError: false, expectHttpError: false,

View File

@@ -3,6 +3,7 @@
// //
// 1. The merging of PDF files. // 1. The merging of PDF files.
// 2. The splitting of PDF files. // 2. The splitting of PDF files.
// 3. Flattening of PDF files
// //
// The path to the QPDF binary must be specified using the QPDK_BIN_PATH // The path to the QPDF binary must be specified using the QPDK_BIN_PATH
// environment variable. // environment variable.

View File

@@ -101,7 +101,8 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
return fmt.Errorf("merge PDFs with QPDF: %w", err) return fmt.Errorf("merge PDFs with QPDF: %w", err)
} }
// Flatten merges annotation appearances with page content, deleting the original annotations. // Flatten merges annotation appearances with page content, deleting the
// original annotations.
func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
var args []string var args []string
args = append(args, "--generate-appearances") args = append(args, "--generate-appearances")