Repository: gotenberg/gotenberg (go)

Issue:
Merge order hard to obtain in some scenarios
## Expected Behavior
when i merge multiple pdf files, the result pdf should be ordered . for example : i merge 1.pdf , 2.pdf ,3.pdf , 4.pdf , the sum pdf file should be 1,2,3,4 turn ,the asc order. The number of pdf I merge varies, could be more than 20 pdf files.

## Current Behavior
i got the result pdf ,which have the 1,4,3,2 turn , it's out-of-order

## Steps to Reproduce (for bugs)
1.prepare three or more pdf files , 1.pdf , 2.pdf ,3.pdf,4.pdf 2.request the merge feature 3.check the order of the result pdf 4.

## Context
* Version used: 8



Pull request description:
feat: better file sorting
Closes #805.

Now handles correctly numeric prefix, and falls back to alphabetical sort if none or same numeric:

```
values:       []string{"10qux.pdf", "2_baz.txt", "2_aza.txt", "1bar.pdf", "Afoo.txt", "Bbar.docx", "25zeta.txt", "3.pdf", "4_foo.pdf"},
expectedSort: []string{"1bar.pdf", "2_aza.txt", "2_baz.txt", "3.pdf", "4_foo.pdf", "10qux.pdf", "25zeta.txt", "Afoo.txt", "Bbar.docx"},
```

