From 36a34a49b853f995a0072809046123ea30567f2e Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 20:09:47 +0200 Subject: [PATCH 1/6] issue 1440: the scan module's eSCL client, settings and image density --- modules/scan/cmd/scan/escl.go | 201 ++++++++++++++ modules/scan/cmd/scan/escl_test.go | 106 +++++++ modules/scan/cmd/scan/helpers_test.go | 81 ++++++ modules/scan/cmd/scan/settings.go | 76 +++++ .../cmd/scan/testdata/escl/capabilities.xml | 261 ++++++++++++++++++ .../cmd/scan/testdata/escl/doc-headers.txt | 6 + .../cmd/scan/testdata/escl/post-headers.txt | 10 + modules/scan/cmd/scan/testdata/escl/scan.jpg | Bin 0 -> 112908 bytes .../scan/cmd/scan/testdata/escl/settings.xml | 9 + .../cmd/scan/testdata/escl/status-after.xml | 1 + .../cmd/scan/testdata/escl/status-idle.xml | 1 + modules/scan/go.mod | 3 + 12 files changed, 755 insertions(+) create mode 100644 modules/scan/cmd/scan/escl.go create mode 100644 modules/scan/cmd/scan/escl_test.go create mode 100644 modules/scan/cmd/scan/helpers_test.go create mode 100644 modules/scan/cmd/scan/settings.go create mode 100644 modules/scan/cmd/scan/testdata/escl/capabilities.xml create mode 100644 modules/scan/cmd/scan/testdata/escl/doc-headers.txt create mode 100644 modules/scan/cmd/scan/testdata/escl/post-headers.txt create mode 100644 modules/scan/cmd/scan/testdata/escl/scan.jpg create mode 100644 modules/scan/cmd/scan/testdata/escl/settings.xml create mode 100644 modules/scan/cmd/scan/testdata/escl/status-after.xml create mode 100644 modules/scan/cmd/scan/testdata/escl/status-idle.xml create mode 100644 modules/scan/go.mod 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 0000000000000000000000000000000000000000..7ba7149e9a0c083a04bc667f3bca9ffeb0a269b8 GIT binary patch literal 112908 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 GUjHANd@tt! literal 0 HcmV?d00001 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 From cf407f187d2fc1f8e0b568ead78858ef2032ccbd Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 20:10:20 +0200 Subject: [PATCH 2/6] issue 1440: crop the document out of the platen's white --- modules/scan/cmd/scan/crop.go | 138 +++++++++++++++++++++++++++++ modules/scan/cmd/scan/crop_test.go | 125 ++++++++++++++++++++++++++ 2 files changed, 263 insertions(+) create mode 100644 modules/scan/cmd/scan/crop.go create mode 100644 modules/scan/cmd/scan/crop_test.go diff --git a/modules/scan/cmd/scan/crop.go b/modules/scan/cmd/scan/crop.go new file mode 100644 index 00000000..62554ecb --- /dev/null +++ b/modules/scan/cmd/scan/crop.go @@ -0,0 +1,138 @@ +package main + +import ( + "bytes" + "fmt" + "image" + "image/color" + "image/jpeg" +) + +// Cropping cuts the document out of the platen. The scanner sends the whole glass, a document in one corner +// and the lid's white everywhere else. The lid's level is measured where no document can be (the corner +// farthest from the origin), a pixel is document when it is darker than the lid by a margin, and the rows and +// columns holding enough document pixels give the box (ADR 0464). +const ( + // lidBlock is the side of the corner block the lid's level is measured on. + lidBlock = 300 + // darker is how much darker than the lid a pixel is document: paper on a white lid is a level or two + // under it, an edge shadow far more. + darker = 1 + // rowShare is the fraction of a row's (column's) length that must be document for the row (column) to + // count: specks of dust stay under it. + rowShare = 0.05 + // margin is the pixels kept around the box. + margin = 6 + // wholeShare is the fraction of the platen a box may fill before it is no crop at all. + wholeShare = 0.9 + // jpegQuality is the quality the cropped image is encoded with. + jpegQuality = 90 +) + +// gray is the pixel's level, 0 to 255. +func grayAt(img image.Image, x, y int) int { + switch m := img.(type) { + case *image.YCbCr: + return int(m.Y[m.YOffset(x, y)]) + case *image.Gray: + return int(m.Pix[m.PixOffset(x, y)]) + } + return int(color.GrayModel.Convert(img.At(x, y)).(color.Gray).Y) +} + +// Box finds the document's box in the image, and whether there is one: false when no document is found or +// when it fills (almost) the platen. +func Box(img image.Image) (image.Rectangle, bool) { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if w < 2*margin || h < 2*margin { + return b, false + } + side := lidBlock + if side > w/2 { + side = w / 2 + } + if side > h/2 { + side = h / 2 + } + sum := 0 + for y := b.Max.Y - side; y < b.Max.Y; y++ { + for x := b.Max.X - side; x < b.Max.X; x++ { + sum += grayAt(img, x, y) + } + } + lid := int(float64(sum)/float64(side*side) + 0.5) + limit := lid - darker + + cols := make([]int, w) + rows := make([]int, h) + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + if grayAt(img, x, y) < limit { + cols[x-b.Min.X]++ + rows[y-b.Min.Y]++ + } + } + } + x0, x1 := span(cols, int(rowShare*float64(h))) + y0, y1 := span(rows, int(rowShare*float64(w))) + if x0 < 0 || y0 < 0 { + return b, false + } + box := image.Rect(max(0, x0-margin), max(0, y0-margin), min(w, x1+1+margin), min(h, y1+1+margin)).Add(b.Min) + if float64(box.Dx()*box.Dy()) >= wholeShare*float64(w*h) { + return b, false + } + return box, true +} + +// span is the first and last index whose count is over the floor, or -1 when none is. +func span(counts []int, floor int) (int, int) { + first, last := -1, -1 + for i, c := range counts { + if c > floor { + if first < 0 { + first = i + } + last = i + } + } + return first, last +} + +// Cropped is what cropping a scan gave. +type Cropped struct { + // JPEG is the image to file: the scanner's own bytes when kept whole, a re-encoded crop otherwise. + JPEG []byte + Width, Height int + Cropped bool +} + +// Crop cuts the document out of a scanned JPEG when crop is set and a document is found; otherwise the whole +// image is kept, its bytes untouched, and Cropped is false. +func Crop(scan []byte, crop bool) (Cropped, error) { + img, err := jpeg.Decode(bytes.NewReader(scan)) + if err != nil { + return Cropped{}, fmt.Errorf("the scanned image cannot be read: %w", err) + } + b := img.Bounds() + whole := Cropped{JPEG: scan, Width: b.Dx(), Height: b.Dy()} + if !crop { + return whole, nil + } + box, found := Box(img) + if !found { + return whole, nil + } + sub, ok := img.(interface { + SubImage(image.Rectangle) image.Image + }) + if !ok { + return whole, nil + } + var out bytes.Buffer + if err := jpeg.Encode(&out, sub.SubImage(box), &jpeg.Options{Quality: jpegQuality}); err != nil { + return Cropped{}, fmt.Errorf("the cropped image cannot be encoded: %w", err) + } + return Cropped{JPEG: out.Bytes(), Width: box.Dx(), Height: box.Dy(), Cropped: true}, nil +} diff --git a/modules/scan/cmd/scan/crop_test.go b/modules/scan/cmd/scan/crop_test.go new file mode 100644 index 00000000..b3de57cc --- /dev/null +++ b/modules/scan/cmd/scan/crop_test.go @@ -0,0 +1,125 @@ +package main + +import ( + "bytes" + "image" + "image/color" + "image/draw" + "image/jpeg" + "math/rand" + "testing" +) + +// lidWhite is a w x h gray image of the lid's white, with a few specks of dust. +func lidWhite(w, h int, seed int64) *image.Gray { + g := image.NewGray(image.Rect(0, 0, w, h)) + draw.Draw(g, g.Bounds(), &image.Uniform{color.Gray{255}}, image.Point{}, draw.Src) + r := rand.New(rand.NewSource(seed)) + for i := 0; i < 40; i++ { + g.SetGray(r.Intn(w), r.Intn(h), color.Gray{120}) + } + return g +} + +func encode(t *testing.T, img image.Image) []byte { + t.Helper() + var b bytes.Buffer + if err := jpeg.Encode(&b, img, &jpeg.Options{Quality: 95}); err != nil { + t.Fatal(err) + } + return b.Bytes() +} + +// 1440/AC3: the captured receipt, top-left of a 1696x2203 scan at 200 dpi. +func TestCropFindsTheCapturedReceipt(t *testing.T) { + c, err := Crop(fixture(t, "scan.jpg"), true) + if err != nil { + t.Fatal(err) + } + if !c.Cropped { + t.Fatal("cropped = false for a scan with a receipt on it") + } + img, err := jpeg.Decode(bytes.NewReader(fixture(t, "scan.jpg"))) + if err != nil { + t.Fatal(err) + } + box, found := Box(img) + if !found { + t.Fatal("no box found") + } + if box.Min.X > 10 || box.Min.Y > 10 { + t.Errorf("box starts at %v, want within 10 px of (0,0)", box.Min) + } + if box.Max.X < 630 || box.Max.X > 660 { + t.Errorf("right edge %d, want 630..660", box.Max.X) + } + if box.Max.Y < 1730 || box.Max.Y > 1810 { + t.Errorf("bottom %d, want 1730..1810", box.Max.Y) + } + if c.Width != box.Dx() || c.Height != box.Dy() { + t.Errorf("cropped image is %dx%d, the box %dx%d", c.Width, c.Height, box.Dx(), box.Dy()) + } + got, err := jpeg.DecodeConfig(bytes.NewReader(c.JPEG)) + if err != nil || got.Width != box.Dx() || got.Height != box.Dy() { + t.Errorf("the cropped JPEG is %+v (%v), want %dx%d", got, err, box.Dx(), box.Dy()) + } + // About 80-84 mm wide at 200 dpi. + if mm := float64(c.Width) / 200 * 25.4; mm < 80 || mm > 84 { + t.Errorf("%.1f mm wide, want 80-84", mm) + } +} + +// 1440/AC4: a plain lid with specks has no document. +func TestCropKeepsAPlainLidWhole(t *testing.T) { + scan := encode(t, lidWhite(800, 1100, 1)) + c, err := Crop(scan, true) + if err != nil { + t.Fatal(err) + } + if c.Cropped || c.Width != 800 || c.Height != 1100 || !bytes.Equal(c.JPEG, scan) { + t.Fatalf("a plain lid was cropped: %+v", c) + } +} + +// 1440/AC4: a document that fills the platen is no crop either. +func TestCropKeepsAFullPlatenDocumentWhole(t *testing.T) { + g := lidWhite(800, 1100, 2) + draw.Draw(g, image.Rect(0, 0, 790, 1090), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src) + scan := encode(t, g) + c, err := Crop(scan, true) + if err != nil { + t.Fatal(err) + } + if c.Cropped || c.Width != 800 || c.Height != 1100 { + t.Fatalf("a document filling the platen was cropped: %+v", c) + } +} + +// 1440/AC4: crop=false keeps the captured scan whole, though it has a document. +func TestCropOffKeepsTheWholeImage(t *testing.T) { + scan := fixture(t, "scan.jpg") + c, err := Crop(scan, false) + if err != nil { + t.Fatal(err) + } + if c.Cropped || c.Width != 1696 || c.Height != 2203 || !bytes.Equal(c.JPEG, scan) { + t.Fatalf("crop=false cropped: %+v", c) + } +} + +// 1440/AC3: a document away from the origin is found where it is, not assumed in the corner. +func TestCropFindsADocumentInTheMiddleOfTheGlass(t *testing.T) { + g := lidWhite(800, 1100, 3) + draw.Draw(g, image.Rect(200, 300, 500, 700), &image.Uniform{color.Gray{240}}, image.Point{}, draw.Src) + img, err := jpeg.Decode(bytes.NewReader(encode(t, g))) + if err != nil { + t.Fatal(err) + } + box, found := Box(img) + if !found { + t.Fatal("no box") + } + if box.Min.X < 190 || box.Min.X > 205 || box.Max.X < 495 || box.Max.X > 510 || box.Min.Y < 290 || box.Min.Y > 305 || box.Max.Y < 695 || box.Max.Y > 710 { + t.Fatalf("box = %v, want about (200,300)-(500,700)", box) + } +} From 26ae4d2fd82c24cac5987cd28b4b11b7199720b8 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 20:10:29 +0200 Subject: [PATCH 3/6] issue 1440: the plain-lid test's specks lie where counting them would make a box --- modules/scan/cmd/scan/crop_test.go | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/modules/scan/cmd/scan/crop_test.go b/modules/scan/cmd/scan/crop_test.go index b3de57cc..b61a263c 100644 --- a/modules/scan/cmd/scan/crop_test.go +++ b/modules/scan/cmd/scan/crop_test.go @@ -10,13 +10,13 @@ import ( "testing" ) -// lidWhite is a w x h gray image of the lid's white, with a few specks of dust. +// lidWhite is a w x h gray image of the lid's white, with a few specks of dust in one patch. func lidWhite(w, h int, seed int64) *image.Gray { g := image.NewGray(image.Rect(0, 0, w, h)) draw.Draw(g, g.Bounds(), &image.Uniform{color.Gray{255}}, image.Point{}, draw.Src) r := rand.New(rand.NewSource(seed)) for i := 0; i < 40; i++ { - g.SetGray(r.Intn(w), r.Intn(h), color.Gray{120}) + g.SetGray(100+r.Intn(w/4), 100+r.Intn(h/4), color.Gray{120}) // all in one patch, so counting them would make a box } return g } From ae47a6cc10b1a96243c609abbf272cba59a01aec Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 20:11:39 +0200 Subject: [PATCH 4/6] issue 1440: file the scan as a one-page PDF, written whole and renamed, and list the filed scans --- modules/scan/cmd/scan/file.go | 124 ++++++++++++++++++ modules/scan/cmd/scan/file_test.go | 194 +++++++++++++++++++++++++++++ modules/scan/cmd/scan/pdf.go | 51 ++++++++ 3 files changed, 369 insertions(+) create mode 100644 modules/scan/cmd/scan/file.go create mode 100644 modules/scan/cmd/scan/file_test.go create mode 100644 modules/scan/cmd/scan/pdf.go diff --git a/modules/scan/cmd/scan/file.go b/modules/scan/cmd/scan/file.go new file mode 100644 index 00000000..44de023a --- /dev/null +++ b/modules/scan/cmd/scan/file.go @@ -0,0 +1,124 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" +) + +// FileMode is the mode a created scan folder gets and ScanMode the mode of a filed scan. +const ( + FolderMode os.FileMode = 0o755 + ScanMode os.FileMode = 0o644 +) + +// File writes the PDF into the folder as scan-YYYY-MM-DD-HHMMSS.pdf (local time): whole beside its place as +// .scan-*.tmp and then renamed, so nothing ever sees half a scan. A name taken already gets -2, -3, and a file +// that is there is never overwritten. The folder is created when it is not there. Nothing in it that this +// writes is touched but what it makes (1440/AC5, AC11). +func File(folder string, pdf []byte, at time.Time) (string, error) { + if !filepath.IsAbs(folder) { + return "", fmt.Errorf("scan-folder %q is not an absolute path", folder) + } + if _, err := os.Stat(folder); os.IsNotExist(err) { + if err := os.MkdirAll(folder, FolderMode); err != nil { + return "", fmt.Errorf("the scan folder cannot be made: %w", err) + } + // The umask may have narrowed it; a folder this made is 0755 whatever the umask. + if err := os.Chmod(folder, FolderMode); err != nil { + return "", fmt.Errorf("the scan folder cannot be made: %w", err) + } + } + tmp, err := os.CreateTemp(folder, ".scan-*.tmp") + if err != nil { + return "", fmt.Errorf("the scan cannot be written in %s: %w", folder, err) + } + done := false + defer func() { + if !done { + tmp.Close() + os.Remove(tmp.Name()) + } + }() + if _, err := tmp.Write(pdf); err != nil { + return "", fmt.Errorf("the scan cannot be written: %w", err) + } + if err := tmp.Sync(); err != nil { + return "", fmt.Errorf("the scan cannot be written: %w", err) + } + if err := tmp.Close(); err != nil { + return "", fmt.Errorf("the scan cannot be written: %w", err) + } + if err := os.Chmod(tmp.Name(), ScanMode); err != nil { + return "", err + } + stem := "scan-" + at.Local().Format("2006-01-02-150405") + for n := 1; n < 10000; n++ { + name := stem + ".pdf" + if n > 1 { + name = fmt.Sprintf("%s-%d.pdf", stem, n) + } + final := filepath.Join(folder, name) + // The name is taken by creating it exclusively; the finished file is then renamed over that empty + // one, so a scan never replaces a file that was there. + f, err := os.OpenFile(final, os.O_CREATE|os.O_EXCL|os.O_WRONLY, ScanMode) + if os.IsExist(err) { + continue + } + if err != nil { + return "", fmt.Errorf("the scan cannot be named in %s: %w", folder, err) + } + f.Close() + if err := os.Rename(tmp.Name(), final); err != nil { + os.Remove(final) + return "", fmt.Errorf("the scan cannot be put in place: %w", err) + } + done = true + return final, nil + } + return "", fmt.Errorf("no free scan name for %s in %s", stem, folder) +} + +// Entry is one filed scan, as scan_list says it. +type Entry struct { + Path string `json:"path"` + Bytes int64 `json:"bytes"` + Modified time.Time `json:"modified"` +} + +// List is the newest PDFs named scan-*.pdf in the folder, newest first, at most limit. A folder that is not +// there yet is an empty list. +func List(folder string, limit int) ([]Entry, error) { + entries, err := os.ReadDir(folder) + if os.IsNotExist(err) { + return []Entry{}, nil + } + if err != nil { + return nil, fmt.Errorf("the scan folder cannot be read: %w", err) + } + out := []Entry{} + for _, e := range entries { + n := e.Name() + if e.IsDir() || !strings.HasPrefix(n, "scan-") || !strings.HasSuffix(n, ".pdf") { + continue + } + info, err := e.Info() + if err != nil || !info.Mode().IsRegular() { + continue + } + out = append(out, Entry{Path: filepath.Join(folder, n), Bytes: info.Size(), Modified: info.ModTime()}) + } + sort.SliceStable(out, func(i, j int) bool { + if !out[i].Modified.Equal(out[j].Modified) { + return out[i].Modified.After(out[j].Modified) + } + return out[i].Path > out[j].Path + }) + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} diff --git a/modules/scan/cmd/scan/file_test.go b/modules/scan/cmd/scan/file_test.go new file mode 100644 index 00000000..c92a8ca2 --- /dev/null +++ b/modules/scan/cmd/scan/file_test.go @@ -0,0 +1,194 @@ +package main + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "testing" + "time" +) + +var scanName = regexp.MustCompile(`^scan-\d{4}-\d{2}-\d{2}-\d{6}(-\d+)?\.pdf$`) + +// 1440/AC5: a valid one-page PDF holding the JPEG as DCTDecode, at the image's real size. +func TestPDFHoldsTheJPEGAtItsRealSize(t *testing.T) { + jpg := fixture(t, "scan.jpg") + pdf, err := PDF(jpg, 200) + if err != nil { + t.Fatal(err) + } + s := string(pdf) + if !strings.HasPrefix(s, "%PDF-1.4") || !strings.HasSuffix(s, "%%EOF\n") { + t.Fatalf("not a PDF: starts %q ends %q", s[:8], s[len(s)-6:]) + } + if !bytes.Contains(pdf, jpg) { + t.Error("the JPEG is not embedded as it is") + } + for _, want := range []string{"/Filter /DCTDecode", "/Width 1696", "/Height 2203", "/ColorSpace /DeviceRGB", "/Count 1"} { + if !strings.Contains(s, want) { + t.Errorf("the PDF lacks %s", want) + } + } + // 1696 x 2203 px at 200 dpi is 610.56 x 793.08 pt, never the 300 dpi that was asked. + if !strings.Contains(s, "/MediaBox [0 0 610.56 793.08]") { + t.Errorf("page size wrong:\n%s", regexp.MustCompile(`/MediaBox[^\]]*\]`).FindString(s)) + } + if n := strings.Count(s, "/Type /Page "); n != 1 { + t.Errorf("%d pages", n) + } + // Every xref entry points at its object. + at := strings.LastIndex(s, "startxref\n") + xref, _ := strconv.Atoi(strings.Fields(s[at+len("startxref\n"):])[0]) + lines := strings.Split(s[xref:], "\n") + for i := 3; i <= 7 && i < len(lines); i++ { + off, err := strconv.Atoi(lines[i][:10]) + if err != nil || !strings.HasPrefix(s[off:], fmt.Sprintf("%d 0 obj", i-2)) { + t.Errorf("xref entry %d (%q) does not point at object %d", i-2, lines[i], i-2) + } + } +} + +// 1440/AC2: the page size follows the density it is given. +func TestPDFPageSizeFollowsTheDensity(t *testing.T) { + jpg := fixture(t, "scan.jpg") + a, _ := PDF(jpg, 200) + b, _ := PDF(jpg, 300) + if bytes.Equal(a, b) { + t.Fatal("200 and 300 dpi give the same page") + } + if !strings.Contains(string(b), "/MediaBox [0 0 407.04 528.72]") { + t.Errorf("300 dpi page wrong") + } + if _, err := PDF(jpg, 0); err == nil { + t.Error("0 dpi accepted") + } +} + +// 1440/AC5: exactly one file, named by local time, no temporary left, its bytes the PDF. +func TestFileWritesOneNamedFileAndLeavesNoTemporary(t *testing.T) { + folder := t.TempDir() + at := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local) + path, err := File(folder, []byte("%PDF-x"), at) + if err != nil { + t.Fatal(err) + } + if want := filepath.Join(folder, "scan-2026-10-20-190509.pdf"); path != want { + t.Fatalf("path = %s, want %s", path, want) + } + entries, _ := os.ReadDir(folder) + if len(entries) != 1 || !scanName.MatchString(entries[0].Name()) { + t.Fatalf("folder holds %v, want the one scan", entries) + } + if got, _ := os.ReadFile(path); string(got) != "%PDF-x" { + t.Fatalf("file holds %q", got) + } +} + +// 1440/AC5: a name taken gets -2, -3; what was there is never overwritten. +func TestFileNeverOverwritesAndSuffixes(t *testing.T) { + folder := t.TempDir() + at := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local) + first, _ := File(folder, []byte("one"), at) + second, err := File(folder, []byte("two"), at) + if err != nil { + t.Fatal(err) + } + third, _ := File(folder, []byte("three"), at) + for p, want := range map[string]string{ + first: "one", second: "two", third: "three", + } { + if got, _ := os.ReadFile(p); string(got) != want { + t.Errorf("%s holds %q, want %q", p, got, want) + } + } + if filepath.Base(second) != "scan-2026-10-20-190509-2.pdf" || filepath.Base(third) != "scan-2026-10-20-190509-3.pdf" { + t.Errorf("names %s and %s", filepath.Base(second), filepath.Base(third)) + } +} + +// 1440/AC11: a folder that is not there is made, 0755, and the scan is filed in it. +func TestFileMakesAMissingFolder(t *testing.T) { + folder := filepath.Join(t.TempDir(), "a", "scans") + path, err := File(folder, []byte("x"), time.Now()) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(folder) + if err != nil || info.Mode().Perm() != 0o755 { + t.Fatalf("folder: %v, mode %v, want 0755", err, info) + } + if _, err := os.Stat(path); err != nil { + t.Fatal(err) + } +} + +// 1440/AC11: a file in the folder that the module did not write is left as it was by every tool. +func TestFileLeavesForeignFilesAlone(t *testing.T) { + folder := t.TempDir() + old := time.Date(2020, 1, 2, 3, 4, 5, 0, time.UTC) + foreign := map[string]string{"notes.txt": "mine", ".scan-theirs.tmp": "half", "scan-2020-01-01-000000.pdf": "someone else's"} + for n, c := range foreign { + p := filepath.Join(folder, n) + os.WriteFile(p, []byte(c), 0o600) + os.Chtimes(p, old, old) + } + if _, err := File(folder, []byte("x"), time.Now()); err != nil { + t.Fatal(err) + } + if _, err := List(folder, 10); err != nil { + t.Fatal(err) + } + for n, c := range foreign { + p := filepath.Join(folder, n) + got, err := os.ReadFile(p) + info, _ := os.Stat(p) + if err != nil || string(got) != c || !info.ModTime().Equal(old) || info.Mode().Perm() != 0o600 { + t.Errorf("%s changed: %q %v", n, got, err) + } + } +} + +// 1440/AC7: a relative folder writes nothing. +func TestFileRefusesARelativeFolder(t *testing.T) { + dir := t.TempDir() + t.Chdir(dir) + if _, err := File("scans", []byte("x"), time.Now()); err == nil { + t.Fatal("a relative folder was accepted") + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Fatalf("wrote %v", entries) + } +} + +// 1440/AC8: scan_list: newest first, only scan PDFs, limited. +func TestListIsNewestFirstAndLimited(t *testing.T) { + folder := t.TempDir() + base := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + for i, n := range []string{"scan-a.pdf", "scan-b.pdf", "scan-c.pdf", "other.pdf", "scan-d.txt"} { + p := filepath.Join(folder, n) + os.WriteFile(p, []byte("x"), 0o644) + at := base.Add(time.Duration(i) * time.Hour) + os.Chtimes(p, at, at) + } + got, err := List(folder, 20) + if err != nil { + t.Fatal(err) + } + names := []string{} + for _, e := range got { + names = append(names, filepath.Base(e.Path)) + } + if strings.Join(names, ",") != "scan-c.pdf,scan-b.pdf,scan-a.pdf" { + t.Fatalf("list = %v", names) + } + if got, _ = List(folder, 2); len(got) != 2 || filepath.Base(got[0].Path) != "scan-c.pdf" { + t.Fatalf("limit 2 = %v", got) + } + if got, err = List(filepath.Join(folder, "none"), 5); err != nil || len(got) != 0 { + t.Fatalf("missing folder = %v, %v", got, err) + } +} diff --git a/modules/scan/cmd/scan/pdf.go b/modules/scan/cmd/scan/pdf.go new file mode 100644 index 00000000..f394d924 --- /dev/null +++ b/modules/scan/cmd/scan/pdf.go @@ -0,0 +1,51 @@ +package main + +import ( + "bytes" + "fmt" + "image/color" + "image/jpeg" +) + +// PDF writes a one-page PDF holding the JPEG as it is (DCTDecode), the page as large as the image really is: +// pixels over dpi, in points (72 to the inch). +func PDF(jpegBytes []byte, dpi int) ([]byte, error) { + if dpi <= 0 { + return nil, fmt.Errorf("resolution %d dpi is no resolution", dpi) + } + cfg, err := jpeg.DecodeConfig(bytes.NewReader(jpegBytes)) + if err != nil { + return nil, fmt.Errorf("the image cannot be read: %w", err) + } + space := "DeviceRGB" + if cfg.ColorModel == color.GrayModel { + space = "DeviceGray" + } + wpt := float64(cfg.Width) / float64(dpi) * 72 + hpt := float64(cfg.Height) / float64(dpi) * 72 + content := fmt.Sprintf("q %.2f 0 0 %.2f 0 0 cm /Im0 Do Q\n", wpt, hpt) + + var b bytes.Buffer + var offsets []int + obj := func(body string) { + offsets = append(offsets, b.Len()) + fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", len(offsets), body) + } + b.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n") + obj("<< /Type /Catalog /Pages 2 0 R >>") + obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") + obj(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>", wpt, hpt)) + offsets = append(offsets, b.Len()) + fmt.Fprintf(&b, "4 0 obj\n<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /%s /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", + cfg.Width, cfg.Height, space, len(jpegBytes)) + b.Write(jpegBytes) + b.WriteString("\nendstream\nendobj\n") + obj(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content)) + xref := b.Len() + fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1) + for _, o := range offsets { + fmt.Fprintf(&b, "%010d 00000 n \n", o) + } + fmt.Fprintf(&b, "trailer\n<< /Size %d /Root 1 0 R >>\nstartxref\n%d\n%%%%EOF\n", len(offsets)+1, xref) + return b.Bytes(), nil +} From 27709b07e74165ad1a984ca8de4816bfa918dc8c Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 20:13:02 +0200 Subject: [PATCH 5/6] issue 1440: scan_now, scan_status and scan_list, and scan.added said after the rename --- modules/scan/cmd/scan/main.go | 92 ++++++++ modules/scan/cmd/scan/scan.go | 103 +++++++++ modules/scan/cmd/scan/service_test.go | 307 ++++++++++++++++++++++++++ modules/scan/go.mod | 2 + modules/scan/go.sum | 2 + 5 files changed, 506 insertions(+) create mode 100644 modules/scan/cmd/scan/main.go create mode 100644 modules/scan/cmd/scan/scan.go create mode 100644 modules/scan/cmd/scan/service_test.go create mode 100644 modules/scan/go.sum diff --git a/modules/scan/cmd/scan/main.go b/modules/scan/cmd/scan/main.go new file mode 100644 index 00000000..de13cfa4 --- /dev/null +++ b/modules/scan/cmd/scan/main.go @@ -0,0 +1,92 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +func main() { + if err := stdio.Serve("", tools(os.Getenv("SCAN_SETTINGS"), emit, time.Now)); err != nil { + fmt.Fprintln(os.Stderr, err) + os.Exit(1) + } +} + +// emit says scan.added on the bus, under the file's sha256 as its message id. +func emit(body map[string]any, id string) error { + return stdio.Emit(EventAdded, body, stdio.EmitOptions{MessageID: id}) +} + +// tools are the three tools. The settings are read at each call, so a changed config value is the next +// call's. +func tools(settingsFile string, say Emitter, now func() time.Time) []stdio.Tool { + service := func() (*Service, error) { + s, err := LoadSettings(settingsFile) + if err != nil { + return nil, err + } + return &Service{Settings: s, Node: os.Getenv("MESH_NODE"), Now: now, Emit: say}, nil + } + return []stdio.Tool{ + { + Name: "scan_now", + Description: "Scan the glass of the configured network scanner, cut the document out of the platen's white, " + + "file it as a one-page PDF in the scan folder and say scan.added on the bus. Answers the event's body: " + + "the file's path, its size and sha256, its real size in millimetres, the resolution the scanner " + + "really gave, whether it was cropped. The image itself is never sent.", + Run: func(map[string]any) (any, error) { + s, err := service() + if err != nil { + return nil, err + } + return s.ScanNow(context.Background()) + }, + }, + { + Name: "scan_status", + Description: "Whether the configured scanner answers, and what it is doing: its state (Idle or Processing) " + + "and its document feeder's state. A scanner that does not answer is said as answers false with why. Read-only.", + Run: func(map[string]any) (any, error) { + s, err := service() + if err != nil { + return nil, err + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + return s.ScanStatus(ctx) + }, + }, + { + Name: "scan_list", + Description: "The newest scans filed in the scan folder, newest first, with their size and time. Read-only.", + Input: map[string]any{ + "type": "object", + "properties": map[string]any{ + "limit": map[string]any{"type": "integer", "description": "at most this many scans (default 20)"}, + }, + }, + Run: func(args map[string]any) (any, error) { + limit := 20 + if v, ok := args["limit"]; ok { + f, isNum := v.(float64) + if i, isInt := v.(int); isInt { + f, isNum = float64(i), true + } + if !isNum || f < 1 || f > 1000 || f != float64(int(f)) { + return nil, fmt.Errorf("limit is a whole number from 1 to 1000") + } + limit = int(f) + } + s, err := service() + if err != nil { + return nil, err + } + return s.ScanList(limit) + }, + }, + } +} diff --git a/modules/scan/cmd/scan/scan.go b/modules/scan/cmd/scan/scan.go new file mode 100644 index 00000000..87c9002a --- /dev/null +++ b/modules/scan/cmd/scan/scan.go @@ -0,0 +1,103 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "fmt" + "math" + "time" +) + +// EventAdded is the module's event: a scan was filed (ADR 0464). Consumers declare scan.scan.added; it +// travels as mesh.mod.scan.event.scan.added. It names the file and never carries the image. +const EventAdded = "scan.added" + +// Emitter says an event on the bus under a message id, so a repeated publish is kept to one (ADR 0436). +type Emitter func(body map[string]any, messageID string) error + +// Service is what the tools do, over one reading of the settings. +type Service struct { + Settings Settings + // Node is this machine's name, for the event. + Node string + Now func() time.Time + Emit Emitter +} + +func mm(pixels, dpi int) float64 { + return math.Round(float64(pixels)/float64(dpi)*25.4*10) / 10 +} + +// ScanNow scans the glass, crops, files and says so. Nothing is written and nothing is said when the scanner +// does not give a page; the error says why. The event follows the rename, once (1440/AC6). +func (s *Service) ScanNow(ctx context.Context) (map[string]any, error) { + if err := s.Settings.Missing(); err != nil { + return nil, err + } + at := s.Now() + scan, err := NewClient(s.Settings.Scanner).Scan(ctx, s.Settings.Resolution, s.Settings.ColorMode) + if err != nil { + return nil, err + } + // The resolution is the image's own, never the one asked (1440/AC2). + dpi, err := Density(scan) + if err != nil { + return nil, err + } + cut, err := Crop(scan, s.Settings.Crop) + if err != nil { + return nil, err + } + pdf, err := PDF(cut.JPEG, dpi) + if err != nil { + return nil, err + } + path, err := File(s.Settings.Folder, pdf, at) + if err != nil { + return nil, err + } + sum := sha256.Sum256(pdf) + id := hex.EncodeToString(sum[:]) + body := map[string]any{ + "path": path, + "node": s.Node, + "bytes": len(pdf), + "sha256": id, + "width-mm": mm(cut.Width, dpi), + "height-mm": mm(cut.Height, dpi), + "resolution": dpi, + "color-mode": s.Settings.ColorMode, + "cropped": cut.Cropped, + "scanned-at": at.UTC().Format(time.RFC3339), + } + if err := s.Emit(body, id); err != nil { + return nil, fmt.Errorf("the scan is filed at %s, but %s could not be said on the bus: %w", path, EventAdded, err) + } + return body, nil +} + +// ScanStatus says whether the scanner answers, and what it is doing. A scanner that does not answer is an +// answer, not an error: that is how the module's health is read. +func (s *Service) ScanStatus(ctx context.Context) (map[string]any, error) { + if err := s.Settings.Missing(); err != nil { + return nil, err + } + st, err := NewClient(s.Settings.Scanner).Status(ctx) + if err != nil { + return map[string]any{"answers": false, "why": err.Error()}, nil + } + return map[string]any{"answers": true, "state": st.State, "adf-state": st.AdfState}, nil +} + +// ScanList is the newest filed scans, newest first. +func (s *Service) ScanList(limit int) (map[string]any, error) { + if err := s.Settings.Missing(); err != nil { + return nil, err + } + scans, err := List(s.Settings.Folder, limit) + if err != nil { + return nil, err + } + return map[string]any{"folder": s.Settings.Folder, "scans": scans}, nil +} diff --git a/modules/scan/cmd/scan/service_test.go b/modules/scan/cmd/scan/service_test.go new file mode 100644 index 00000000..845b892a --- /dev/null +++ b/modules/scan/cmd/scan/service_test.go @@ -0,0 +1,307 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "image/jpeg" + "os" + "path/filepath" + "strings" + "testing" + "time" + + stdio "git.novox.be/novox/mesh-sdk/go" +) + +type said struct { + body map[string]any + id string + // seen is whether the filed scan was there when it was said. + seen bool +} + +func service(t *testing.T, f *fakeScanner, s Settings) (*Service, *[]said) { + t.Helper() + var out []said + if s.Scanner == "" { + s.Scanner = f.base() + } + if s.Folder == "" { + s.Folder = filepath.Join(t.TempDir(), "scans") + } + if s.Resolution == 0 { + s.Resolution = 300 + } + if s.ColorMode == "" { + s.ColorMode = "color" + } + return &Service{Settings: s, Node: "shanks", Now: func() time.Time { return time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local) }, + Emit: func(body map[string]any, id string) error { + _, err := os.Stat(body["path"].(string)) + out = append(out, said{body, id, err == nil}) + return nil + }}, &out +} + +// 1440/AC2: asked 300 dpi, the scanner gave 200: the event's resolution and millimetres, and the PDF's page, +// are the image's own. +func TestTheFiledScanIsAtTheImagesOwnResolution(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + svc, _ := service(t, f, Settings{Crop: false, Resolution: 300}) + body, err := svc.ScanNow(context.Background()) + if err != nil { + t.Fatal(err) + } + if body["resolution"] != 200 { + t.Errorf("resolution = %v, want the image's 200 (300 was asked)", body["resolution"]) + } + if body["width-mm"] != 215.4 || body["height-mm"] != 279.8 { + t.Errorf("size = %v x %v mm, want 215.4 x 279.8 (1696 x 2203 px at 200 dpi)", body["width-mm"], body["height-mm"]) + } + pdf, _ := os.ReadFile(body["path"].(string)) + if !strings.Contains(string(pdf), "/MediaBox [0 0 610.56 793.08]") { + t.Errorf("the page is not 200 dpi's size") + } +} + +// 1440/AC6: one scan.added, after the rename, with every key and never the image, under the sha256. +func TestScanNowEmitsOneEventAfterTheRenameUnderTheSha256(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + svc, out := service(t, f, Settings{Crop: true}) + body, err := svc.ScanNow(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(*out) != 1 { + t.Fatalf("%d events, want 1", len(*out)) + } + ev := (*out)[0] + if !ev.seen { + t.Error("the event was said before the file was in place") + } + for _, k := range []string{"path", "node", "bytes", "sha256", "width-mm", "height-mm", "resolution", "color-mode", "cropped", "scanned-at"} { + if _, ok := ev.body[k]; !ok { + t.Errorf("the event lacks %s", k) + } + } + if len(ev.body) != 10 { + t.Errorf("the event has %d keys, want the ten named", len(ev.body)) + } + pdf, err := os.ReadFile(ev.body["path"].(string)) + if err != nil { + t.Fatal(err) + } + sum := sha256.Sum256(pdf) + if ev.body["sha256"] != hex.EncodeToString(sum[:]) || ev.id != ev.body["sha256"] { + t.Errorf("sha256 %v / message id %v, want the file's %x", ev.body["sha256"], ev.id, sum) + } + if ev.body["bytes"] != len(pdf) || ev.body["node"] != "shanks" || ev.body["cropped"] != true || ev.body["color-mode"] != "color" { + t.Errorf("body = %v", ev.body) + } + if want := time.Date(2026, 10, 20, 19, 5, 9, 0, time.Local).UTC().Format(time.RFC3339); ev.body["scanned-at"] != want { + t.Errorf("scanned-at = %v, want %v", ev.body["scanned-at"], want) + } + if bytes.Contains(pdf, []byte("scanned-at")) || len(body) != 10 { + t.Error("the body holds more than the ten keys") + } + // The cropped scan is the receipt, about 80-84 mm wide (1440/AC3). + if w := ev.body["width-mm"].(float64); w < 80 || w > 84 { + t.Errorf("width = %v mm", w) + } + img, _ := os.ReadDir(filepath.Dir(ev.body["path"].(string))) + if len(img) != 1 { + t.Errorf("folder holds %d files, want the one scan", len(img)) + } +} + +// 1440/AC6: a scanner that refuses writes nothing, says nothing, and the error says why. +func TestAScannerRefusalWritesAndSaysNothing(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.refuse = 503 + svc, out := service(t, f, Settings{}) + _, err := svc.ScanNow(context.Background()) + if err == nil || !strings.Contains(err.Error(), "503") { + t.Fatalf("error = %v, want the refusal said", err) + } + if len(*out) != 0 { + t.Errorf("%d events after a refusal", len(*out)) + } + if _, err := os.Stat(svc.Settings.Folder); !os.IsNotExist(err) { + t.Errorf("the folder exists after a refusal: %v", err) + } +} + +// 1440/AC6: a job that gives no page (the second NextDocument's 404) is the same. +func TestAJobThatGivesNoPageWritesAndSaysNothing(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.noPage = true + svc, out := service(t, f, Settings{}) + if _, err := svc.ScanNow(context.Background()); err == nil || !strings.Contains(err.Error(), "404") { + t.Fatalf("error = %v", err) + } + if len(*out) != 0 { + t.Errorf("%d events", len(*out)) + } + if _, err := os.Stat(svc.Settings.Folder); !os.IsNotExist(err) { + t.Errorf("the folder exists: %v", err) + } +} + +// 1440/AC6: an image that is no JPEG (a scanner that sent something else) files nothing. +func TestAnImageThatIsNotAJPEGFilesNothing(t *testing.T) { + f := newFakeScanner(t, []byte("not an image")) + svc, out := service(t, f, Settings{}) + if _, err := svc.ScanNow(context.Background()); err == nil { + t.Fatal("a non-image was filed") + } + if len(*out) != 0 { + t.Errorf("%d events", len(*out)) + } + if _, err := os.Stat(svc.Settings.Folder); !os.IsNotExist(err) { + t.Errorf("the folder exists: %v", err) + } +} + +// 1440/AC11: the first scan into a folder that is not there makes it. +func TestTheFirstScanMakesTheFolder(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + svc, _ := service(t, f, Settings{}) + if _, err := svc.ScanNow(context.Background()); err != nil { + t.Fatal(err) + } + info, err := os.Stat(svc.Settings.Folder) + if err != nil || info.Mode().Perm() != 0o755 { + t.Fatalf("folder %v %v", info, err) + } +} + +func settingsFile(t *testing.T, json string) string { + t.Helper() + p := filepath.Join(t.TempDir(), "settings.json") + if err := os.WriteFile(p, []byte(json), 0o600); err != nil { + t.Fatal(err) + } + return p +} + +func tool(t *testing.T, tl []stdio.Tool, name string) stdio.Tool { + t.Helper() + for _, x := range tl { + if x.Name == name { + return x + } + } + t.Fatalf("no tool %s", name) + return stdio.Tool{} +} + +// 1440/AC7: scanner or folder unset: every tool says which, and nothing is written or asked. +func TestEveryToolSaysWhichValueIsUnsetAndWritesNothing(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + for _, c := range []struct{ json, want string }{ + {`{"scanner":"` + "%s" + `","scan-folder":""}`, "scan-folder"}, + {`{"scanner":"","scan-folder":"FOLDER"}`, "scanner"}, + {`{}`, "scanner and scan-folder"}, + } { + folder := filepath.Join(t.TempDir(), "scans") + js := strings.ReplaceAll(strings.Replace(c.json, "%s", f.base(), 1), "FOLDER", folder) + var out []said + tl := tools(settingsFile(t, js), func(b map[string]any, id string) error { out = append(out, said{b, id, true}); return nil }, time.Now) + for _, name := range []string{"scan_now", "scan_status", "scan_list"} { + _, err := tool(t, tl, name).Run(map[string]any{}) + if err == nil || !strings.Contains(err.Error(), c.want) { + t.Errorf("%s with %s: error = %v, want it to name %q", name, c.json, err, c.want) + } + } + if f.posts != 0 || f.documents != 0 || len(out) != 0 { + t.Errorf("the scanner was asked or an event said: %d posts, %d documents, %d events", f.posts, f.documents, len(out)) + } + if _, err := os.Stat(folder); !os.IsNotExist(err) { + t.Errorf("the folder was made: %v", err) + } + } +} + +// 1440/AC7: the settings file is not there at all. +func TestAMissingSettingsFileIsSaid(t *testing.T) { + tl := tools(filepath.Join(t.TempDir(), "none.json"), nil, time.Now) + if _, err := tool(t, tl, "scan_status").Run(nil); err == nil || !strings.Contains(err.Error(), "settings") { + t.Fatalf("error = %v", err) + } +} + +// 1440/AC8: scan_status from the captured idle status; a scanner that does not answer is answers false. +func TestScanStatusSaysStateAndAdfState(t *testing.T) { + f := newFakeScanner(t, nil) + svc, _ := service(t, f, Settings{}) + got, err := svc.ScanStatus(context.Background()) + if err != nil { + t.Fatal(err) + } + if got["answers"] != true || got["state"] != "Idle" || got["adf-state"] != "ScannerAdfEmpty" { + t.Fatalf("status = %v", got) + } + f.Close() + got, err = svc.ScanStatus(context.Background()) + if err != nil || got["answers"] != false || got["why"] == "" { + t.Fatalf("a scanner that is gone: %v, %v", got, err) + } +} + +// 1440/AC8: scan_list through the tool, newest first. +func TestScanListToolListsTheNewestFirst(t *testing.T) { + folder := t.TempDir() + base := time.Date(2026, 10, 1, 12, 0, 0, 0, time.UTC) + for i, n := range []string{"scan-1.pdf", "scan-2.pdf"} { + p := filepath.Join(folder, n) + os.WriteFile(p, []byte("x"), 0o644) + os.Chtimes(p, base.Add(time.Duration(i)*time.Hour), base.Add(time.Duration(i)*time.Hour)) + } + tl := tools(settingsFile(t, `{"scanner":"http://192.0.2.10/eSCL","scan-folder":"`+folder+`"}`), nil, time.Now) + got, err := tool(t, tl, "scan_list").Run(map[string]any{"limit": float64(5)}) + if err != nil { + t.Fatal(err) + } + scans := got.(map[string]any)["scans"].([]Entry) + if len(scans) != 2 || filepath.Base(scans[0].Path) != "scan-2.pdf" { + t.Fatalf("scans = %v", scans) + } + if _, err := tool(t, tl, "scan_list").Run(map[string]any{"limit": float64(0)}); err == nil { + t.Error("limit 0 accepted") + } +} + +// 1440/AC1: the whole path through the tool, settings read from the file, with the real defaults. +func TestScanNowToolScansWithTheFileSettings(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + folder := filepath.Join(t.TempDir(), "scans") + var out []said + tl := tools(settingsFile(t, `{"scanner":"`+f.base()+`","scan-folder":"`+folder+`","resolution":300,"color-mode":"color","crop":true}`), + func(b map[string]any, id string) error { out = append(out, said{b, id, true}); return nil }, time.Now) + got, err := tool(t, tl, "scan_now").Run(map[string]any{}) + if err != nil { + t.Fatal(err) + } + if len(out) != 1 || got.(map[string]any)["cropped"] != true { + t.Fatalf("%d events, body %v", len(out), got) + } + if _, err := jpeg.DecodeConfig(bytes.NewReader(fixture(t, "scan.jpg"))); err != nil { + t.Fatal(err) + } +} + +func TestLoadSettingsLaysTheDefaultsUnder(t *testing.T) { + s, err := LoadSettings(settingsFile(t, `{"scanner":" http://192.0.2.10/eSCL ","scan-folder":"/srv/scans"}`)) + if err != nil { + t.Fatal(err) + } + if s.Scanner != "http://192.0.2.10/eSCL" || s.Folder != "/srv/scans" || s.Resolution != 300 || s.ColorMode != "color" || !s.Crop { + t.Fatalf("settings = %+v", s) + } + s, _ = LoadSettings(settingsFile(t, `{"resolution":600,"color-mode":"gray","crop":false}`)) + if s.Resolution != 600 || s.ColorMode != "gray" || s.Crop { + t.Fatalf("settings = %+v", s) + } +} diff --git a/modules/scan/go.mod b/modules/scan/go.mod index 86725f92..8affa504 100644 --- a/modules/scan/go.mod +++ b/modules/scan/go.mod @@ -1,3 +1,5 @@ module scan go 1.26.0 + +require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef diff --git a/modules/scan/go.sum b/modules/scan/go.sum new file mode 100644 index 00000000..8a563497 --- /dev/null +++ b/modules/scan/go.sum @@ -0,0 +1,2 @@ +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef h1:xy3wuGWJpqMAXYdXolocYG6w9xnWqBltVUffoaziIKo= +git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef/go.mod h1:ouERNpZizzwZ0HLEtAypsY9FR20FyUgUYRni6J7kVCY= From 0821718d7ca94e61a0994160ec250c2835d27283 Mon Sep 17 00:00:00 2001 From: jochen Date: Tue, 20 Oct 2026 20:13:58 +0200 Subject: [PATCH 6/6] issue 1440: the scan module's manifest, settings template and README --- modules/scan/README.md | 66 ++++++++++++++ modules/scan/cmd/scan/manifest_test.go | 118 +++++++++++++++++++++++++ modules/scan/files/settings.json | 7 ++ modules/scan/module.json | 102 +++++++++++++++++++++ 4 files changed, 293 insertions(+) create mode 100644 modules/scan/README.md create mode 100644 modules/scan/cmd/scan/manifest_test.go create mode 100644 modules/scan/files/settings.json create mode 100644 modules/scan/module.json diff --git a/modules/scan/README.md b/modules/scan/README.md new file mode 100644 index 00000000..0b339f15 --- /dev/null +++ b/modules/scan/README.md @@ -0,0 +1,66 @@ +# scan — scan a document, crop it, file it, say so + +Built for issue 1440 as novox/hq ADR 0464 decides. It scans the glass of a network scanner (any scanner that +speaks eSCL, also called AirScan or Mopria), cuts the document out of the lid's white, files it as a one-page PDF +in a folder and says `scan.added` on the bus, so other modules can react. A scan happens when a tool is called: +the module runs no process of its own, and it runs as the node's operator account. + +## What a scan does + +1. Asks the scanner for its glass size and puts one job to it (`POST /ScanJobs`): the platen, the whole + glass, at the configured resolution and colour mode, as a JPEG; it fetches the page once. +2. Reads the resolution from the image's own JFIF density, never from the request: a scanner may answer a + 300 dpi request with 200 dpi, and the page size and the event's millimetres follow what it gave. +3. Crops: the lid's level is measured in the corner farthest from where documents lie, a pixel is document when + it is darker than the lid, and the rows and columns holding enough of them give the box. No document, or one + filling nearly the whole glass, keeps the whole image (`cropped` false), and so does `crop` false. +4. Files a one-page PDF holding the JPEG: written whole beside its place as `.scan-*.tmp` and renamed to + `scan-YYYY-MM-DD-HHMMSS.pdf` (local time); a taken name gets `-2`, `-3`, and no file is ever overwritten. +5. Says `scan.added` once, after the rename, under the file's sha256 as its message id. It never carries the image. + +A scanner that refuses or gives no page files nothing and says nothing on the bus; the tool's error says why. + +## Config + +| key | meaning | default | +|---|---|---| +| `scanner` | the scanner's eSCL address, such as `http://192.0.2.10/eSCL` (required, trusted) | none | +| `scan-folder` | the absolute path the PDFs are filed in (required, trusted) | none | +| `resolution` | dpi asked: 100, 200, 300 or 600 | 300 | +| `color-mode` | `color` or `gray` | `color` | +| `crop` | cut the document out of the lid's white | true | + +The values reach the code in one file, `settings.json` in the module's state directory, which the mesh writes +from the config; the code reads it at every call. With `scanner` or `scan-folder` unset, every tool says which and +writes and asks nothing. + +The scan folder is not a manifest resource: a directory's path cannot take a config value (issue 1441), so the code +makes it (mode 0755) when it files the first scan. + +## Tools + +| tool | does | +|---|---| +| `scan_now` | scans, crops, files and emits; answers the event's body | +| `scan_status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on | +| `scan_list` | the newest filed scans, newest first (`limit`, default 20). Read-only | + +## The event + +`scan.added`, on the wire `mesh.mod.scan.event.scan.added`; a consumer declares `scan.scan.added`. Its body: +`path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it), +`color-mode`, `cropped`, `scanned-at` (RFC 3339, UTC). + +## Files it does not know + +Each directory or archive this module declares says how it treats a file it did not write (ADR 0422, ADR 0416). + +- `${dir:state}`: everything in it is the program's; the mesh moves nothing out (ADR 0422 §2). +- The scan folder (`scan-folder`) is declared by no manifest. The module writes only the `scan-*.pdf` files it + files and their `.scan-*.tmp` while they are written; it never changes, moves or removes any other file there, + and `scan_list` only reads. + +## Tests + +The unit tests use the captured answers of a real scanner (`cmd/scan/testdata/escl`, captured 2026-10-20) served +by a test HTTP server; no test reaches a scanner. diff --git a/modules/scan/cmd/scan/manifest_test.go b/modules/scan/cmd/scan/manifest_test.go new file mode 100644 index 00000000..c250e66d --- /dev/null +++ b/modules/scan/cmd/scan/manifest_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +type manifest struct { + Emits []string `json:"emits"` + Tools []string `json:"tools"` + Config map[string]map[string]any `json:"config"` + Resources []map[string]any `json:"resources"` + Build struct { + Artifacts []map[string]any `json:"artifacts"` + } `json:"build"` +} + +func readManifest(t *testing.T) manifest { + t.Helper() + raw, err := os.ReadFile(filepath.Join("..", "..", "module.json")) + if err != nil { + t.Fatal(err) + } + var m manifest + if err := json.Unmarshal(raw, &m); err != nil { + t.Fatalf("module.json: %v", err) + } + return m +} + +// 1440/AC9: the scan folder is no manifest resource, because a directory's path cannot take a config value +// (issue 1441, ADR 0464 clause 4): no resource's path names it, and no directory but the state one is declared. +func TestTheScanFolderIsNoManifestResource(t *testing.T) { + m := readManifest(t) + for _, r := range m.Resources { + if p, _ := r["path"].(string); strings.Contains(p, "${config:") || strings.Contains(p, "${setting:") { + t.Errorf("resource %v puts a config value in a path, which composition does not fill", r["id"]) + } + if r["type"] == "directory" && r["id"] != "state" { + t.Errorf("directory %v: the code makes the scan folder", r["id"]) + } + } +} + +// 1440/AC9: the event the code emits is the one the manifest declares, and the tools it serves are named. +func TestTheManifestDeclaresWhatTheCodeServes(t *testing.T) { + m := readManifest(t) + if !reflect.DeepEqual(m.Emits, []string{EventAdded}) { + t.Errorf("emits = %v, want [%s]", m.Emits, EventAdded) + } + var served []string + for _, tl := range tools("", nil, nil) { + served = append(served, tl.Name) + } + if !reflect.DeepEqual(m.Tools, served) { + t.Errorf("manifest tools = %v, the bundle serves %v", m.Tools, served) + } +} + +// 1440/AC9: the config reaches the code as the one file, under the name the code reads, and every key the +// code reads is declared, taken by the file and in its template, with the default the code lays under it. +func TestTheConfigReachesTheCodeThroughItsSettingsFile(t *testing.T) { + m := readManifest(t) + keys := []string{"scanner", "scan-folder", "resolution", "color-mode", "crop"} + for _, k := range keys { + if _, ok := m.Config[k]; !ok { + t.Errorf("config does not declare %s", k) + } + } + if len(m.Config) != len(keys) { + t.Errorf("config declares %d keys, the code reads %d", len(m.Config), len(keys)) + } + var taken []any + for _, r := range m.Resources { + if r["id"] == "settings" { + taken, _ = r["takes-config"].([]any) + } + } + if len(taken) != len(keys) { + t.Errorf("the settings file takes %v", taken) + } + env := "" + for _, a := range m.Build.Artifacts { + if a["kind"] == "bundle" { + env, _ = a["env"].(map[string]any)["SCAN_SETTINGS"].(string) + } + } + if env != "${dir:state}/settings.json" { + t.Errorf("SCAN_SETTINGS = %q", env) + } + raw, err := os.ReadFile(filepath.Join("..", "..", "files", "settings.json")) + if err != nil { + t.Fatal(err) + } + var tpl map[string]any + if err := json.Unmarshal(raw, &tpl); err != nil { + t.Fatal(err) + } + // What the template and the manifest default say is what LoadSettings falls back to. + d, err := LoadSettings(settingsFile(t, string(raw))) + if err != nil { + t.Fatal(err) + } + for k, want := range map[string]any{"resolution": float64(d.Resolution), "color-mode": d.ColorMode, "crop": d.Crop} { + if m.Config[k]["default"] != want || tpl[k] != want { + t.Errorf("%s: manifest default %v, template %v, code %v", k, m.Config[k]["default"], tpl[k], want) + } + } + for _, k := range []string{"scanner", "scan-folder"} { + if m.Config[k]["required"] != true || m.Config[k]["trusted"] != true { + t.Errorf("%s is the operator's: required and trusted", k) + } + } +} diff --git a/modules/scan/files/settings.json b/modules/scan/files/settings.json new file mode 100644 index 00000000..5bf936f4 --- /dev/null +++ b/modules/scan/files/settings.json @@ -0,0 +1,7 @@ +{ + "scanner": "", + "scan-folder": "", + "resolution": 300, + "color-mode": "color", + "crop": true +} diff --git a/modules/scan/module.json b/modules/scan/module.json new file mode 100644 index 00000000..e5f3bba5 --- /dev/null +++ b/modules/scan/module.json @@ -0,0 +1,102 @@ +{ + "module": "scan", + "version": "1", + "emits": [ + "scan.added" + ], + "config": { + "scanner": { + "type": "string", + "description": "The network scanner's eSCL address, such as http://192.0.2.10/eSCL: the scanner's own address and the path its eSCL service answers on; the operator's, with no default.", + "pattern": "^https?://.+", + "trusted": true, + "required": true + }, + "scan-folder": { + "type": "string", + "description": "The absolute path of the folder the scans are filed in as PDF files; the module makes it when it files the first scan, and never touches a file in it that it did not write. The operator's, with no default.", + "pattern": "^/", + "trusted": true, + "required": true + }, + "resolution": { + "type": "number", + "description": "The resolution asked of the scanner, in dpi: 100, 200, 300 or 600. A scanner may give another; the scan is filed at the resolution it gave.", + "enum": [ + 100, + 200, + 300, + 600 + ], + "default": 300 + }, + "color-mode": { + "type": "string", + "description": "Whether the scan is in colour or in grays: color or gray.", + "enum": [ + "color", + "gray" + ], + "default": "color" + }, + "crop": { + "type": "boolean", + "description": "Whether the document is cut out of the platen's white lid; false files the whole platen.", + "default": true + } + }, + "tools": [ + "scan_now", + "scan_status", + "scan_list" + ], + "resources": [ + { + "id": "state", + "type": "directory", + "mode": "0700", + "owner": "${machine:account}", + "place": ".", + "unknown": "program" + }, + { + "id": "settings", + "type": "file", + "path": "${dir:state}/settings.json", + "owner": "${machine:account}", + "mode": "0600", + "takes-config": [ + "scanner", + "scan-folder", + "resolution", + "color-mode", + "crop" + ], + "merge": "json", + "template": "files/settings.json" + } + ], + "build": { + "artifacts": [ + { + "name": "tools", + "kind": "bundle", + "language": "go", + "system": "arch", + "from": "cmd/scan", + "binary": "scan", + "loads": [ + "scan" + ], + "env": { + "SCAN_SETTINGS": "${dir:state}/settings.json" + } + }, + { + "name": "files", + "kind": "archive", + "from": "files" + } + ] + } +}