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:
@@ -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.
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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" }
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 "))
|
||||
}
|
||||
@@ -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
|
||||
|
||||
BIN
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>
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"scanner": "",
|
||||
"scan-folder": "",
|
||||
"resolution": 300,
|
||||
"color-mode": "color",
|
||||
"crop": true
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
module scan
|
||||
|
||||
go 1.26.0
|
||||
|
||||
require git.novox.be/novox/mesh-sdk/go v0.1.14-0.20261020060201-88581d0acaef
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user