Patch:
diff --git a/go.mod b/go.mod
index a7609f0..0f423e3 100644
--- a/go.mod
+++ b/go.mod
@@ -11,7 +11,7 @@ require (
 	github.com/google/uuid v1.6.0
 	github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
 	github.com/hashicorp/go-retryablehttp v0.7.5
-	github.com/klauspost/compress v1.17.6 // indirect
+	github.com/klauspost/compress v1.17.7 // indirect
 	github.com/klauspost/pgzip v1.2.6 // indirect
 	github.com/labstack/echo/v4 v4.11.4
 	github.com/labstack/gommon v0.4.2
@@ -26,7 +26,7 @@ require (
 	github.com/spf13/pflag v1.0.5
 	github.com/ulikunitz/xz v0.5.11 // indirect
 	go.uber.org/multierr v1.11.0
-	go.uber.org/zap v1.26.0
+	go.uber.org/zap v1.27.0
 	golang.org/x/crypto v0.19.0 // indirect
 	golang.org/x/image v0.15.0 // indirect
 	golang.org/x/net v0.21.0
@@ -56,7 +56,7 @@ require (
 	github.com/mattn/go-runewidth v0.0.15 // indirect
 	github.com/pkg/errors v0.9.1 // indirect
 	github.com/prometheus/client_model v0.6.0 // indirect
-	github.com/prometheus/common v0.47.0 // indirect
+	github.com/prometheus/common v0.48.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
diff --git a/go.sum b/go.sum
index 225a86b..4b7fcc3 100644
--- a/go.sum
+++ b/go.sum
@@ -53,8 +53,8 @@ github.com/josharian/intern v1.0.0 h1:vlS4z54oSdjm0bgjRigI+G1HpF+tI+9rE5LLzOg8Hm
 github.com/josharian/intern v1.0.0/go.mod h1:5DoeVV0s6jJacbCEi61lwdGj/aVlrQvzHFFd8Hwg//Y=
 github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
 github.com/klauspost/compress v1.11.4/go.mod h1:aoV0uJVorq1K+umq18yTdKaF57EivdYsUV+/s2qKfXs=
-github.com/klauspost/compress v1.17.6 h1:60eq2E/jlfwQXtvZEeBUYADs+BwKBWURIY+Gj2eRGjI=
-github.com/klauspost/compress v1.17.6/go.mod h1:/dCuZOvVtNoHsyb+cuJD3itjs3NbnF6KH9zAO4BDxPM=
+github.com/klauspost/compress v1.17.7 h1:ehO88t2UGzQK66LMdE8tibEd1ErmzZjNEqWkjLAKQQg=
+github.com/klauspost/compress v1.17.7/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw=
 github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
 github.com/klauspost/pgzip v1.2.5/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
 github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
@@ -100,8 +100,8 @@ github.com/prometheus/client_golang v1.18.0 h1:HzFfmkOzH5Q8L8G+kSJKUx5dtG87sewO+
 github.com/prometheus/client_golang v1.18.0/go.mod h1:T+GXkCk5wSJyOqMIzVgvvjFDlkOQntgjkJWKrN5txjA=
 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.47.0 h1:p5Cz0FNHo7SnWOmWmoRozVcjEp0bIVU8cV7OShpjL1k=
-github.com/prometheus/common v0.47.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
+github.com/prometheus/common v0.48.0 h1:QO8U2CdOzSn1BBsmXJXduaaW+dY/5QLjfB8svtSzKKE=
+github.com/prometheus/common v0.48.0/go.mod h1:0/KsvlIEfPQCQ5I2iNSAWKPZziNCvRs5EC6ILDTlAPc=
 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=
@@ -128,12 +128,12 @@ github.com/valyala/fasttemplate v1.2.2 h1:lxLXG0uE3Qnshl9QyaK6XJxMXlQZELvChBOCmQ
 github.com/valyala/fasttemplate v1.2.2/go.mod h1:KHLXt3tVN2HBp8eijSv/kGJopbvo7S+qRAEEKiv+SiQ=
 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8 h1:nIPpBwaJSVYIxUFsDv3M8ofmx9yWTog9BfvIu0q41lo=
 github.com/xi2/xz v0.0.0-20171230120015-48954b6210f8/go.mod h1:HUYIGzjTL3rfEspMxjDjgmT5uz5wzYJKVo23qUhYTos=
-go.uber.org/goleak v1.2.0 h1:xqgm/S+aQvhWFTtR0XK3Jvg7z8kGV8P4X14IzwN3Eqk=
-go.uber.org/goleak v1.2.0/go.mod h1:XJYK+MuIchqpmGmUSAzotztawfKvYLUIgg7guXrwVUo=
+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/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.26.0 h1:sI7k6L95XOKS281NhVKOFCUNIvv9e0w4BF8N3u+tCRo=
-go.uber.org/zap v1.26.0/go.mod h1:dtElttAiwGvoJ/vj4IwHBS/gXsEu/pZ50mUIRWuG0so=
+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.19.0 h1:ENy+Az/9Y1vSrlrvBSyna3PITt4tiZLf7sgCjZBX7Wo=
 golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
 golang.org/x/image v0.15.0 h1:kOELfmgrmJlw4Cdb7g/QGuB3CvDrXbqEIww/pNtNBm8=
diff --git a/pkg/gotenberg/regexp.go b/pkg/gotenberg/filter.go
similarity index 100%
rename from pkg/gotenberg/regexp.go
rename to pkg/gotenberg/filter.go
diff --git a/pkg/gotenberg/regexp_test.go b/pkg/gotenberg/filter_test.go
similarity index 100%
rename from pkg/gotenberg/regexp_test.go
rename to pkg/gotenberg/filter_test.go
diff --git a/pkg/gotenberg/sort.go b/pkg/gotenberg/sort.go
new file mode 100644
index 0000000..e8e2ea7
--- /dev/null
+++ b/pkg/gotenberg/sort.go
@@ -0,0 +1,69 @@
+package gotenberg
+
+import (
+	"regexp"
+	"sort"
+	"strconv"
+)
+
+// AlphanumericSort implements sort.Interface and helps to sort strings
+// alphanumerically.
+//
+// See https://github.com/gotenberg/gotenberg/issues/805.
+type AlphanumericSort []string
+
+func (s AlphanumericSort) Len() int {
+	return len(s)
+}
+
+func (s AlphanumericSort) Swap(i, j int) {
+	s[i], s[j] = s[j], s[i]
+}
+
+func (s AlphanumericSort) Less(i, j int) bool {
+	numI, restI := extractPrefix(s[i])
+	numJ, restJ := extractPrefix(s[j])
+
+	// Compares numerical prefixes if they exist.
+	if numI != -1 && numJ != -1 {
+		if numI != numJ {
+			return numI < numJ
+		}
+		// If numbers are equal, falls back to string comparison of the rest.
+		return restI < restJ
+	}
+
+	// If one has a numerical prefix and the other doesn't, the one with the
+	// number comes first.
+	if numI != -1 {
+		return true
+	}
+	if numJ != -1 {
+		return false
+	}
+
+	// If neither has a numerical prefix, compare as strings
+	return s[i] < s[j]
+}
+
+// extractPrefix attempts to extract a numerical prefix and the rest of the filename
+func extractPrefix(filename string) (int, string) {
+	matches := numPrefixRegexp.FindStringSubmatch(filename)
+	if len(matches) > 2 {
+		prefix, err := strconv.Atoi(matches[1])
+		if err == nil {
+			return prefix, matches[2]
+		}
+	}
+
+	// Returns -1 if no numerical prefix is found, indicating to just compare
+	// as strings.
+	return -1, filename
+}
+
+var numPrefixRegexp = regexp.MustCompile(`^(\d+)(.*)$`)
+
+// Interface guard.
+var (
+	_ sort.Interface = (*AlphanumericSort)(nil)
+)
diff --git a/pkg/modules/api/formdata.go b/pkg/modules/api/formdata.go
index 2ceb5e1..d07c14c 100644
--- a/pkg/modules/api/formdata.go
+++ b/pkg/modules/api/formdata.go
@@ -11,6 +11,8 @@ import (
 	"time"
 
 	"go.uber.org/multierr"
+
+	"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
 )
 
 // FormData is a helper for validating and hydrating values from a
@@ -304,7 +306,7 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
 	}
 
 	// See https://github.com/gotenberg/gotenberg/issues/139.
-	sort.Strings(*target)
+	sort.Sort(gotenberg.AlphanumericSort(*target))
 
 	return form
 }


Test patch:
diff --git a/pkg/gotenberg/sort_test.go b/pkg/gotenberg/sort_test.go
new file mode 100644
index 0000000..94b2916
--- /dev/null
+++ b/pkg/gotenberg/sort_test.go
@@ -0,0 +1,34 @@
+package gotenberg
+
+import (
+	"reflect"
+	"sort"
+	"testing"
+)
+
+func TestAlphanumericSort(t *testing.T) {
+	for _, tc := range []struct {
+		scenario     string
+		values       []string
+		expectedSort []string
+	}{
+		{
+			scenario:     "numeric and letters",
+			values:       []string{"10qux.pdf", "2_baz.txt", "2_aza.txt", "1bar.pdf", "Afoo.txt", "Bbar.docx", "25zeta.txt", "3.pdf", "4_foo.pdf"},
+			expectedSort: []string{"1bar.pdf", "2_aza.txt", "2_baz.txt", "3.pdf", "4_foo.pdf", "10qux.pdf", "25zeta.txt", "Afoo.txt", "Bbar.docx"},
+		},
+		{
+			scenario:     "hrtime (PHP library)",
+			values:       []string{"245654773395259", "245654773395039", "245654773395149", "245654773394919", "245654773394369"},
+			expectedSort: []string{"245654773394369", "245654773394919", "245654773395039", "245654773395149", "245654773395259"},
+		},
+	} {
+		t.Run(tc.scenario, func(t *testing.T) {
+			sort.Sort(AlphanumericSort(tc.values))
+
+			if !reflect.DeepEqual(tc.values, tc.expectedSort) {
+				t.Fatalf("expected %+v but got: %+v", tc.expectedSort, tc.values)
+			}
+		})
+	}
+}