Move the scan module to brother, ADR 0467 clause 1: the code unchanged in what it does (issue 1453)

This commit is contained in:
2026-10-20 21:06:24 +02:00
parent 809dd76973
commit 4a892ee29d
27 changed files with 18 additions and 18 deletions
+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)
}
}
@@ -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("BROTHER_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)
},
},
}
}
@@ -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)["BROTHER_SETTINGS"].(string)
}
}
if env != "${dir:state}/settings.json" {
t.Errorf("BROTHER_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 brother.scan.added; it
// travels as mesh.mod.brother.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 brother 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 BROTHER_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("BROTHER_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 brother module on this node",
strings.Join(unset, " and "))
}
@@ -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>
@@ -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
@@ -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

@@ -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>
@@ -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>
@@ -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>