diff --git a/modules/scan/cmd/scan/escl.go b/modules/scan/cmd/scan/escl.go new file mode 100644 index 00000000..0f7c2a9d --- /dev/null +++ b/modules/scan/cmd/scan/escl.go @@ -0,0 +1,201 @@ +package main + +import ( + "bytes" + "context" + "encoding/xml" + "fmt" + "io" + "net/http" + "net/url" + "strings" + "time" +) + +// eSCL (the AirScan protocol) over HTTP: POST /ScanJobs answers 201 and the job's Location, GET +// /NextDocument answers the image, and a second NextDocument answers 404 once the job is done. + +// maxImage bounds what a scanner may send: a 600 dpi A4 JPEG is a few tens of megabytes. +const maxImage = 256 << 20 + +// Client speaks eSCL to one scanner. +type Client struct { + Base string + HTTP *http.Client +} + +// NewClient is a client for the eSCL base URL, with a timeout that outlasts a slow scan. +func NewClient(base string) *Client { + return &Client{Base: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 3 * time.Minute}} +} + +// Capabilities are the platen's largest scan region, in the 300ths of an inch the region is asked in. +type Capabilities struct { + MaxWidth, MaxHeight int +} + +type capabilitiesXML struct { + MaxWidth int `xml:"Platen>PlatenInputCaps>MaxWidth"` + MaxHeight int `xml:"Platen>PlatenInputCaps>MaxHeight"` +} + +// Status is what /ScannerStatus says. +type Status struct { + State string `xml:"State" json:"state"` + AdfState string `xml:"AdfState" json:"adf-state"` +} + +func (c *Client) get(ctx context.Context, rawURL string) (*http.Response, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil) + if err != nil { + return nil, err + } + return c.HTTP.Do(req) +} + +func readBody(resp *http.Response, limit int64) ([]byte, error) { + defer resp.Body.Close() + return io.ReadAll(io.LimitReader(resp.Body, limit)) +} + +// Capabilities asks the scanner how large its glass is. +func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) { + resp, err := c.get(ctx, c.Base+"/ScannerCapabilities") + if err != nil { + return Capabilities{}, fmt.Errorf("the scanner does not answer: %w", err) + } + body, err := readBody(resp, 1<<20) + if err != nil { + return Capabilities{}, fmt.Errorf("the scanner's capabilities cannot be read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return Capabilities{}, fmt.Errorf("the scanner refused its capabilities: %s", resp.Status) + } + var x capabilitiesXML + if err := xml.Unmarshal(body, &x); err != nil { + return Capabilities{}, fmt.Errorf("the scanner's capabilities are not XML: %w", err) + } + if x.MaxWidth <= 0 || x.MaxHeight <= 0 { + return Capabilities{}, fmt.Errorf("the scanner's capabilities name no platen size") + } + return Capabilities{MaxWidth: x.MaxWidth, MaxHeight: x.MaxHeight}, nil +} + +// Status asks what the scanner is doing. +func (c *Client) Status(ctx context.Context) (Status, error) { + resp, err := c.get(ctx, c.Base+"/ScannerStatus") + if err != nil { + return Status{}, fmt.Errorf("the scanner does not answer: %w", err) + } + body, err := readBody(resp, 1<<20) + if err != nil { + return Status{}, fmt.Errorf("the scanner's status cannot be read: %w", err) + } + if resp.StatusCode != http.StatusOK { + return Status{}, fmt.Errorf("the scanner refused its status: %s", resp.Status) + } + var s Status + if err := xml.Unmarshal(body, &s); err != nil { + return Status{}, fmt.Errorf("the scanner's status is not XML: %w", err) + } + return s, nil +} + +// colorModes maps the config's words to eSCL's. +var colorModes = map[string]string{"color": "RGB24", "gray": "Grayscale8"} + +// ScanSettings is the request body: the Platen, the whole glass, the colour mode and resolution, as a JPEG. +func ScanSettings(c Capabilities, resolution int, colorMode string) (string, error) { + escl, ok := colorModes[colorMode] + if !ok { + return "", fmt.Errorf("color-mode %q is not color or gray", colorMode) + } + return fmt.Sprintf(` + +2.63 +escl:ThreeHundredthsOfInches00%d%d +Platen +%s +%d%d +image/jpeg + +`, c.MaxWidth, c.MaxHeight, escl, resolution, resolution), nil +} + +// Scan puts one job to the scanner and returns the JPEG it sends. The resolution asked is only a request: the +// real one is the image's own density (Density), for the real scanner answered 300 with 200. +func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([]byte, error) { + caps, err := c.Capabilities(ctx) + if err != nil { + return nil, err + } + settings, err := ScanSettings(caps, resolution, colorMode) + if err != nil { + return nil, err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/ScanJobs", bytes.NewReader([]byte(settings))) + if err != nil { + return nil, err + } + req.Header.Set("Content-Type", "text/xml") + resp, err := c.HTTP.Do(req) + if err != nil { + return nil, fmt.Errorf("the scanner does not answer: %w", err) + } + reply, _ := readBody(resp, 1<<20) + if resp.StatusCode != http.StatusCreated { + return nil, fmt.Errorf("the scanner refused the scan job: %s %s", resp.Status, strings.TrimSpace(string(reply))) + } + loc := resp.Header.Get("Location") + if loc == "" { + return nil, fmt.Errorf("the scanner took the scan job but named no Location") + } + base, _ := url.Parse(c.Base + "/") + ref, err := url.Parse(loc) + if err != nil { + return nil, fmt.Errorf("the scan job's Location %q is not a URL: %w", loc, err) + } + doc := strings.TrimRight(base.ResolveReference(ref).String(), "/") + "/NextDocument" + got, err := c.get(ctx, doc) + if err != nil { + return nil, fmt.Errorf("the scanned page cannot be fetched: %w", err) + } + image, err := readBody(got, maxImage) + if err != nil { + return nil, fmt.Errorf("the scanned page cannot be read: %w", err) + } + if got.StatusCode != http.StatusOK { + return nil, fmt.Errorf("the scanner gave no page: %s", got.Status) + } + if ct := got.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") { + return nil, fmt.Errorf("the scanner sent %q, not image/jpeg", ct) + } + return image, nil +} + +// Density is the image's own resolution in dpi, from its JFIF header (units dpi, or dots per cm). +func Density(jpegBytes []byte) (int, error) { + b := jpegBytes + if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 { + return 0, fmt.Errorf("the image is not a JPEG") + } + for i := 2; i+4 <= len(b) && b[i] == 0xFF; { + marker, n := b[i+1], int(b[i+2])<<8|int(b[i+3]) + if marker == 0xE0 && n >= 16 && i+4+12 <= len(b) && string(b[i+4:i+9]) == "JFIF\x00" { + units := b[i+11] + x := int(b[i+12])<<8 | int(b[i+13]) + switch { + case units == 1 && x > 0: + return x, nil + case units == 2 && x > 0: + return int(float64(x)*2.54 + 0.5), nil + } + return 0, fmt.Errorf("the image's JFIF header gives no resolution") + } + if marker == 0xDA || n < 2 { + break + } + i += 2 + n + } + return 0, fmt.Errorf("the image has no JFIF header, so its resolution is unknown") +} diff --git a/modules/scan/cmd/scan/escl_test.go b/modules/scan/cmd/scan/escl_test.go new file mode 100644 index 00000000..1b346f58 --- /dev/null +++ b/modules/scan/cmd/scan/escl_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "context" + "strings" + "testing" +) + +func squeeze(s string) string { return strings.ReplaceAll(s, "\n", "") } + +// 1440/AC1: the captured capabilities, job creation, document and status replies, served by a test server. +func TestScanPostsOnePlatenJobOverTheWholeGlassAndFetchesTheDocumentOnce(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + image, err := NewClient(f.base()).Scan(context.Background(), 300, "color") + if err != nil { + t.Fatal(err) + } + if f.posts != 1 { + t.Fatalf("%d ScanJobs posts, want 1", f.posts) + } + // The body is the request the real scanner accepted (testdata/escl/settings.xml): Platen, 2550x3507 + // 300ths of an inch from the capabilities, RGB24, 300 dpi, image/jpeg. + if got, want := squeeze(f.postBody), squeeze(string(fixture(t, "settings.xml"))); got != want { + t.Fatalf("the job is not the captured request\n got: %s\nwant: %s", got, want) + } + if f.documents != 1 { + t.Fatalf("%d NextDocument requests, want exactly 1", f.documents) + } + if want := fixture(t, "scan.jpg"); string(image) != string(want) { + t.Fatalf("the returned image is %d bytes, want the scanner's %d", len(image), len(want)) + } +} + +// 1440/AC1: the colour mode and resolution asked are the configured ones. +func TestScanAsksForTheConfiguredResolutionAndColourMode(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + if _, err := NewClient(f.base()).Scan(context.Background(), 600, "gray"); err != nil { + t.Fatal(err) + } + for _, want := range []string{"Grayscale8", "600", "600"} { + if !strings.Contains(f.postBody, want) { + t.Errorf("the job lacks %s:\n%s", want, f.postBody) + } + } +} + +// 1440/AC1: a job the scanner refuses is an error that says so, and no page is fetched. +func TestScanSaysWhyAScannerRefusal(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.refuse = 503 + _, err := NewClient(f.base()).Scan(context.Background(), 300, "color") + if err == nil || !strings.Contains(err.Error(), "503") { + t.Fatalf("error = %v, want the refusal's 503", err) + } + if f.documents != 0 { + t.Fatalf("%d documents fetched after a refusal", f.documents) + } +} + +// 1440/AC2: the image's own density, never the request's. +func TestDensityIsTheImagesOwn(t *testing.T) { + dpi, err := Density(fixture(t, "scan.jpg")) + if err != nil { + t.Fatal(err) + } + if dpi != 200 { + t.Fatalf("density = %d dpi, want the captured scan's 200 (the request said 300)", dpi) + } +} + +func TestDensityOfAnImageWithNoneIsAnError(t *testing.T) { + if _, err := Density([]byte{0xFF, 0xD8, 0xFF, 0xDA, 0, 2}); err == nil { + t.Fatal("an image with no JFIF header has a resolution") + } +} + +// 1440/AC8: the captured status replies. +func TestStatusReadsStateAndAdfState(t *testing.T) { + f := newFakeScanner(t, nil) + s, err := NewClient(f.base()).Status(context.Background()) + if err != nil { + t.Fatal(err) + } + if s.State != "Idle" || s.AdfState != "ScannerAdfEmpty" { + t.Fatalf("status = %+v, want Idle / ScannerAdfEmpty", s) + } +} + +// 1440/AC7: which value is unset is said by name. +func TestMissingNamesWhatIsUnset(t *testing.T) { + for _, c := range []struct { + s Settings + want string + }{ + {Settings{Folder: "/x"}, "scanner"}, + {Settings{Scanner: "http://192.0.2.10/eSCL"}, "scan-folder"}, + } { + err := c.s.Missing() + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("Missing(%+v) = %v, want it to name %s", c.s, err, c.want) + } + } + if err := (Settings{Scanner: "a", Folder: "/x"}).Missing(); err != nil { + t.Errorf("both set: %v", err) + } +} diff --git a/modules/scan/cmd/scan/helpers_test.go b/modules/scan/cmd/scan/helpers_test.go new file mode 100644 index 00000000..0b5fff31 --- /dev/null +++ b/modules/scan/cmd/scan/helpers_test.go @@ -0,0 +1,81 @@ +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "sync" + "testing" +) + +// fixture reads a capture of the real scanner's answers (testdata/escl, 2026-10-20). Tests never reach a real scanner. +func fixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", "escl", name)) + if err != nil { + t.Fatal(err) + } + return b +} + +// fakeScanner serves the captured eSCL answers and records what it was asked. +type fakeScanner struct { + *httptest.Server + mu sync.Mutex + image []byte + postBody string + posts int + documents int // NextDocument requests + refuse int // when set, the status ScanJobs answers instead of 201 + noPage bool +} + +func newFakeScanner(t *testing.T, image []byte) *fakeScanner { + t.Helper() + f := &fakeScanner{image: image} + mux := http.NewServeMux() + mux.HandleFunc("/eSCL/ScannerCapabilities", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + w.Write(fixture(t, "capabilities.xml")) + }) + mux.HandleFunc("/eSCL/ScannerStatus", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/xml") + w.Write(fixture(t, "status-idle.xml")) + }) + mux.HandleFunc("/eSCL/ScanJobs", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + if r.Method != http.MethodPost { + http.Error(w, "post only", http.StatusMethodNotAllowed) + return + } + b := make([]byte, 1<<16) + n, _ := r.Body.Read(b) + f.postBody = string(b[:n]) + f.posts++ + if f.refuse != 0 { + http.Error(w, "busy", f.refuse) + return + } + // The captured Location names the real scanner's host; the test's own server stands in for it. + w.Header().Set("Location", f.URL+"/eSCL/ScanJobs/7320ead5") + w.WriteHeader(http.StatusCreated) + }) + mux.HandleFunc("/eSCL/ScanJobs/7320ead5/NextDocument", func(w http.ResponseWriter, r *http.Request) { + f.mu.Lock() + defer f.mu.Unlock() + f.documents++ + if f.documents > 1 || f.noPage { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "image/jpeg") + w.Write(f.image) + }) + f.Server = httptest.NewServer(mux) + t.Cleanup(f.Close) + return f +} + +func (f *fakeScanner) base() string { return f.URL + "/eSCL" } diff --git a/modules/scan/cmd/scan/settings.go b/modules/scan/cmd/scan/settings.go new file mode 100644 index 00000000..3f6ddb8d --- /dev/null +++ b/modules/scan/cmd/scan/settings.go @@ -0,0 +1,76 @@ +// Package main is the scan module's Go tools bundle (novox/hq ADR 0464, issue 1440): it scans the glass of a +// network scanner over eSCL, crops the document, files it as a one-page PDF in a folder and says so on the bus. +// A scan happens when a tool is called; the module runs no process of its own. +package main + +import ( + "encoding/json" + "fmt" + "os" + "strings" +) + +// Settings are the module's five config values, as the mesh writes them into settings.json (ADR 0348). +type Settings struct { + // Scanner is the eSCL base URL, such as http://192.0.2.10/eSCL. + Scanner string + // Folder is the absolute path the PDFs are filed in. It is not a manifest resource: a directory's path + // cannot take a config value (issue 1441), so the code creates it. + Folder string + // Resolution is the dpi asked of the scanner: 100, 200, 300 or 600. + Resolution int + // ColorMode is "color" or "gray". + ColorMode string + // Crop says whether the document is cut out of the platen's white. + Crop bool +} + +// LoadSettings reads settings.json (the file named by SCAN_SETTINGS) and lays the defaults under it. A missing +// or unreadable file is an error that names it; an unset scanner or folder is Missing's to say. +func LoadSettings(path string) (Settings, error) { + s := Settings{Resolution: 300, ColorMode: "color", Crop: true} + if path == "" { + return s, fmt.Errorf("SCAN_SETTINGS names no settings file: the module's config does not reach the code") + } + raw, err := os.ReadFile(path) + if err != nil { + return s, fmt.Errorf("settings cannot be read: %w", err) + } + var in map[string]any + if err := json.Unmarshal(raw, &in); err != nil { + return s, fmt.Errorf("settings are not JSON (%s): %w", path, err) + } + if v, ok := in["scanner"].(string); ok { + s.Scanner = strings.TrimSpace(v) + } + if v, ok := in["scan-folder"].(string); ok { + s.Folder = strings.TrimSpace(v) + } + if v, ok := in["resolution"].(float64); ok && v > 0 { + s.Resolution = int(v) + } + if v, ok := in["color-mode"].(string); ok && v != "" { + s.ColorMode = v + } + if v, ok := in["crop"].(bool); ok { + s.Crop = v + } + return s, nil +} + +// Missing is nil when both required values are set, and otherwise says which is not: no tool writes or +// asks anything before that (1440/AC7). +func (s Settings) Missing() error { + var unset []string + if s.Scanner == "" { + unset = append(unset, "scanner") + } + if s.Folder == "" { + unset = append(unset, "scan-folder") + } + if len(unset) == 0 { + return nil + } + return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the scan module on this node", + strings.Join(unset, " and ")) +} diff --git a/modules/scan/cmd/scan/testdata/escl/capabilities.xml b/modules/scan/cmd/scan/testdata/escl/capabilities.xml new file mode 100644 index 00000000..da671013 --- /dev/null +++ b/modules/scan/cmd/scan/testdata/escl/capabilities.xml @@ -0,0 +1,261 @@ + + +2.93 +Brother MFC-L8390CDW series +E82414G4N581626 +Brother +e3248000-80ce-11db-8000-94ddf83ec78a +http://BRW58CDC9DFC80B.local./net/net/airprint.html +http://BRW58CDC9DFC80B.local./icons/device-icons-128.png + + +mopria-certified-scan +1.4 + + + + +64 +2550 +64 +3507 +2486 +3443 +1 + + + +BlackAndWhite1 +Grayscale8 +RGB24 + + +application/pdf +image/jpeg +application/pdf +image/jpeg + + + + +100 +100 + + +200 +200 + + +300 +300 + + +600 +600 + + + + +CMYK +YCC +sRGB + + +Red +Green +Blue +NTSC +GrayCcd +GrayCcdEmulated + + +Halftone +Threshold + + + + +Document +TextAndGraphic +Photo +Preview + +1200 +2400 +0 +0 +0 +0 +2550 +3507 + + + + +64 +2550 +1740 +4200 +2486 +2460 +1 + + + +BlackAndWhite1 +Grayscale8 +RGB24 + + +application/pdf +image/jpeg +application/pdf +image/jpeg + + + + +100 +100 + + +200 +200 + + +300 +300 + + +600 +600 + + + + +CMYK +YCC +sRGB + + +Red +Green +Blue +NTSC +GrayCcd +GrayCcdEmulated + + +Halftone +Threshold + + + + +Document +TextAndGraphic +Photo +Preview + +1200 +600 +0 +0 +0 +0 +2550 +4200 + + +64 +2550 +1740 +4200 +2486 +2460 +1 + + + +BlackAndWhite1 +Grayscale8 +RGB24 + + +application/pdf +image/jpeg +application/pdf +image/jpeg + + + + +100 +100 + + +200 +200 + + +300 +300 + + +600 +600 + + + + +CMYK +YCC +sRGB + + +Red +Green +Blue +NTSC +GrayCcd +GrayCcdEmulated + + +Halftone +Threshold + + + + +Document +TextAndGraphic +Photo +Preview + +600 +600 +0 +0 +0 +0 +2550 +4200 + +50 + +DetectPaperLoaded +Duplex + + + +1 +3 +2 +1 + + diff --git a/modules/scan/cmd/scan/testdata/escl/doc-headers.txt b/modules/scan/cmd/scan/testdata/escl/doc-headers.txt new file mode 100644 index 00000000..58ba6a22 --- /dev/null +++ b/modules/scan/cmd/scan/testdata/escl/doc-headers.txt @@ -0,0 +1,6 @@ +HTTP/1.1 200 OK +Content-Location: /eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a/doc-1 +Accept-Encoding: identity +Content-Type: image/jpeg +Transfer-Encoding: chunked + diff --git a/modules/scan/cmd/scan/testdata/escl/post-headers.txt b/modules/scan/cmd/scan/testdata/escl/post-headers.txt new file mode 100644 index 00000000..bde86cec --- /dev/null +++ b/modules/scan/cmd/scan/testdata/escl/post-headers.txt @@ -0,0 +1,10 @@ +HTTP/1.1 201 Created +Cache-Control: no-store +Accept-Encoding: identity +X-Frame-Options: DENY +Content-Length: 0 +Content-Type: text/xml +Connection: close +Pragma: no-cache +Location: http://192.168.1.171/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a + diff --git a/modules/scan/cmd/scan/testdata/escl/scan.jpg b/modules/scan/cmd/scan/testdata/escl/scan.jpg new file mode 100644 index 00000000..7ba7149e Binary files /dev/null and b/modules/scan/cmd/scan/testdata/escl/scan.jpg differ diff --git a/modules/scan/cmd/scan/testdata/escl/settings.xml b/modules/scan/cmd/scan/testdata/escl/settings.xml new file mode 100644 index 00000000..ad341752 --- /dev/null +++ b/modules/scan/cmd/scan/testdata/escl/settings.xml @@ -0,0 +1,9 @@ + + +2.63 +escl:ThreeHundredthsOfInches0025503507 +Platen +RGB24 +300300 +image/jpeg + diff --git a/modules/scan/cmd/scan/testdata/escl/status-after.xml b/modules/scan/cmd/scan/testdata/escl/status-after.xml new file mode 100644 index 00000000..ef3ac425 --- /dev/null +++ b/modules/scan/cmd/scan/testdata/escl/status-after.xml @@ -0,0 +1 @@ +2.93IdleScannerAdfEmpty/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78aurn:uuid:7320ead5-ccb0-11f1-8ff9-94ddf83ec78a929107111CompletedJobCompletedSuccessfully \ No newline at end of file diff --git a/modules/scan/cmd/scan/testdata/escl/status-idle.xml b/modules/scan/cmd/scan/testdata/escl/status-idle.xml new file mode 100644 index 00000000..b16eb5d6 --- /dev/null +++ b/modules/scan/cmd/scan/testdata/escl/status-idle.xml @@ -0,0 +1 @@ +2.93IdleScannerAdfEmpty/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78aurn:uuid:7320ead5-ccb0-11f1-8ff9-94ddf83ec78a929219211CompletedJobCompletedSuccessfully \ No newline at end of file diff --git a/modules/scan/go.mod b/modules/scan/go.mod new file mode 100644 index 00000000..86725f92 --- /dev/null +++ b/modules/scan/go.mod @@ -0,0 +1,3 @@ +module scan + +go 1.26.0