Initial orientator
This commit is contained in:
3
.gitignore
vendored
Normal file
3
.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
.DS_Store
|
||||
.gocache/
|
||||
.gomodcache/
|
||||
47
cmd/orientator/main.go
Normal file
47
cmd/orientator/main.go
Normal file
@@ -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
|
||||
}
|
||||
41
cmd/testfixture/main.go
Normal file
41
cmd/testfixture/main.go
Normal file
@@ -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 <in.pdf> <upright.png> <rotated90.png>")
|
||||
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)
|
||||
}
|
||||
}
|
||||
BIN
dist/orientator-linux-amd64
vendored
Executable file
BIN
dist/orientator-linux-amd64
vendored
Executable file
Binary file not shown.
36
go.mod
Normal file
36
go.mod
Normal file
@@ -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
|
||||
)
|
||||
67
go.sum
Normal file
67
go.sum
Normal file
@@ -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=
|
||||
2
internal/detector/assets/README.txt
Normal file
2
internal/detector/assets/README.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Place dedoc_orientation.onnx here.
|
||||
ONNX runtime shared libs go in onnxruntime/<goos>-<goarch>/.
|
||||
BIN
internal/detector/assets/dedoc_orientation.onnx
Normal file
BIN
internal/detector/assets/dedoc_orientation.onnx
Normal file
Binary file not shown.
Binary file not shown.
Binary file not shown.
10
internal/detector/assets_darwin_arm64.go
Normal file
10
internal/detector/assets_darwin_arm64.go
Normal file
@@ -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"
|
||||
10
internal/detector/assets_linux_amd64.go
Normal file
10
internal/detector/assets_linux_amd64.go
Normal file
@@ -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"
|
||||
181
internal/detector/detector.go
Normal file
181
internal/detector/detector.go
Normal file
@@ -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
|
||||
}
|
||||
53
internal/detector/preprocess.go
Normal file
53
internal/detector/preprocess.go
Normal file
@@ -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
|
||||
}
|
||||
160
internal/extract/archive.go
Normal file
160
internal/extract/archive.go
Normal file
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
80
internal/pipeline/imgrot.go
Normal file
80
internal/pipeline/imgrot.go
Normal file
@@ -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)
|
||||
}
|
||||
198
internal/pipeline/pipeline.go
Normal file
198
internal/pipeline/pipeline.go
Normal file
@@ -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
|
||||
}
|
||||
51
internal/rasterize/pdf.go
Normal file
51
internal/rasterize/pdf.go
Normal file
@@ -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
|
||||
}
|
||||
51
internal/rasterize/rotate.go
Normal file
51
internal/rasterize/rotate.go
Normal file
@@ -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
|
||||
}
|
||||
52
internal/server/combined.go
Normal file
52
internal/server/combined.go
Normal file
@@ -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
|
||||
}
|
||||
164
internal/server/server.go
Normal file
164
internal/server/server.go
Normal file
@@ -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"
|
||||
}
|
||||
353
internal/server/web/app.js
Normal file
353
internal/server/web/app.js
Normal file
@@ -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 = '<div class="empty">Добавьте документы для обработки.</div>';
|
||||
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();
|
||||
51
internal/server/web/index.html
Normal file
51
internal/server/web/index.html
Normal file
@@ -0,0 +1,51 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1" />
|
||||
<title>Orientator</title>
|
||||
<link rel="stylesheet" href="/style.css" />
|
||||
</head>
|
||||
<body>
|
||||
<header class="topbar">
|
||||
<div class="brand">
|
||||
<span class="dot"></span>
|
||||
<span class="name">orientator</span>
|
||||
</div>
|
||||
<span class="version">v1</span>
|
||||
</header>
|
||||
|
||||
<main class="container">
|
||||
<section class="workspace">
|
||||
<div class="intro">
|
||||
<h1>Автоповорот документов</h1>
|
||||
<p>PDF, изображения и архивы обрабатываются моделью постранично.</p>
|
||||
</div>
|
||||
|
||||
<section id="drop" class="dropzone" tabindex="0">
|
||||
<input id="file" type="file" multiple hidden />
|
||||
<div class="dz-inner">
|
||||
<div class="dz-icon"></div>
|
||||
<div class="dz-title">Перетащите файлы сюда</div>
|
||||
<div class="dz-sub">или <button type="button" id="pick">выберите</button></div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="controls" aria-label="Очередь обработки">
|
||||
<div class="summary">
|
||||
<strong id="summary-title">Файлов нет</strong>
|
||||
<span id="summary-meta">pdf, png, jpg, tiff, webp, bmp, zip, 7z, rar, tar.gz</span>
|
||||
</div>
|
||||
<div class="actions">
|
||||
<button type="button" id="process-all" class="btn" disabled>Обработать все</button>
|
||||
<button type="button" id="clear" class="btn ghost" disabled>Очистить</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section id="queue" class="queue" aria-live="polite"></section>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
<script src="/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
309
internal/server/web/style.css
Normal file
309
internal/server/web/style.css
Normal file
@@ -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; }
|
||||
}
|
||||
Reference in New Issue
Block a user