commit 49fb60f8317bfc32abee8a879637c4db8dc84a92 Author: Artem Tsyrulnikov Date: Wed May 13 19:52:51 2026 +0300 Initial orientator diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b4a15e3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +.DS_Store +.gocache/ +.gomodcache/ diff --git a/cmd/orientator/main.go b/cmd/orientator/main.go new file mode 100644 index 0000000..a1909cd --- /dev/null +++ b/cmd/orientator/main.go @@ -0,0 +1,47 @@ +package main + +import ( + "flag" + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "orientator/internal/server" +) + +func main() { + addr := flag.String("addr", envOr("ADDR", ":8080"), "listen address") + flag.Parse() + + srv := &http.Server{ + Addr: *addr, + Handler: server.New().Handler(), + ReadTimeout: 15 * time.Minute, + WriteTimeout: 15 * time.Minute, + MaxHeaderBytes: 1 << 20, + } + + go func() { + log.Printf("orientator listening on %s", *addr) + if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatal(err) + } + }() + + sig := make(chan os.Signal, 1) + signal.Notify(sig, syscall.SIGINT, syscall.SIGTERM) + <-sig + fmt.Println("\nshutting down") + _ = srv.Close() +} + +func envOr(k, def string) string { + if v := os.Getenv(k); v != "" { + return v + } + return def +} diff --git a/cmd/testfixture/main.go b/cmd/testfixture/main.go new file mode 100644 index 0000000..e01fc13 --- /dev/null +++ b/cmd/testfixture/main.go @@ -0,0 +1,41 @@ +// testfixture renders page 1 of a PDF as upright PNG and as a 90°-rotated PNG. +package main + +import ( + "fmt" + "image" + "image/png" + "os" + + "github.com/gen2brain/go-fitz" + "orientator/internal/pipeline" +) + +func main() { + if len(os.Args) < 4 { + fmt.Println("usage: testfixture ") + os.Exit(2) + } + doc, err := fitz.New(os.Args[1]) + must(err) + defer doc.Close() + img, err := doc.ImageDPI(0, 150) + must(err) + writePNG(os.Args[2], img) + writePNG(os.Args[3], pipeline.RotateImage(img, 90)) + fmt.Println("ok") +} + +func writePNG(path string, img image.Image) { + f, err := os.Create(path) + must(err) + defer f.Close() + must(png.Encode(f, img)) +} + +func must(err error) { + if err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} diff --git a/dist/orientator-linux-amd64 b/dist/orientator-linux-amd64 new file mode 100755 index 0000000..3cc4502 Binary files /dev/null and b/dist/orientator-linux-amd64 differ diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..7a18d3c --- /dev/null +++ b/go.mod @@ -0,0 +1,36 @@ +module orientator + +go 1.25.0 + +require ( + github.com/gen2brain/go-fitz v1.24.15 + github.com/pdfcpu/pdfcpu v0.12.0 + github.com/yalue/onnxruntime_go v1.13.0 + golang.org/x/image v0.39.0 +) + +require ( + github.com/andybalholm/brotli v1.2.1 // indirect + github.com/bodgit/plumbing v1.3.0 // indirect + github.com/bodgit/sevenzip v1.6.2 // indirect + github.com/bodgit/windows v1.0.1 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect + github.com/ebitengine/purego v0.8.4 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/hhrutter/lzw v1.0.0 // indirect + github.com/hhrutter/pkcs7 v0.2.2 // indirect + github.com/hhrutter/tiff v1.0.3 // indirect + github.com/jupiterrider/ffi v0.5.0 // indirect + github.com/klauspost/compress v1.18.5 // indirect + github.com/mattn/go-runewidth v0.0.23 // indirect + github.com/nwaples/rardecode/v2 v2.2.2 // indirect + github.com/pierrec/lz4/v4 v4.1.26 // indirect + github.com/pkg/errors v0.9.1 // indirect + github.com/spf13/afero v1.15.0 // indirect + github.com/ulikunitz/xz v0.5.15 // indirect + go4.org v0.0.0-20260112195520-a5071408f32f // indirect + golang.org/x/crypto v0.50.0 // indirect + golang.org/x/sys v0.43.0 // indirect + golang.org/x/text v0.36.0 // indirect + gopkg.in/yaml.v2 v2.4.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..fa04831 --- /dev/null +++ b/go.sum @@ -0,0 +1,67 @@ +github.com/andybalholm/brotli v1.2.1 h1:R+f5xP285VArJDRgowrfb9DqL18yVK0gKAW/F+eTWro= +github.com/andybalholm/brotli v1.2.1/go.mod h1:rzTDkvFWvIrjDXZHkuS16NPggd91W3kUSvPlQ1pLaKY= +github.com/bodgit/plumbing v1.3.0 h1:pf9Itz1JOQgn7vEOE7v7nlEfBykYqvUYioC61TwWCFU= +github.com/bodgit/plumbing v1.3.0/go.mod h1:JOTb4XiRu5xfnmdnDJo6GmSbSbtSyufrsyZFByMtKEs= +github.com/bodgit/sevenzip v1.6.2 h1:6/0mwj5KaRXpuf9iSiE+VpG7VpzFJ8D60P53VjxRv34= +github.com/bodgit/sevenzip v1.6.2/go.mod h1:q8DktB7GbvNn0Q6u4Iq6zULE0vo3rWtRHQg5L1XmjuU= +github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= +github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/ebitengine/purego v0.8.4 h1:CF7LEKg5FFOsASUj0+QwaXf8Ht6TlFxg09+S9wz0omw= +github.com/ebitengine/purego v0.8.4/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/gen2brain/go-fitz v1.24.15 h1:sJNB1MOWkqnzzENPHggFpgxTwW0+S5WF/rM5wUBpJWo= +github.com/gen2brain/go-fitz v1.24.15/go.mod h1:SftkiVbTHqF141DuiLwBBM65zP7ig6AVDQpf2WlHamo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/hhrutter/lzw v1.0.0 h1:laL89Llp86W3rRs83LvKbwYRx6INE8gDn0XNb1oXtm0= +github.com/hhrutter/lzw v1.0.0/go.mod h1:2HC6DJSn/n6iAZfgM3Pg+cP1KxeWc3ezG8bBqW5+WEo= +github.com/hhrutter/pkcs7 v0.2.2 h1:xMoifoVWah1LNym3C0pomEiLmyJyVIBXt/8oTPyPz+8= +github.com/hhrutter/pkcs7 v0.2.2/go.mod h1:aEzKz0+ZAlz7YaEMY47jDHL14hVWD6iXt0AgqgAvWgE= +github.com/hhrutter/tiff v1.0.3 h1:POV5xITOE1Lt5FvP24ylft0LyCmHmc8GkJ1SVlvUyk0= +github.com/hhrutter/tiff v1.0.3/go.mod h1:zZDLVY4cp9za2FLrryAaGszwWYAUM6DrRiBR0l//mxA= +github.com/jupiterrider/ffi v0.5.0 h1:j2nSgpabbV1JOwgP4Kn449sJUHq3cVLAZVBoOYn44V8= +github.com/jupiterrider/ffi v0.5.0/go.mod h1:x7xdNKo8h0AmLuXfswDUBxUsd2OqUP4ekC8sCnsmbvo= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= +github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= +github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= +github.com/nwaples/rardecode/v2 v2.2.2 h1:/5oL8dzYivRM/tqX9VcTSWfbpwcbwKG1QtSJr3b3KcU= +github.com/nwaples/rardecode/v2 v2.2.2/go.mod h1:7uz379lSxPe6j9nvzxUZ+n7mnJNgjsRNb6IbvGVHRmw= +github.com/pdfcpu/pdfcpu v0.12.0 h1:GonU1Ub45kKo/LdakJhaBA0NTTvBA7KGs3bfmEU1osU= +github.com/pdfcpu/pdfcpu v0.12.0/go.mod h1:7KPpVLMavcpliPrtN6o7Kuk3cFtYq8nii3SJnnsK7ps= +github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= +github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= +github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= +github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/spf13/afero v1.15.0 h1:b/YBCLWAJdFWJTN9cLhiXXcD7mzKn9Dm86dNnfyQw1I= +github.com/spf13/afero v1.15.0/go.mod h1:NC2ByUVxtQs4b3sIUphxK0NioZnmxgyCrfzeuq8lxMg= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw= +github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo= +github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU= +github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= +github.com/ulikunitz/xz v0.5.15 h1:9DNdB5s+SgV3bQ2ApL10xRc35ck0DuIX/isZvIk+ubY= +github.com/ulikunitz/xz v0.5.15/go.mod h1:nbz6k7qbPmH4IRqmfOplQw/tblSgqTqBwxkY0oWt/14= +github.com/yalue/onnxruntime_go v1.13.0 h1:5HDXHon3EukQMyYA7yPMed/raWaDE/gjwLOwnVoiwy8= +github.com/yalue/onnxruntime_go v1.13.0/go.mod h1:b4X26A8pekNb1ACJ58wAXgNKeUCGEAQ9dmACut9Sm/4= +go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= +go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= +golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= +golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +golang.org/x/image v0.39.0 h1:skVYidAEVKgn8lZ602XO75asgXBgLj9G/FE3RbuPFww= +golang.org/x/image v0.39.0/go.mod h1:sIbmppfU+xFLPIG0FoVUTvyBMmgng1/XAMhQ2ft0hpA= +golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= +golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= +golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/detector/assets/README.txt b/internal/detector/assets/README.txt new file mode 100644 index 0000000..5d21d2e --- /dev/null +++ b/internal/detector/assets/README.txt @@ -0,0 +1,2 @@ +Place dedoc_orientation.onnx here. +ONNX runtime shared libs go in onnxruntime/-/. diff --git a/internal/detector/assets/dedoc_orientation.onnx b/internal/detector/assets/dedoc_orientation.onnx new file mode 100644 index 0000000..5e2ed1c Binary files /dev/null and b/internal/detector/assets/dedoc_orientation.onnx differ diff --git a/internal/detector/assets/onnxruntime/darwin-arm64/libonnxruntime.dylib b/internal/detector/assets/onnxruntime/darwin-arm64/libonnxruntime.dylib new file mode 100644 index 0000000..f76f66d Binary files /dev/null and b/internal/detector/assets/onnxruntime/darwin-arm64/libonnxruntime.dylib differ diff --git a/internal/detector/assets/onnxruntime/linux-amd64/libonnxruntime.so b/internal/detector/assets/onnxruntime/linux-amd64/libonnxruntime.so new file mode 100644 index 0000000..0ce9417 Binary files /dev/null and b/internal/detector/assets/onnxruntime/linux-amd64/libonnxruntime.so differ diff --git a/internal/detector/assets_darwin_arm64.go b/internal/detector/assets_darwin_arm64.go new file mode 100644 index 0000000..dcc0af6 --- /dev/null +++ b/internal/detector/assets_darwin_arm64.go @@ -0,0 +1,10 @@ +//go:build darwin && arm64 + +package detector + +import _ "embed" + +//go:embed assets/onnxruntime/darwin-arm64/libonnxruntime.dylib +var runtimeLibBytes []byte + +const runtimeLibFileName = "libonnxruntime.dylib" diff --git a/internal/detector/assets_linux_amd64.go b/internal/detector/assets_linux_amd64.go new file mode 100644 index 0000000..0dc257e --- /dev/null +++ b/internal/detector/assets_linux_amd64.go @@ -0,0 +1,10 @@ +//go:build linux && amd64 + +package detector + +import _ "embed" + +//go:embed assets/onnxruntime/linux-amd64/libonnxruntime.so +var runtimeLibBytes []byte + +const runtimeLibFileName = "libonnxruntime.so" diff --git a/internal/detector/detector.go b/internal/detector/detector.go new file mode 100644 index 0000000..afa6f31 --- /dev/null +++ b/internal/detector/detector.go @@ -0,0 +1,181 @@ +package detector + +import ( + _ "embed" + "fmt" + "image" + "math" + "os" + "path/filepath" + "runtime" + "sync" + + ort "github.com/yalue/onnxruntime_go" +) + +// Angles map to model's [2:6] logits in the same order as the Python reference: +// indices [0,1,2,3] -> degrees [0, 270, 180, 90]. +var dedocMap = [4]int{0, 270, 180, 90} + +//go:embed assets/dedoc_orientation.onnx +var modelBytes []byte + +type Detector struct { + session *ort.AdvancedSession + input *ort.Tensor[float32] + output *ort.Tensor[float32] + mu sync.Mutex // ONNX session is not goroutine-safe under concurrent Run +} + +var ( + once sync.Once + instance *Detector + initErr error +) + +// Get returns the lazily-initialized singleton detector. +func Get() (*Detector, error) { + once.Do(func() { + instance, initErr = newDetector() + }) + return instance, initErr +} + +func newDetector() (*Detector, error) { + libPath, err := extractRuntimeLib() + if err != nil { + return nil, fmt.Errorf("extract runtime: %w", err) + } + ort.SetSharedLibraryPath(libPath) + if err := ort.InitializeEnvironment(); err != nil { + return nil, fmt.Errorf("init ort env: %w", err) + } + + modelPath, err := extractModel() + if err != nil { + return nil, fmt.Errorf("extract model: %w", err) + } + + inputs, outputs, err := ort.GetInputOutputInfo(modelPath) + if err != nil { + return nil, fmt.Errorf("inspect model: %w", err) + } + if len(inputs) == 0 || len(outputs) == 0 { + return nil, fmt.Errorf("model has no inputs/outputs") + } + + inShape := ort.NewShape(1, 3, ImgSize, ImgSize) + inTensor, err := ort.NewEmptyTensor[float32](inShape) + if err != nil { + return nil, err + } + outShape := ort.NewShape(1, 6) + outTensor, err := ort.NewEmptyTensor[float32](outShape) + if err != nil { + return nil, err + } + + opts, err := ort.NewSessionOptions() + if err != nil { + return nil, err + } + defer opts.Destroy() + _ = opts.SetIntraOpNumThreads(runtime.NumCPU()) + + sess, err := ort.NewAdvancedSession( + modelPath, + []string{inputs[0].Name}, + []string{outputs[0].Name}, + []ort.ArbitraryTensor{inTensor}, + []ort.ArbitraryTensor{outTensor}, + opts, + ) + if err != nil { + return nil, fmt.Errorf("create session: %w", err) + } + + return &Detector{ + session: sess, + input: inTensor, + output: outTensor, + }, nil +} + +// Predict returns the rotation needed to right the image and the confidence. +// Output angle ∈ {0, 90, 180, 270}; meaning matches the Python reference. +func (d *Detector) Predict(img image.Image) (angle int, confidence float64, err error) { + if img == nil { + return 0, 0, fmt.Errorf("nil image") + } + data := Preprocess(img) + + d.mu.Lock() + defer d.mu.Unlock() + + dst := d.input.GetData() + copy(dst, data) + + if err := d.session.Run(); err != nil { + return 0, 0, fmt.Errorf("ort run: %w", err) + } + logits := d.output.GetData() // shape [1,6] + // Use only orientation logits at index [2:6]. + probs := softmax4(logits[2:6]) + idx := 0 + best := probs[0] + for i := 1; i < 4; i++ { + if probs[i] > best { + best = probs[i] + idx = i + } + } + return dedocMap[idx], float64(best), nil +} + +func softmax4(x []float32) [4]float32 { + var max float32 = x[0] + for i := 1; i < 4; i++ { + if x[i] > max { + max = x[i] + } + } + var sum float32 + var e [4]float32 + for i := 0; i < 4; i++ { + e[i] = float32(math.Exp(float64(x[i] - max))) + sum += e[i] + } + for i := 0; i < 4; i++ { + e[i] /= sum + } + return e +} + +func extractRuntimeLib() (string, error) { + return materialize("runtime", runtimeLibFileName, runtimeLibBytes) +} + +func extractModel() (string, error) { + return materialize("model", "dedoc_orientation.onnx", modelBytes) +} + +// materialize writes embedded bytes to the user cache dir, reusing the existing +// file if its size already matches. +func materialize(subdir, name string, data []byte) (string, error) { + cacheDir, err := os.UserCacheDir() + if err != nil { + cacheDir = os.TempDir() + } + dir := filepath.Join(cacheDir, "orientator", subdir, fmt.Sprintf("%s-%s", runtime.GOOS, runtime.GOARCH)) + if err := os.MkdirAll(dir, 0o755); err != nil { + return "", err + } + out := filepath.Join(dir, name) + if st, err := os.Stat(out); err == nil && int(st.Size()) == len(data) { + return out, nil + } + if err := os.WriteFile(out, data, 0o644); err != nil { + return "", err + } + return out, nil +} diff --git a/internal/detector/preprocess.go b/internal/detector/preprocess.go new file mode 100644 index 0000000..f787e44 --- /dev/null +++ b/internal/detector/preprocess.go @@ -0,0 +1,53 @@ +package detector + +import ( + "image" + "image/color" + stddraw "image/draw" + + xdraw "golang.org/x/image/draw" +) + +const ImgSize = 1200 + +// Preprocess implements the Python reference exactly: +// 1. resize keeping aspect ratio to fit in 1200x1200 (Lanczos-equivalent CatmullRom) +// 2. paste onto white 1200x1200 canvas at (0,0) +// 3. normalize: (x/255 - 0.5) / 0.5 +// 4. HWC -> CHW with batch dim -> [1, 3, 1200, 1200] +func Preprocess(img image.Image) []float32 { + bounds := img.Bounds() + w, h := bounds.Dx(), bounds.Dy() + + scale := float64(ImgSize) / float64(w) + if s := float64(ImgSize) / float64(h); s < scale { + scale = s + } + newW := int(float64(w) * scale) + newH := int(float64(h) * scale) + if newW < 1 { + newW = 1 + } + if newH < 1 { + newH = 1 + } + + canvas := image.NewRGBA(image.Rect(0, 0, ImgSize, ImgSize)) + stddraw.Draw(canvas, canvas.Bounds(), &image.Uniform{C: color.RGBA{255, 255, 255, 255}}, image.Point{}, stddraw.Src) + xdraw.CatmullRom.Scale(canvas, image.Rect(0, 0, newW, newH), img, bounds, xdraw.Over, nil) + + out := make([]float32, 3*ImgSize*ImgSize) + plane := ImgSize * ImgSize + pix := canvas.Pix + for y := 0; y < ImgSize; y++ { + row := y * canvas.Stride + base := y * ImgSize + for x := 0; x < ImgSize; x++ { + i := row + x*4 + out[base+x] = float32(pix[i+0])/127.5 - 1 + out[plane+base+x] = float32(pix[i+1])/127.5 - 1 + out[2*plane+base+x] = float32(pix[i+2])/127.5 - 1 + } + } + return out +} diff --git a/internal/extract/archive.go b/internal/extract/archive.go new file mode 100644 index 0000000..493a7bf --- /dev/null +++ b/internal/extract/archive.go @@ -0,0 +1,160 @@ +// Package extract iterates over the contents of common archive formats +// (zip, tar, tar.gz, 7z, rar) without extracting to disk. +package extract + +import ( + "archive/tar" + "archive/zip" + "bytes" + "compress/gzip" + "errors" + "fmt" + "io" + "path/filepath" + "strings" + + "github.com/bodgit/sevenzip" + "github.com/nwaples/rardecode/v2" +) + +type Entry struct { + Name string + Data []byte +} + +type WalkFn func(Entry) error + +// Walk dispatches by extension and calls fn for each file entry. +func Walk(name string, data []byte, fn WalkFn) error { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".zip": + return walkZip(data, fn) + case ".tar": + return walkTar(bytes.NewReader(data), fn) + case ".gz", ".tgz": + return walkTarGz(data, fn) + case ".7z": + return walk7z(data, fn) + case ".rar": + return walkRar(data, fn) + } + return fmt.Errorf("unsupported archive: %s", ext) +} + +// IsArchive reports whether name has an extension we know how to extract. +func IsArchive(name string) bool { + switch strings.ToLower(filepath.Ext(name)) { + case ".zip", ".tar", ".gz", ".tgz", ".7z", ".rar": + return true + } + return false +} + +func walkZip(data []byte, fn WalkFn) error { + r, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return err + } + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + rc, err := f.Open() + if err != nil { + return err + } + buf, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return err + } + if err := fn(Entry{Name: f.Name, Data: buf}); err != nil { + return err + } + } + return nil +} + +func walkTar(r io.Reader, fn WalkFn) error { + tr := tar.NewReader(r) + for { + hdr, err := tr.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + if hdr.Typeflag != tar.TypeReg { + continue + } + buf, err := io.ReadAll(tr) + if err != nil { + return err + } + if err := fn(Entry{Name: hdr.Name, Data: buf}); err != nil { + return err + } + } +} + +func walkTarGz(data []byte, fn WalkFn) error { + gz, err := gzip.NewReader(bytes.NewReader(data)) + if err != nil { + return err + } + defer gz.Close() + return walkTar(gz, fn) +} + +func walk7z(data []byte, fn WalkFn) error { + r, err := sevenzip.NewReader(bytes.NewReader(data), int64(len(data))) + if err != nil { + return err + } + for _, f := range r.File { + if f.FileInfo().IsDir() { + continue + } + rc, err := f.Open() + if err != nil { + return err + } + buf, err := io.ReadAll(rc) + rc.Close() + if err != nil { + return err + } + if err := fn(Entry{Name: f.Name, Data: buf}); err != nil { + return err + } + } + return nil +} + +func walkRar(data []byte, fn WalkFn) error { + r, err := rardecode.NewReader(bytes.NewReader(data)) + if err != nil { + return err + } + for { + hdr, err := r.Next() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return err + } + if hdr.IsDir { + continue + } + buf, err := io.ReadAll(r) + if err != nil { + return err + } + if err := fn(Entry{Name: hdr.Name, Data: buf}); err != nil { + return err + } + } +} diff --git a/internal/pipeline/imgrot.go b/internal/pipeline/imgrot.go new file mode 100644 index 0000000..5cf4ce9 --- /dev/null +++ b/internal/pipeline/imgrot.go @@ -0,0 +1,80 @@ +package pipeline + +import ( + "bytes" + "fmt" + "image" + "image/jpeg" + "image/png" + "strings" + + "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" // decode-only; we re-encode webp as png +) + +// RotateImage rotates src clockwise by angle (must be 0/90/180/270). +func RotateImage(src image.Image, angle int) image.Image { + angle = ((angle % 360) + 360) % 360 + if angle == 0 { + return src + } + b := src.Bounds() + w, h := b.Dx(), b.Dy() + switch angle { + case 180: + dst := image.NewRGBA(image.Rect(0, 0, w, h)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + dst.Set(w-1-x, h-1-y, src.At(b.Min.X+x, b.Min.Y+y)) + } + } + return dst + case 90: + dst := image.NewRGBA(image.Rect(0, 0, h, w)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + dst.Set(h-1-y, x, src.At(b.Min.X+x, b.Min.Y+y)) + } + } + return dst + case 270: + dst := image.NewRGBA(image.Rect(0, 0, h, w)) + for y := 0; y < h; y++ { + for x := 0; x < w; x++ { + dst.Set(y, w-1-x, src.At(b.Min.X+x, b.Min.Y+y)) + } + } + return dst + } + return src +} + +// EncodeImage re-encodes img using format hint from the original filename / detected format. +// Falls back to PNG when format is not directly writable. +func EncodeImage(img image.Image, format string) ([]byte, string, error) { + var buf bytes.Buffer + switch strings.ToLower(format) { + case "jpeg", "jpg": + if err := jpeg.Encode(&buf, img, &jpeg.Options{Quality: 92}); err != nil { + return nil, "", err + } + return buf.Bytes(), "jpg", nil + case "tiff", "tif": + if err := tiff.Encode(&buf, img, &tiff.Options{Compression: tiff.Deflate}); err != nil { + return nil, "", err + } + return buf.Bytes(), "tiff", nil + case "png", "": + if err := png.Encode(&buf, img); err != nil { + return nil, "", err + } + return buf.Bytes(), "png", nil + case "webp": + // Re-encode to PNG (no webp encoder in stdlib/x/image). + if err := png.Encode(&buf, img); err != nil { + return nil, "", err + } + return buf.Bytes(), "png", nil + } + return nil, "", fmt.Errorf("unsupported format: %s", format) +} diff --git a/internal/pipeline/pipeline.go b/internal/pipeline/pipeline.go new file mode 100644 index 0000000..2d6846b --- /dev/null +++ b/internal/pipeline/pipeline.go @@ -0,0 +1,198 @@ +// Package pipeline orchestrates orientation detection and rotation across +// images, PDFs, and archives. +package pipeline + +import ( + "archive/zip" + "bytes" + "fmt" + "image" + _ "image/jpeg" + _ "image/png" + "io" + "path/filepath" + "strings" + "sync" + + _ "golang.org/x/image/tiff" + _ "golang.org/x/image/webp" + + "orientator/internal/detector" + "orientator/internal/extract" + "orientator/internal/rasterize" +) + +// PageReport describes what happened to a single page or image. +type PageReport struct { + File string `json:"file"` + Page int `json:"page,omitempty"` // 0 for plain images + Angle int `json:"angle"` + Confidence float64 `json:"confidence"` +} + +// Result holds the rewritten output and a per-page report. +type Result struct { + OutputName string + OutputBytes []byte + Reports []PageReport +} + +// Process processes a single uploaded file and returns the corrected output. +// For archives the output is a zip of all corrected entries. +func Process(name string, data []byte) (*Result, error) { + det, err := detector.Get() + if err != nil { + return nil, fmt.Errorf("detector init: %w", err) + } + + if extract.IsArchive(name) { + return processArchive(det, name, data) + } + return processSingle(det, name, data) +} + +func processSingle(det *detector.Detector, name string, data []byte) (*Result, error) { + ext := strings.ToLower(filepath.Ext(name)) + switch ext { + case ".pdf": + out, reports, err := processPDF(det, name, data) + if err != nil { + return nil, err + } + return &Result{OutputName: name, OutputBytes: out, Reports: reports}, nil + case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp": + out, outName, report, err := processImage(det, name, data) + if err != nil { + return nil, err + } + return &Result{OutputName: outName, OutputBytes: out, Reports: []PageReport{report}}, nil + } + return nil, fmt.Errorf("unsupported file type: %s", ext) +} + +func processImage(det *detector.Detector, name string, data []byte) ([]byte, string, PageReport, error) { + img, format, err := image.Decode(bytes.NewReader(data)) + if err != nil { + return nil, "", PageReport{}, fmt.Errorf("decode %s: %w", name, err) + } + angle, conf, err := det.Predict(img) + if err != nil { + return nil, "", PageReport{}, err + } + rotated := RotateImage(img, angle) + out, ext, err := EncodeImage(rotated, format) + if err != nil { + return nil, "", PageReport{}, err + } + outName := replaceExt(name, "."+ext) + return out, outName, PageReport{File: name, Angle: angle, Confidence: conf}, nil +} + +func processPDF(det *detector.Detector, name string, data []byte) ([]byte, []PageReport, error) { + pdf, err := rasterize.Open(data) + if err != nil { + return nil, nil, err + } + defer pdf.Close() + + n := pdf.NumPages() + angles := make([]int, n) + reports := make([]PageReport, 0, n) + for i := 0; i < n; i++ { + img, err := pdf.RenderPage(i) + if err != nil { + return nil, nil, err + } + angle, conf, err := det.Predict(img) + if err != nil { + return nil, nil, err + } + angles[i] = angle + reports = append(reports, PageReport{File: name, Page: i + 1, Angle: angle, Confidence: conf}) + } + out, err := rasterize.RotatePDFPages(data, angles) + if err != nil { + return nil, nil, err + } + return out, reports, nil +} + +func processArchive(det *detector.Detector, name string, data []byte) (*Result, error) { + var ( + mu sync.Mutex + buf bytes.Buffer + zw = zip.NewWriter(&buf) + reports []PageReport + ) + + err := extract.Walk(name, data, func(e extract.Entry) error { + // Recurse into nested archives. + if extract.IsArchive(e.Name) { + sub, err := Process(e.Name, e.Data) + if err != nil { + return writeZipFailure(zw, e.Name, err) + } + mu.Lock() + reports = append(reports, sub.Reports...) + mu.Unlock() + return writeZipEntry(zw, sub.OutputName, sub.OutputBytes) + } + + ext := strings.ToLower(filepath.Ext(e.Name)) + switch ext { + case ".pdf": + out, rs, err := processPDF(det, e.Name, e.Data) + if err != nil { + return writeZipFailure(zw, e.Name, err) + } + mu.Lock() + reports = append(reports, rs...) + mu.Unlock() + return writeZipEntry(zw, e.Name, out) + case ".jpg", ".jpeg", ".png", ".tif", ".tiff", ".webp", ".bmp": + out, outName, r, err := processImage(det, e.Name, e.Data) + if err != nil { + return writeZipFailure(zw, e.Name, err) + } + mu.Lock() + reports = append(reports, r) + mu.Unlock() + return writeZipEntry(zw, outName, out) + default: + // Pass-through any non-document files. + return writeZipEntry(zw, e.Name, e.Data) + } + }) + if err != nil { + return nil, err + } + if err := zw.Close(); err != nil { + return nil, err + } + + outName := replaceExt(name, ".zip") + return &Result{OutputName: outName, OutputBytes: buf.Bytes(), Reports: reports}, nil +} + +func writeZipEntry(zw *zip.Writer, name string, data []byte) error { + w, err := zw.Create(name) + if err != nil { + return err + } + _, err = io.Copy(w, bytes.NewReader(data)) + return err +} + +func writeZipFailure(zw *zip.Writer, name string, cause error) error { + w, err := zw.Create(name + ".error.txt") + if err != nil { + return err + } + _, _ = w.Write([]byte(cause.Error())) + return nil +} + +func replaceExt(name, newExt string) string { + old := filepath.Ext(name) + return strings.TrimSuffix(name, old) + newExt +} diff --git a/internal/rasterize/pdf.go b/internal/rasterize/pdf.go new file mode 100644 index 0000000..6c92a09 --- /dev/null +++ b/internal/rasterize/pdf.go @@ -0,0 +1,51 @@ +// Package rasterize converts PDF pages to in-memory images using MuPDF. +package rasterize + +import ( + "fmt" + "image" + + "github.com/gen2brain/go-fitz" +) + +// DPI used when rasterizing pages for the orientation classifier. +// 150 mirrors the Ghostscript -r150 flag used in the original Python pipeline. +const DPI = 150 + +type Page struct { + Index int + Img image.Image +} + +// PDF wraps an open MuPDF document. +type PDF struct { + doc *fitz.Document +} + +func Open(data []byte) (*PDF, error) { + doc, err := fitz.NewFromMemory(data) + if err != nil { + return nil, fmt.Errorf("fitz open: %w", err) + } + return &PDF{doc: doc}, nil +} + +func (p *PDF) Close() error { + if p == nil || p.doc == nil { + return nil + } + return p.doc.Close() +} + +func (p *PDF) NumPages() int { + return p.doc.NumPage() +} + +// RenderPage rasterizes a single page at the configured DPI. +func (p *PDF) RenderPage(idx int) (image.Image, error) { + img, err := p.doc.ImageDPI(idx, DPI) + if err != nil { + return nil, fmt.Errorf("render page %d: %w", idx, err) + } + return img, nil +} diff --git a/internal/rasterize/rotate.go b/internal/rasterize/rotate.go new file mode 100644 index 0000000..dbdc23c --- /dev/null +++ b/internal/rasterize/rotate.go @@ -0,0 +1,51 @@ +package rasterize + +import ( + "bytes" + "fmt" + + "github.com/pdfcpu/pdfcpu/pkg/api" + "github.com/pdfcpu/pdfcpu/pkg/pdfcpu/model" +) + +// RotatePDFPages applies clockwise rotations (degrees, multiples of 90) to each page. +// pageRotations is 1-indexed slice aligned with input pages: pageRotations[0] = page 1. +func RotatePDFPages(in []byte, pageRotations []int) ([]byte, error) { + conf := model.NewDefaultConfiguration() + conf.ValidationMode = model.ValidationRelaxed + + // pdfcpu's Rotate API takes a single rotation and a page selection. We group + // pages by rotation value to minimize passes. + groups := map[int][]int{} + for i, r := range pageRotations { + r = ((r % 360) + 360) % 360 + if r == 0 { + continue + } + groups[r] = append(groups[r], i+1) + } + if len(groups) == 0 { + out := make([]byte, len(in)) + copy(out, in) + return out, nil + } + + current := bytes.NewReader(in) + var buf bytes.Buffer + for rot, pages := range groups { + buf.Reset() + selectedPages := make([]string, len(pages)) + for i, p := range pages { + selectedPages[i] = fmt.Sprintf("%d", p) + } + if err := api.Rotate(current, &buf, rot, selectedPages, conf); err != nil { + return nil, fmt.Errorf("rotate %d°: %w", rot, err) + } + current = bytes.NewReader(append([]byte(nil), buf.Bytes()...)) + } + out := make([]byte, current.Size()) + if _, err := current.ReadAt(out, 0); err != nil { + return nil, err + } + return out, nil +} diff --git a/internal/server/combined.go b/internal/server/combined.go new file mode 100644 index 0000000..e0f935f --- /dev/null +++ b/internal/server/combined.go @@ -0,0 +1,52 @@ +package server + +import ( + "archive/zip" + "bytes" + "io" + + "orientator/internal/pipeline" +) + +type combinedZip struct { + results []*pipeline.Result + errors map[string]error +} + +func (c *combinedZip) add(r *pipeline.Result) { + c.results = append(c.results, r) +} + +func (c *combinedZip) addError(name string, err error) { + if c.errors == nil { + c.errors = map[string]error{} + } + c.errors[name] = err +} + +func (c *combinedZip) finalize() ([]byte, []pipeline.PageReport, error) { + var buf bytes.Buffer + zw := zip.NewWriter(&buf) + var allReports []pipeline.PageReport + for _, r := range c.results { + w, err := zw.Create(r.OutputName) + if err != nil { + return nil, nil, err + } + if _, err := io.Copy(w, bytes.NewReader(r.OutputBytes)); err != nil { + return nil, nil, err + } + allReports = append(allReports, r.Reports...) + } + for name, err := range c.errors { + w, e := zw.Create(name + ".error.txt") + if e != nil { + return nil, nil, e + } + _, _ = w.Write([]byte(err.Error())) + } + if err := zw.Close(); err != nil { + return nil, nil, err + } + return buf.Bytes(), allReports, nil +} diff --git a/internal/server/server.go b/internal/server/server.go new file mode 100644 index 0000000..5758bbc --- /dev/null +++ b/internal/server/server.go @@ -0,0 +1,164 @@ +package server + +import ( + "embed" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "net/http" + "net/url" + "path/filepath" + "strings" + "unicode" + "unicode/utf8" + + "orientator/internal/pipeline" +) + +//go:embed all:web +var webFS embed.FS + +type Server struct { + mux *http.ServeMux +} + +func New() *Server { + s := &Server{mux: http.NewServeMux()} + sub, _ := fs.Sub(webFS, "web") + s.mux.Handle("/", http.FileServer(http.FS(sub))) + s.mux.HandleFunc("/api/process", s.handleProcess) + s.mux.HandleFunc("/api/health", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte("ok")) + }) + return s +} + +func (s *Server) Handler() http.Handler { return s.mux } + +const maxUploadBytes = 2 << 30 // 2 GiB + +func (s *Server) handleProcess(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + r.Body = http.MaxBytesReader(w, r.Body, maxUploadBytes) + if err := r.ParseMultipartForm(64 << 20); err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + files := r.MultipartForm.File["files"] + if len(files) == 0 { + writeErr(w, http.StatusBadRequest, errors.New("no files")) + return + } + + if len(files) == 1 { + fh := files[0] + f, err := fh.Open() + if err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + data, err := io.ReadAll(f) + f.Close() + if err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + res, err := pipeline.Process(fh.Filename, data) + if err != nil { + writeErr(w, http.StatusUnprocessableEntity, err) + return + } + writeFile(w, res.OutputName, res.OutputBytes, res.Reports) + return + } + + // Multiple files: synthesize a tar/zip wrapping name and pack outputs. + combined := &combinedZip{} + for _, fh := range files { + f, err := fh.Open() + if err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + data, err := io.ReadAll(f) + f.Close() + if err != nil { + writeErr(w, http.StatusBadRequest, err) + return + } + res, err := pipeline.Process(fh.Filename, data) + if err != nil { + combined.addError(fh.Filename, err) + continue + } + combined.add(res) + } + out, reports, err := combined.finalize() + if err != nil { + writeErr(w, http.StatusInternalServerError, err) + return + } + writeFile(w, "orientator-output.zip", out, reports) +} + +func writeErr(w http.ResponseWriter, code int, err error) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(code) + _ = json.NewEncoder(w).Encode(map[string]string{"error": err.Error()}) +} + +func writeFile(w http.ResponseWriter, name string, data []byte, reports []pipeline.PageReport) { + rep, _ := json.Marshal(reports) + w.Header().Set("Content-Type", contentTypeFor(name)) + w.Header().Set("Content-Disposition", contentDisposition(name)) + w.Header().Set("X-Orientator-Reports", url.QueryEscape(string(rep))) + w.Header().Set("Content-Length", fmt.Sprintf("%d", len(data))) + w.Write(data) +} + +func contentDisposition(name string) string { + base := filepath.Base(name) + return fmt.Sprintf( + "attachment; filename=%q; filename*=UTF-8''%s", + asciiFilename(base), + url.PathEscape(base), + ) +} + +func asciiFilename(name string) string { + var b strings.Builder + b.Grow(len(name)) + for _, r := range name { + switch { + case r < utf8.RuneSelf && (unicode.IsLetter(r) || unicode.IsDigit(r) || r == '.' || r == '-' || r == '_'): + b.WriteRune(r) + case unicode.IsSpace(r): + b.WriteByte('_') + } + } + if b.Len() == 0 { + return "output" + } + return b.String() +} + +func contentTypeFor(name string) string { + switch strings.ToLower(filepath.Ext(name)) { + case ".pdf": + return "application/pdf" + case ".zip": + return "application/zip" + case ".png": + return "image/png" + case ".jpg", ".jpeg": + return "image/jpeg" + case ".tif", ".tiff": + return "image/tiff" + } + return "application/octet-stream" +} diff --git a/internal/server/web/app.js b/internal/server/web/app.js new file mode 100644 index 0000000..62466c4 --- /dev/null +++ b/internal/server/web/app.js @@ -0,0 +1,353 @@ +const drop = document.getElementById('drop'); +const fileInput = document.getElementById('file'); +const pickBtn = document.getElementById('pick'); +const queue = document.getElementById('queue'); +const processAllBtn = document.getElementById('process-all'); +const clearBtn = document.getElementById('clear'); +const summaryTitle = document.getElementById('summary-title'); +const summaryMeta = document.getElementById('summary-meta'); + +const jobs = []; +let nextId = 1; +let activeXhr = null; + +['dragenter', 'dragover'].forEach(ev => + drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.add('drag'); }) +); +['dragleave', 'drop'].forEach(ev => + drop.addEventListener(ev, e => { e.preventDefault(); drop.classList.remove('drag'); }) +); +drop.addEventListener('drop', e => addFiles(e.dataTransfer.files)); +drop.addEventListener('click', () => fileInput.click()); +drop.addEventListener('keydown', e => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + fileInput.click(); + } +}); +pickBtn.addEventListener('click', e => { e.stopPropagation(); fileInput.click(); }); +fileInput.addEventListener('change', () => { + addFiles(fileInput.files); + fileInput.value = ''; +}); +processAllBtn.addEventListener('click', processPending); +clearBtn.addEventListener('click', clearQueue); + +function addFiles(files) { + for (const file of files) { + jobs.unshift({ + id: nextId++, + file, + state: 'ready', + status: 'в очереди', + progress: 0, + reports: [], + error: '', + download: null, + outputName: '' + }); + } + render(); +} + +function clearQueue() { + if (activeXhr) activeXhr.abort(); + activeXhr = null; + for (const job of jobs) { + if (job.download) URL.revokeObjectURL(job.download); + } + jobs.splice(0, jobs.length); + render(); +} + +function processPending() { + const selected = jobs.filter(j => j.state === 'ready' || j.state === 'error'); + if (!selected.length || activeXhr) return; + processJobs(selected); +} + +function retryJob(id) { + const job = jobs.find(j => j.id === id); + if (!job || activeXhr) return; + processJobs([job]); +} + +function removeJob(id) { + const idx = jobs.findIndex(j => j.id === id); + if (idx < 0 || activeXhr) return; + const [job] = jobs.splice(idx, 1); + if (job.download) URL.revokeObjectURL(job.download); + render(); +} + +function processJobs(selected) { + selected.forEach(job => { + job.state = 'run'; + job.status = 'загрузка'; + job.progress = 4; + job.error = ''; + job.reports = []; + if (job.download) URL.revokeObjectURL(job.download); + job.download = null; + job.outputName = ''; + }); + render(); + + const fd = new FormData(); + selected.forEach(job => fd.append('files', job.file)); + + const xhr = new XMLHttpRequest(); + activeXhr = xhr; + xhr.open('POST', '/api/process'); + xhr.responseType = 'blob'; + xhr.upload.onprogress = e => { + const pct = e.lengthComputable ? Math.min(70, Math.round(e.loaded / e.total * 70)) : 35; + selected.forEach(job => { + job.progress = pct; + job.status = pct >= 70 ? 'обработка' : 'загрузка'; + }); + render(); + }; + xhr.onprogress = () => { + selected.forEach(job => { + job.progress = Math.max(job.progress, 85); + job.status = 'получение'; + }); + render(); + }; + xhr.onload = () => { + activeXhr = null; + if (xhr.status >= 200 && xhr.status < 300) { + handleSuccess(xhr, selected); + } else { + handleFailure(xhr, selected); + } + render(); + }; + xhr.onerror = () => { + activeXhr = null; + selected.forEach(job => markError(job, 'Сеть или сервер оборвали запрос')); + render(); + }; + xhr.onabort = () => { + selected.forEach(job => markError(job, 'Обработка отменена')); + render(); + }; + xhr.send(fd); +} + +function handleSuccess(xhr, selected) { + const blob = xhr.response; + const cd = xhr.getResponseHeader('Content-Disposition') || ''; + const outputName = filenameFromContentDisposition(cd) || (selected.length > 1 ? 'orientator-output.zip' : 'output'); + const reports = parseReports(xhr.getResponseHeader('X-Orientator-Reports')); + + if (selected.length === 1) { + const job = selected[0]; + job.state = 'done'; + job.status = 'готово'; + job.progress = 100; + job.outputName = outputName; + job.download = URL.createObjectURL(blob); + job.reports = reports; + return; + } + + const batchJob = { + id: nextId++, + file: new File([blob], outputName), + kind: 'batch', + sourceCount: selected.length, + state: 'done', + status: 'готов к скачиванию', + progress: 100, + reports, + error: '', + download: URL.createObjectURL(blob), + outputName + }; + jobs.unshift(batchJob); + selected.forEach(job => { + const jobReports = reports.filter(r => r.file === job.file.name); + job.state = 'done'; + job.status = jobReports.length ? 'добавлено в архив' : 'см. архив'; + job.progress = 100; + job.reports = jobReports; + job.error = jobReports.length ? '' : 'По этому файлу нет отчета. Если формат не поддерживается, в итоговом zip будет файл с ошибкой .error.txt.'; + }); +} + +function handleFailure(xhr, selected) { + if (!xhr.response) { + selected.forEach(job => markError(job, 'Сервер вернул ошибку без текста')); + return; + } + const reader = new FileReader(); + reader.onload = () => { + let msg = reader.result || 'Ошибка обработки'; + try { + msg = JSON.parse(reader.result).error || msg; + } catch {} + selected.forEach(job => markError(job, msg)); + render(); + }; + reader.readAsText(xhr.response); +} + +function markError(job, msg) { + job.state = 'error'; + job.status = 'ошибка'; + job.progress = 100; + job.error = msg; +} + +function render() { + updateSummary(); + queue.innerHTML = ''; + if (!jobs.length) { + queue.innerHTML = '
Добавьте документы для обработки.
'; + return; + } + for (const job of jobs) { + queue.appendChild(renderJob(job)); + } +} + +function renderJob(job) { + const item = document.createElement('article'); + item.className = `item ${job.state}`; + + const main = document.createElement('div'); + const name = document.createElement('div'); + name.className = 'name'; + name.textContent = job.kind === 'batch' + ? `Итоговый архив: ${job.outputName || job.file.name}` + : (job.outputName || job.file.name); + const meta = document.createElement('div'); + meta.className = 'meta'; + meta.textContent = job.kind === 'batch' + ? `Скачать один zip с результатами по ${job.sourceCount || 0} файл(ам) · ${fmtBytes(job.file.size)}` + : [fmtBytes(job.file.size), fileKind(job.file.name)].filter(Boolean).join(' · '); + main.append(name, meta); + + const right = document.createElement('div'); + right.className = 'right'; + if (job.download) { + const a = document.createElement('a'); + a.href = job.download; + a.textContent = 'Скачать'; + a.download = job.outputName || job.file.name; + a.className = 'btn'; + right.appendChild(a); + } + if (job.kind !== 'batch' && (job.state === 'ready' || job.state === 'error' || job.state === 'done')) { + right.appendChild(button('Обработать заново', 'btn ghost', () => retryJob(job.id), Boolean(activeXhr))); + } + if (job.state !== 'run') { + right.appendChild(button('Убрать', 'icon-btn', () => removeJob(job.id), Boolean(activeXhr), 'x')); + } + const status = document.createElement('span'); + status.className = 'status'; + status.textContent = job.status; + right.appendChild(status); + + const bar = document.createElement('div'); + bar.className = 'bar'; + const fill = document.createElement('i'); + fill.style.width = `${job.progress}%`; + bar.appendChild(fill); + + item.append(main, right, bar); + + if (job.error) { + const err = document.createElement('div'); + err.className = 'report error-text'; + err.textContent = job.error; + item.appendChild(err); + } else if (job.reports.length) { + item.appendChild(renderReport(job.reports)); + } + return item; +} + +function renderReport(reports) { + const box = document.createElement('div'); + box.className = 'report'; + const changed = reports.filter(r => r.angle && r.angle !== 0).length; + const avg = reports.reduce((sum, r) => sum + (r.confidence || 0), 0) / reports.length; + const files = new Set(reports.map(r => r.file)); + const head = document.createElement('div'); + head.className = 'report-head'; + head.textContent = `${reports.length} стр. · повернуто ${changed} · средняя уверенность ${(avg * 100).toFixed(1)}%`; + const list = document.createElement('pre'); + list.textContent = reports.map(r => { + const page = r.page ? `стр. ${r.page}` : 'файл'; + return `${page}: ${r.angle}° · ${(r.confidence * 100).toFixed(1)}%`; + }).map((line, i) => { + const r = reports[i]; + return files.size > 1 ? `${r.file} · ${line}` : line; + }).join('\n'); + box.append(head, list); + return box; +} + +function updateSummary() { + const total = jobs.length; + const ready = jobs.filter(j => j.state === 'ready' || j.state === 'error').length; + const done = jobs.filter(j => j.state === 'done').length; + const running = jobs.some(j => j.state === 'run'); + const bytes = jobs.reduce((sum, j) => sum + j.file.size, 0); + + summaryTitle.textContent = total ? `${total} файл(ов) · ${fmtBytes(bytes)}` : 'Файлов нет'; + summaryMeta.textContent = total ? `${ready} ожидает · ${done} готово` : 'pdf, png, jpg, tiff, webp, bmp, zip, 7z, rar, tar.gz'; + processAllBtn.disabled = !ready || running; + clearBtn.disabled = !total; + processAllBtn.textContent = running ? 'Идет обработка' : (ready > 1 ? 'Обработать все в zip' : 'Обработать'); +} + +function button(text, className, onClick, disabled, title) { + const b = document.createElement('button'); + b.type = 'button'; + b.className = className; + b.textContent = title || text; + b.title = text; + b.disabled = disabled; + b.addEventListener('click', onClick); + return b; +} + +function fmtBytes(n) { + if (n < 1024) return n + ' B'; + if (n < 1024 * 1024) return (n / 1024).toFixed(1) + ' KB'; + if (n < 1024 * 1024 * 1024) return (n / 1024 / 1024).toFixed(1) + ' MB'; + return (n / 1024 / 1024 / 1024).toFixed(2) + ' GB'; +} + +function fileKind(name) { + const lower = name.toLowerCase(); + if (lower.endsWith('.tar.gz')) return 'tar.gz'; + const i = lower.lastIndexOf('.'); + return i >= 0 ? lower.slice(i + 1) : ''; +} + +function parseReports(header) { + if (!header) return []; + try { + return JSON.parse(decodeURIComponent(header.replace(/\+/g, ' '))); + } catch { + return []; + } +} + +function filenameFromContentDisposition(cd) { + const encoded = cd.match(/filename\*=UTF-8''([^;]+)/i); + if (encoded) { + try { + return decodeURIComponent(encoded[1]); + } catch {} + } + const plain = cd.match(/filename="([^"]+)"|filename=([^;]+)/i); + return plain ? (plain[1] || plain[2]).trim() : ''; +} + +render(); diff --git a/internal/server/web/index.html b/internal/server/web/index.html new file mode 100644 index 0000000..6930f55 --- /dev/null +++ b/internal/server/web/index.html @@ -0,0 +1,51 @@ + + + + + + Orientator + + + +
+
+ + orientator +
+ v1 +
+ +
+
+
+

