Merge pull request 'A scan module: scan the glass over eSCL, crop the document, file it as a PDF and announce it (issue 1440, ADR 0464)' (#383) from feat/scan-module into main

This commit was merged in pull request #383.
This commit is contained in:
2026-10-20 18:23:43 +00:00
25 changed files with 2186 additions and 0 deletions
+66
View File
@@ -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 <scanner>/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.
+138
View File
@@ -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
}
+125
View File
@@ -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)
}
}
+201
View File
@@ -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 <base>/ScanJobs answers 201 and the job's Location, GET
// <Location>/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(`<?xml version="1.0" encoding="UTF-8"?>
<scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
<pwg:Version>2.63</pwg:Version>
<pwg:ScanRegions><pwg:ScanRegion><pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits><pwg:XOffset>0</pwg:XOffset><pwg:YOffset>0</pwg:YOffset><pwg:Width>%d</pwg:Width><pwg:Height>%d</pwg:Height></pwg:ScanRegion></pwg:ScanRegions>
<pwg:InputSource>Platen</pwg:InputSource>
<scan:ColorMode>%s</scan:ColorMode>
<scan:XResolution>%d</scan:XResolution><scan:YResolution>%d</scan:YResolution>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
</scan:ScanSettings>
`, 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")
}
+106
View File
@@ -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{"<scan:ColorMode>Grayscale8</scan:ColorMode>", "<scan:XResolution>600</scan:XResolution>", "<scan:YResolution>600</scan:YResolution>"} {
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)
}
}
+124
View File
@@ -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
}
+194
View File
@@ -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)
}
}
+81
View File
@@ -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" }
+92
View File
@@ -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)
},
},
}
}
+118
View File
@@ -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)
}
}
}
+51
View File
@@ -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
}
+103
View File
@@ -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
}
+307
View File
@@ -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)
}
}
+76
View File
@@ -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 "))
}
+261
View File
@@ -0,0 +1,261 @@
<?xml version="1.0" encoding="UTF-8"?>
<scan:ScannerCapabilities
xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03"
xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm"
xmlns:dest="http://schemas.hp.com/imaging/destination/2011/06/06"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://schemas.hp.com/imaging/escl/2011/05/03 ../../schemas/eSCL-1_92.xsd">
<pwg:Version>2.93</pwg:Version>
<pwg:MakeAndModel>Brother MFC-L8390CDW series</pwg:MakeAndModel>
<pwg:SerialNumber>E82414G4N581626</pwg:SerialNumber>
<scan:Manufacturer>Brother</scan:Manufacturer>
<scan:UUID>e3248000-80ce-11db-8000-94ddf83ec78a</scan:UUID>
<scan:AdminURI>http://BRW58CDC9DFC80B.local./net/net/airprint.html</scan:AdminURI>
<scan:IconURI>http://BRW58CDC9DFC80B.local./icons/device-icons-128.png</scan:IconURI>
<scan:Certifications>
<scan:Certification>
<scan:Name>mopria-certified-scan</scan:Name>
<scan:Version>1.4</scan:Version>
</scan:Certification>
</scan:Certifications>
<scan:Platen>
<scan:PlatenInputCaps>
<scan:MinWidth>64</scan:MinWidth>
<scan:MaxWidth>2550</scan:MaxWidth>
<scan:MinHeight>64</scan:MinHeight>
<scan:MaxHeight>3507</scan:MaxHeight>
<scan:MaxXOffset>2486</scan:MaxXOffset>
<scan:MaxYOffset>3443</scan:MaxYOffset>
<scan:MaxScanRegions>1</scan:MaxScanRegions>
<scan:SettingProfiles>
<scan:SettingProfile>
<scan:ColorModes>
<scan:ColorMode>BlackAndWhite1</scan:ColorMode>
<scan:ColorMode>Grayscale8</scan:ColorMode>
<scan:ColorMode>RGB24</scan:ColorMode>
</scan:ColorModes>
<scan:DocumentFormats>
<pwg:DocumentFormat>application/pdf</pwg:DocumentFormat>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
<scan:DocumentFormatExt>application/pdf</scan:DocumentFormatExt>
<scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt>
</scan:DocumentFormats>
<scan:SupportedResolutions>
<scan:DiscreteResolutions>
<scan:DiscreteResolution>
<scan:XResolution>100</scan:XResolution>
<scan:YResolution>100</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>200</scan:XResolution>
<scan:YResolution>200</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>300</scan:XResolution>
<scan:YResolution>300</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>600</scan:XResolution>
<scan:YResolution>600</scan:YResolution>
</scan:DiscreteResolution>
</scan:DiscreteResolutions>
</scan:SupportedResolutions>
<scan:ColorSpaces>
<scan:ColorSpace>CMYK</scan:ColorSpace>
<scan:ColorSpace>YCC</scan:ColorSpace>
<scan:ColorSpace>sRGB</scan:ColorSpace>
</scan:ColorSpaces>
<scan:CcdChannels>
<scan:CcdChannel>Red</scan:CcdChannel>
<scan:CcdChannel>Green</scan:CcdChannel>
<scan:CcdChannel>Blue</scan:CcdChannel>
<scan:CcdChannel>NTSC</scan:CcdChannel>
<scan:CcdChannel>GrayCcd</scan:CcdChannel>
<scan:CcdChannel>GrayCcdEmulated</scan:CcdChannel>
</scan:CcdChannels>
<scan:BinaryRenderings>
<scan:BinaryRendering>Halftone</scan:BinaryRendering>
<scan:BinaryRendering>Threshold</scan:BinaryRendering>
</scan:BinaryRenderings>
</scan:SettingProfile>
</scan:SettingProfiles>
<scan:SupportedIntents>
<scan:Intent>Document</scan:Intent>
<scan:Intent>TextAndGraphic</scan:Intent>
<scan:Intent>Photo</scan:Intent>
<scan:Intent>Preview</scan:Intent>
</scan:SupportedIntents>
<scan:MaxOpticalXResolution>1200</scan:MaxOpticalXResolution>
<scan:MaxOpticalYResolution>2400</scan:MaxOpticalYResolution>
<scan:RiskyLeftMargin>0</scan:RiskyLeftMargin>
<scan:RiskyRightMargin>0</scan:RiskyRightMargin>
<scan:RiskyTopMargin>0</scan:RiskyTopMargin>
<scan:RiskyBottomMargin>0</scan:RiskyBottomMargin>
<scan:MaxPhysicalWidth>2550</scan:MaxPhysicalWidth>
<scan:MaxPhysicalHeight>3507</scan:MaxPhysicalHeight>
</scan:PlatenInputCaps>
</scan:Platen>
<scan:Adf>
<scan:AdfSimplexInputCaps>
<scan:MinWidth>64</scan:MinWidth>
<scan:MaxWidth>2550</scan:MaxWidth>
<scan:MinHeight>1740</scan:MinHeight>
<scan:MaxHeight>4200</scan:MaxHeight>
<scan:MaxXOffset>2486</scan:MaxXOffset>
<scan:MaxYOffset>2460</scan:MaxYOffset>
<scan:MaxScanRegions>1</scan:MaxScanRegions>
<scan:SettingProfiles>
<scan:SettingProfile>
<scan:ColorModes>
<scan:ColorMode>BlackAndWhite1</scan:ColorMode>
<scan:ColorMode>Grayscale8</scan:ColorMode>
<scan:ColorMode>RGB24</scan:ColorMode>
</scan:ColorModes>
<scan:DocumentFormats>
<pwg:DocumentFormat>application/pdf</pwg:DocumentFormat>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
<scan:DocumentFormatExt>application/pdf</scan:DocumentFormatExt>
<scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt>
</scan:DocumentFormats>
<scan:SupportedResolutions>
<scan:DiscreteResolutions>
<scan:DiscreteResolution>
<scan:XResolution>100</scan:XResolution>
<scan:YResolution>100</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>200</scan:XResolution>
<scan:YResolution>200</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>300</scan:XResolution>
<scan:YResolution>300</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>600</scan:XResolution>
<scan:YResolution>600</scan:YResolution>
</scan:DiscreteResolution>
</scan:DiscreteResolutions>
</scan:SupportedResolutions>
<scan:ColorSpaces>
<scan:ColorSpace>CMYK</scan:ColorSpace>
<scan:ColorSpace>YCC</scan:ColorSpace>
<scan:ColorSpace>sRGB</scan:ColorSpace>
</scan:ColorSpaces>
<scan:CcdChannels>
<scan:CcdChannel>Red</scan:CcdChannel>
<scan:CcdChannel>Green</scan:CcdChannel>
<scan:CcdChannel>Blue</scan:CcdChannel>
<scan:CcdChannel>NTSC</scan:CcdChannel>
<scan:CcdChannel>GrayCcd</scan:CcdChannel>
<scan:CcdChannel>GrayCcdEmulated</scan:CcdChannel>
</scan:CcdChannels>
<scan:BinaryRenderings>
<scan:BinaryRendering>Halftone</scan:BinaryRendering>
<scan:BinaryRendering>Threshold</scan:BinaryRendering>
</scan:BinaryRenderings>
</scan:SettingProfile>
</scan:SettingProfiles>
<scan:SupportedIntents>
<scan:Intent>Document</scan:Intent>
<scan:Intent>TextAndGraphic</scan:Intent>
<scan:Intent>Photo</scan:Intent>
<scan:Intent>Preview</scan:Intent>
</scan:SupportedIntents>
<scan:MaxOpticalXResolution>1200</scan:MaxOpticalXResolution>
<scan:MaxOpticalYResolution>600</scan:MaxOpticalYResolution>
<scan:RiskyLeftMargin>0</scan:RiskyLeftMargin>
<scan:RiskyRightMargin>0</scan:RiskyRightMargin>
<scan:RiskyTopMargin>0</scan:RiskyTopMargin>
<scan:RiskyBottomMargin>0</scan:RiskyBottomMargin>
<scan:MaxPhysicalWidth>2550</scan:MaxPhysicalWidth>
<scan:MaxPhysicalHeight>4200</scan:MaxPhysicalHeight>
</scan:AdfSimplexInputCaps>
<scan:AdfDuplexInputCaps>
<scan:MinWidth>64</scan:MinWidth>
<scan:MaxWidth>2550</scan:MaxWidth>
<scan:MinHeight>1740</scan:MinHeight>
<scan:MaxHeight>4200</scan:MaxHeight>
<scan:MaxXOffset>2486</scan:MaxXOffset>
<scan:MaxYOffset>2460</scan:MaxYOffset>
<scan:MaxScanRegions>1</scan:MaxScanRegions>
<scan:SettingProfiles>
<scan:SettingProfile>
<scan:ColorModes>
<scan:ColorMode>BlackAndWhite1</scan:ColorMode>
<scan:ColorMode>Grayscale8</scan:ColorMode>
<scan:ColorMode>RGB24</scan:ColorMode>
</scan:ColorModes>
<scan:DocumentFormats>
<pwg:DocumentFormat>application/pdf</pwg:DocumentFormat>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
<scan:DocumentFormatExt>application/pdf</scan:DocumentFormatExt>
<scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt>
</scan:DocumentFormats>
<scan:SupportedResolutions>
<scan:DiscreteResolutions>
<scan:DiscreteResolution>
<scan:XResolution>100</scan:XResolution>
<scan:YResolution>100</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>200</scan:XResolution>
<scan:YResolution>200</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>300</scan:XResolution>
<scan:YResolution>300</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>600</scan:XResolution>
<scan:YResolution>600</scan:YResolution>
</scan:DiscreteResolution>
</scan:DiscreteResolutions>
</scan:SupportedResolutions>
<scan:ColorSpaces>
<scan:ColorSpace>CMYK</scan:ColorSpace>
<scan:ColorSpace>YCC</scan:ColorSpace>
<scan:ColorSpace>sRGB</scan:ColorSpace>
</scan:ColorSpaces>
<scan:CcdChannels>
<scan:CcdChannel>Red</scan:CcdChannel>
<scan:CcdChannel>Green</scan:CcdChannel>
<scan:CcdChannel>Blue</scan:CcdChannel>
<scan:CcdChannel>NTSC</scan:CcdChannel>
<scan:CcdChannel>GrayCcd</scan:CcdChannel>
<scan:CcdChannel>GrayCcdEmulated</scan:CcdChannel>
</scan:CcdChannels>
<scan:BinaryRenderings>
<scan:BinaryRendering>Halftone</scan:BinaryRendering>
<scan:BinaryRendering>Threshold</scan:BinaryRendering>
</scan:BinaryRenderings>
</scan:SettingProfile>
</scan:SettingProfiles>
<scan:SupportedIntents>
<scan:Intent>Document</scan:Intent>
<scan:Intent>TextAndGraphic</scan:Intent>
<scan:Intent>Photo</scan:Intent>
<scan:Intent>Preview</scan:Intent>
</scan:SupportedIntents>
<scan:MaxOpticalXResolution>600</scan:MaxOpticalXResolution>
<scan:MaxOpticalYResolution>600</scan:MaxOpticalYResolution>
<scan:RiskyLeftMargin>0</scan:RiskyLeftMargin>
<scan:RiskyRightMargin>0</scan:RiskyRightMargin>
<scan:RiskyTopMargin>0</scan:RiskyTopMargin>
<scan:RiskyBottomMargin>0</scan:RiskyBottomMargin>
<scan:MaxPhysicalWidth>2550</scan:MaxPhysicalWidth>
<scan:MaxPhysicalHeight>4200</scan:MaxPhysicalHeight>
</scan:AdfDuplexInputCaps>
<scan:FeederCapacity>50</scan:FeederCapacity>
<scan:AdfOptions>
<scan:AdfOption>DetectPaperLoaded</scan:AdfOption>
<scan:AdfOption>Duplex</scan:AdfOption>
</scan:AdfOptions>
</scan:Adf>
<scan:CompressionFactorSupport>
<scan:Min>1</scan:Min>
<scan:Max>3</scan:Max>
<scan:Normal>2</scan:Normal>
<scan:Step>1</scan:Step>
</scan:CompressionFactorSupport>
</scan:ScannerCapabilities>
+6
View File
@@ -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
+10
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
<pwg:Version>2.63</pwg:Version>
<pwg:ScanRegions><pwg:ScanRegion><pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits><pwg:XOffset>0</pwg:XOffset><pwg:YOffset>0</pwg:YOffset><pwg:Width>2550</pwg:Width><pwg:Height>3507</pwg:Height></pwg:ScanRegion></pwg:ScanRegions>
<pwg:InputSource>Platen</pwg:InputSource>
<scan:ColorMode>RGB24</scan:ColorMode>
<scan:XResolution>300</scan:XResolution><scan:YResolution>300</scan:YResolution>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
</scan:ScanSettings>
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><scan:ScannerStatus xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm" xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://schemas.hp.com/imaging/escl/2011/05/03 ../../schemas/eSCL-1_92.xsd"><pwg:Version>2.93</pwg:Version><pwg:State>Idle</pwg:State><scan:AdfState>ScannerAdfEmpty</scan:AdfState><scan:Jobs><scan:JobInfo><pwg:JobUri>/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUri><pwg:JobUuid>urn:uuid:7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUuid><scan:Age>9291071</scan:Age><pwg:ImagesCompleted>1</pwg:ImagesCompleted><pwg:ImagesToTransfer>1</pwg:ImagesToTransfer><pwg:JobState>Completed</pwg:JobState><pwg:JobStateReasons><pwg:JobStateReason>JobCompletedSuccessfully</pwg:JobStateReason></pwg:JobStateReasons></scan:JobInfo></scan:Jobs></scan:ScannerStatus>
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><scan:ScannerStatus xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm" xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://schemas.hp.com/imaging/escl/2011/05/03 ../../schemas/eSCL-1_92.xsd"><pwg:Version>2.93</pwg:Version><pwg:State>Idle</pwg:State><scan:AdfState>ScannerAdfEmpty</scan:AdfState><scan:Jobs><scan:JobInfo><pwg:JobUri>/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUri><pwg:JobUuid>urn:uuid:7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUuid><scan:Age>9292192</scan:Age><pwg:ImagesCompleted>1</pwg:ImagesCompleted><pwg:ImagesToTransfer>1</pwg:ImagesToTransfer><pwg:JobState>Completed</pwg:JobState><pwg:JobStateReasons><pwg:JobStateReason>JobCompletedSuccessfully</pwg:JobStateReason></pwg:JobStateReasons></scan:JobInfo></scan:Jobs></scan:ScannerStatus>
+7
View File
@@ -0,0 +1,7 @@
{
"scanner": "",
"scan-folder": "",
"resolution": 300,
"color-mode": "color",
"crop": true
}
+5
View File
@@ -0,0 +1,5 @@
module scan
go 1.26.0
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
+2
View File
@@ -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=
+102
View File
@@ -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"
}
]
}
}