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/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..b61a263c --- /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 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(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 +} + +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) + } +} 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/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/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/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/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/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 +} 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/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/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/go.mod b/modules/scan/go.mod new file mode 100644 index 00000000..8affa504 --- /dev/null +++ b/modules/scan/go.mod @@ -0,0 +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= 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" + } + ] + } +}