Автоповорот документов

+

PDF, изображения и архивы обрабатываются моделью постранично.

+
+ +
+ +
+
+
Перетащите файлы сюда
+
или
+
+
+ +
+
+ Файлов нет + pdf, png, jpg, tiff, webp, bmp, zip, 7z, rar, tar.gz +
+
+ + +
+
+ +
+
+
+ + + + diff --git a/internal/server/web/style.css b/internal/server/web/style.css new file mode 100644 index 0000000..14f8190 --- /dev/null +++ b/internal/server/web/style.css @@ -0,0 +1,309 @@ +:root { + --bg: #f5f7fa; + --panel: #ffffff; + --ink: #18202a; + --muted: #667085; + --line: #d8dee8; + --soft: #eef2f7; + --accent: #2563eb; + --accent-hover: #1d4ed8; + --accent-ink: #ffffff; + --ok: #15803d; + --warn: #b45309; + --err: #dc2626; +} + +* { box-sizing: border-box; } +html, body { margin: 0; padding: 0; } +body { + min-height: 100vh; + font-family: -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, sans-serif; + background: var(--bg); + color: var(--ink); + -webkit-font-smoothing: antialiased; + font-size: 15px; + line-height: 1.45; +} + +button, input { font: inherit; } + +.topbar { + height: 56px; + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 28px; + border-bottom: 1px solid var(--line); + background: var(--panel); + position: sticky; + top: 0; + z-index: 10; +} +.brand { display: flex; align-items: center; gap: 10px; font-weight: 700; } +.brand .dot { + width: 12px; + height: 12px; + border-radius: 3px; + background: linear-gradient(135deg, var(--accent), #0f766e); +} +.brand .name { letter-spacing: 0; } +.version { color: var(--muted); font-size: 12px; } + +.container { + width: min(1080px, 100%); + margin: 0 auto; + padding: 28px 24px 64px; +} + +.workspace { + display: grid; + grid-template-columns: minmax(260px, 340px) 1fr; + grid-template-areas: + "intro controls" + "drop queue"; + gap: 18px; + align-items: start; +} + +.intro { grid-area: intro; } +.intro h1 { + font-size: 26px; + letter-spacing: 0; + margin: 0 0 6px; + font-weight: 700; +} +.intro p { color: var(--muted); margin: 0; max-width: 560px; } + +.dropzone { + grid-area: drop; + min-height: 240px; + background: var(--panel); + border: 1.5px dashed #aab4c3; + border-radius: 8px; + padding: 28px 22px; + display: grid; + place-items: center; + text-align: center; + cursor: pointer; + transition: border-color .15s, background .15s, box-shadow .15s; +} +.dropzone:hover, +.dropzone:focus { + border-color: var(--accent); + outline: none; + box-shadow: 0 0 0 3px rgba(37, 99, 235, .12); +} +.dropzone.drag { + border-color: var(--accent); + background: #eef5ff; +} + +.dz-icon { + width: 42px; + height: 42px; + margin: 0 auto 14px; + border-radius: 8px; + background: var(--accent); + position: relative; +} +.dz-icon::before, +.dz-icon::after { + content: ""; + position: absolute; + background: var(--accent-ink); + border-radius: 2px; +} +.dz-icon::before { left: 19px; top: 10px; width: 4px; height: 18px; } +.dz-icon::after { left: 12px; top: 17px; width: 18px; height: 4px; } + +.dz-title { font-weight: 700; font-size: 16px; margin-bottom: 4px; } +.dz-sub { color: var(--muted); font-size: 13px; } +.dz-sub button { + background: none; + border: none; + color: var(--accent); + cursor: pointer; + padding: 0; + text-decoration: underline; + text-underline-offset: 2px; +} + +.controls { + grid-area: controls; + min-height: 72px; + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; +} +.summary { min-width: 0; display: grid; gap: 2px; } +.summary strong { font-size: 15px; } +.summary span { + color: var(--muted); + font-size: 12px; + overflow-wrap: anywhere; +} +.actions { display: flex; gap: 8px; flex-wrap: wrap; justify-content: flex-end; } + +.queue { + grid-area: queue; + display: flex; + flex-direction: column; + gap: 10px; + min-width: 0; +} + +.empty { + border: 1px dashed var(--line); + border-radius: 8px; + color: var(--muted); + min-height: 150px; + display: grid; + place-items: center; + background: rgba(255, 255, 255, .55); +} + +.item { + background: var(--panel); + border: 1px solid var(--line); + border-radius: 8px; + padding: 14px; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + gap: 12px; + align-items: center; +} +.item .name { + font-weight: 650; + overflow-wrap: anywhere; +} +.item .meta { + color: var(--muted); + font-size: 12px; + margin-top: 2px; +} +.item .right { + display: flex; + gap: 8px; + align-items: center; + justify-content: flex-end; + flex-wrap: wrap; +} +.item .status { + min-width: 82px; + text-align: center; + font-size: 12px; + padding: 4px 8px; + border-radius: 999px; + background: var(--soft); + color: var(--muted); +} +.item.done .status { background: #e8f7ed; color: var(--ok); } +.item.error .status { background: #fdecec; color: var(--err); } +.item.run .status { background: #fff7e6; color: var(--warn); } + +.bar { + grid-column: 1 / -1; + height: 5px; + background: var(--soft); + border-radius: 999px; + overflow: hidden; +} +.bar > i { + display: block; + height: 100%; + width: 0; + background: linear-gradient(90deg, var(--accent), #0f766e); + transition: width .2s ease; +} + +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + min-height: 34px; + background: var(--accent); + color: var(--accent-ink); + border: 1px solid var(--accent); + padding: 7px 12px; + border-radius: 7px; + font-size: 13px; + font-weight: 650; + cursor: pointer; + text-decoration: none; + white-space: nowrap; +} +.btn:hover { background: var(--accent-hover); border-color: var(--accent-hover); } +.btn.ghost { + background: transparent; + color: var(--ink); + border-color: var(--line); +} +.btn.ghost:hover { background: var(--soft); border-color: #c7d0dd; } +.btn:disabled { + opacity: .55; + cursor: not-allowed; +} + +.icon-btn { + width: 34px; + height: 34px; + border: 1px solid var(--line); + border-radius: 7px; + background: transparent; + color: var(--muted); + cursor: pointer; + font-size: 18px; + line-height: 1; +} +.icon-btn:hover { background: var(--soft); color: var(--ink); } + +.report { + grid-column: 1 / -1; + font-size: 12px; + color: var(--muted); + background: #f8fafc; + border: 1px solid #e6ebf2; + padding: 10px 12px; + border-radius: 7px; + max-height: 180px; + overflow: auto; +} +.report-head { + color: var(--ink); + font-weight: 650; + margin-bottom: 6px; +} +.report pre { + margin: 0; + white-space: pre-wrap; + overflow-wrap: anywhere; + font-family: ui-monospace, "SF Mono", Menlo, monospace; +} +.error-text { color: var(--err); background: #fff7f7; border-color: #ffd9d9; } + +@media (max-width: 820px) { + .topbar { padding: 0 16px; } + .container { padding: 20px 14px 48px; } + .workspace { + grid-template-columns: 1fr; + grid-template-areas: + "intro" + "drop" + "controls" + "queue"; + } + .dropzone { min-height: 190px; } + .controls { + align-items: stretch; + flex-direction: column; + } + .actions { justify-content: stretch; } + .actions .btn { flex: 1; } + .item { grid-template-columns: 1fr; } + .item .right { justify-content: flex-start; } +}