diff --git a/modules/brother/README.md b/modules/brother/README.md index aaa732e5..32e26e07 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -25,6 +25,14 @@ the module runs no process of its own, and it runs as the node's operator accoun 3. Crops the glass page (a feeder page is a whole sheet and is never cropped): 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. + Several documents lying apart on the glass are each cut out and filed as a one-page PDF of its own (own + object, own `scan.added`, own file); the answer then lists them as `documents` (each with its `path`) and + `count`, in reading order, and one that could not be kept or filed is named in `failed` without holding back + the others. The rule (`Boxes` in `crop.go`): the picture is cut into blocks of 1 mm; a block is document when a + quarter of its pixels are darker than the lid; blocks not at least three across (dust, JPEG ringing) are dropped; + blocks within 10 mm of each other are one region, so a white band narrower than that does not split a document; + a region under 20 mm on a side is a stain, not a document; regions whose boxes overlap are one document (a frame + and the text inside it). One document, or none, is cropped exactly as before. 4. Files a PDF holding the JPEGs, one page for the glass and one per image for the feeder (so six for three sheets both sides), each page as large as its image really is: 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. diff --git a/modules/brother/cmd/brother/capture.go b/modules/brother/cmd/brother/capture.go index c917874c..2bafe221 100644 --- a/modules/brother/cmd/brother/capture.go +++ b/modules/brother/cmd/brother/capture.go @@ -51,15 +51,34 @@ func ParseSource(source string, duplex bool) (ScanRequest, error) { return ScanRequest{}, fmt.Errorf("source %q is not glass or feeder", source) } -// Capture scans the glass or the feeder and builds the PDF. The glass page is cropped when the settings say so; a -// feeder page is a whole sheet and never cropped. Nothing is kept and nothing is said here; an error says why -// the scanner gave no page. The resolution is the image's own, never the one asked (1440/AC2). -func (s *Service) Capture(ctx context.Context, req ScanRequest) (Capture, error) { +// Capture scans the glass or the feeder and builds the PDFs: one for a feeder job (every page of it), and for the +// glass one for each separate document lying on the platen when crop is set (issue 1528), in reading order. A glass +// page is cropped when the settings say so; a feeder page is a whole sheet and never cropped. Nothing is kept and +// nothing is said here; an error says why the scanner gave no page. The resolution is the image's own, never the +// one asked (1440/AC2). +func (s *Service) Capture(ctx context.Context, req ScanRequest) ([]Capture, error) { at := s.Now() scans, err := NewClient(s.Settings.Scanner).ScanPages(ctx, ScanOptions{Resolution: s.Settings.Resolution, ColorMode: s.Settings.ColorMode, Feeder: req.Feeder, Duplex: req.Duplex}) if err != nil { - return Capture{}, err + return nil, err + } + if !req.Feeder { + dpi, err := Density(scans[0]) + if err != nil { + return nil, fmt.Errorf("page 1: %w", err) + } + cuts, err := CropAll(scans[0], s.Settings.Crop, dpi) + if err != nil { + return nil, err + } + out := make([]Capture, len(cuts)) + for i, cut := range cuts { + if out[i], err = captureOf([]PDFPage{{JPEG: cut.JPEG, DPI: dpi}}, cut, dpi, at); err != nil { + return nil, err + } + } + return out, nil } pages := make([]PDFPage, len(scans)) var first Cropped @@ -67,25 +86,26 @@ func (s *Service) Capture(ctx context.Context, req ScanRequest) (Capture, error) for i, scan := range scans { dpi, err := Density(scan) if err != nil { - return Capture{}, fmt.Errorf("page %d: %w", i+1, err) + return nil, fmt.Errorf("page %d: %w", i+1, err) } - cut := Cropped{JPEG: scan} - if !req.Feeder { - if cut, err = Crop(scan, s.Settings.Crop); err != nil { - return Capture{}, err - } - } else { - cfg, err := jpeg.DecodeConfig(bytes.NewReader(scan)) - if err != nil { - return Capture{}, fmt.Errorf("page %d: the scanned image cannot be read: %w", i+1, err) - } - cut.Width, cut.Height = cfg.Width, cfg.Height + cfg, err := jpeg.DecodeConfig(bytes.NewReader(scan)) + if err != nil { + return nil, fmt.Errorf("page %d: the scanned image cannot be read: %w", i+1, err) } - pages[i] = PDFPage{JPEG: cut.JPEG, DPI: dpi} + pages[i] = PDFPage{JPEG: scan, DPI: dpi} if i == 0 { - first, firstDPI = cut, dpi + first, firstDPI = Cropped{JPEG: scan, Width: cfg.Width, Height: cfg.Height}, dpi } } + c, err := captureOf(pages, first, firstDPI, at) + if err != nil { + return nil, err + } + return []Capture{c}, nil +} + +// captureOf is the PDF of the pages, with what the event says of it from the first page. +func captureOf(pages []PDFPage, first Cropped, firstDPI int, at time.Time) (Capture, error) { pdf, err := PDFPages(pages) if err != nil { return Capture{}, err diff --git a/modules/brother/cmd/brother/crop.go b/modules/brother/cmd/brother/crop.go index 62554ecb..47033c32 100644 --- a/modules/brother/cmd/brother/crop.go +++ b/modules/brother/cmd/brother/crop.go @@ -6,6 +6,7 @@ import ( "image" "image/color" "image/jpeg" + "sort" ) // Cropping cuts the document out of the platen. The scanner sends the whole glass, a document in one corner @@ -48,21 +49,7 @@ func Box(img image.Image) (image.Rectangle, bool) { 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 + limit := lidLimit(img) cols := make([]int, w) rows := make([]int, h) @@ -86,6 +73,28 @@ func Box(img image.Image) (image.Rectangle, bool) { return box, true } +// lidLimit is the level under which a pixel is document: the lid's level, measured in the corner block farthest +// from the origin, less darker. +func lidLimit(img image.Image) int { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + 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) + return lid - darker +} + // 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 @@ -136,3 +145,199 @@ func Crop(scan []byte, crop bool) (Cropped, error) { } return Cropped{JPEG: out.Bytes(), Width: box.Dx(), Height: box.Dy(), Cropped: true}, nil } + +// Several documents on the glass (issue 1528). Where the rows-and-columns box of Box would be one image around +// all of them, Boxes finds each document apart from the others: the picture is cut into blocks of about a +// millimetre, a block is document when a quarter of its pixels are darker than the lid (dust is not), blocks +// within gapMM of each other are one region (so a white area inside a document, or a gap in it, does not split +// it), and a region of at least minMM on both sides is a document. Regions whose boxes touch or overlap +// (a text block inside a document's frame) are one document. +const ( + // blockMM is the side of a block, in millimetres. + blockMM = 1.0 + // blockShare is the fraction of a block's pixels that must be document for the block to count. + blockShare = 0.25 + // gapMM is how near two regions must be to be one document, in millimetres: documents lying farther apart than + // this are separate ones. + gapMM = 10.0 + // minMM is the least a region may measure, on both sides, to be a document and not a speck or a stain. + minMM = 20.0 +) + +// Boxes is the box of each separate document on the platen, in reading order (top to bottom by the box's top, +// then left to right), at the image's resolution dpi. It is Box's white-detection (the lid's level and darker) on +// a grid of blocks. A box that fills (almost) the platen is no crop: then the whole image is the answer, as one +// box with found false. +func Boxes(img image.Image, dpi int) ([]image.Rectangle, bool) { + b := img.Bounds() + w, h := b.Dx(), b.Dy() + if dpi <= 0 || w < 2*margin || h < 2*margin { + return []image.Rectangle{b}, false + } + bs := max(2, int(blockMM*float64(dpi)/25.4+0.5)) + gw, gh := (w+bs-1)/bs, (h+bs-1)/bs + limit := lidLimit(img) + doc := make([]bool, gw*gh) + for gy := 0; gy < gh; gy++ { + for gx := 0; gx < gw; gx++ { + dark, total := 0, 0 + for y := b.Min.Y + gy*bs; y < min(b.Min.Y+(gy+1)*bs, b.Max.Y); y++ { + for x := b.Min.X + gx*bs; x < min(b.Min.X+(gx+1)*bs, b.Max.X); x++ { + total++ + if grayAt(img, x, y) < limit { + dark++ + } + } + } + doc[gy*gw+gx] = float64(dark) >= blockShare*float64(total) + } + } + // Dust and the ringing of the JPEG around it make single blocks, or a pair, of "document": an opening (every + // block must have all eight neighbours document to survive, and survivors give back their neighbours that are + // document) removes what is not at least three blocks across. + eroded := make([]bool, gw*gh) + for gy := 1; gy < gh-1; gy++ { + for gx := 1; gx < gw-1; gx++ { + all := true + for y := gy - 1; y <= gy+1 && all; y++ { + for x := gx - 1; x <= gx+1; x++ { + if !doc[y*gw+x] { + all = false + break + } + } + } + eroded[gy*gw+gx] = all + } + } + opened := make([]bool, gw*gh) + for gy := 0; gy < gh; gy++ { + for gx := 0; gx < gw; gx++ { + if !eroded[gy*gw+gx] { + continue + } + for y := gy - 1; y <= gy+1; y++ { + for x := gx - 1; x <= gx+1; x++ { + opened[y*gw+x] = true + } + } + } + } + doc = opened + // Join the blocks within the gap: a block is near when it is within gapBlocks/2 of a document block in each + // direction (so two blocks up to gapBlocks apart meet), then the nearby blocks are labelled in 4-connected + // regions; each region's box is that of the document blocks in it. + radius := int(gapMM * float64(dpi) / 25.4 / float64(bs) / 2) + near := make([]bool, gw*gh) + for gy := 0; gy < gh; gy++ { + for gx := 0; gx < gw; gx++ { + if !doc[gy*gw+gx] { + continue + } + for y := max(0, gy-radius); y <= min(gh-1, gy+radius); y++ { + for x := max(0, gx-radius); x <= min(gw-1, gx+radius); x++ { + near[y*gw+x] = true + } + } + } + } + seen := make([]bool, gw*gh) + var boxes []image.Rectangle + for start := range near { + if !near[start] || seen[start] { + continue + } + minX, minY, maxX, maxY := gw, gh, -1, -1 + stack := []int{start} + seen[start] = true + for len(stack) > 0 { + c := stack[len(stack)-1] + stack = stack[:len(stack)-1] + cx, cy := c%gw, c/gw + if doc[c] { + minX, minY, maxX, maxY = min(minX, cx), min(minY, cy), max(maxX, cx), max(maxY, cy) + } + for _, d := range [4][2]int{{1, 0}, {-1, 0}, {0, 1}, {0, -1}} { + x, y := cx+d[0], cy+d[1] + if x < 0 || y < 0 || x >= gw || y >= gh || !near[y*gw+x] || seen[y*gw+x] { + continue + } + seen[y*gw+x] = true + stack = append(stack, y*gw+x) + } + } + if maxX < 0 { + continue + } + r := image.Rect(minX*bs, minY*bs, (maxX+1)*bs, (maxY+1)*bs) + if float64(r.Dx())/float64(dpi)*25.4 < minMM || float64(r.Dy())/float64(dpi)*25.4 < minMM { + continue + } + boxes = append(boxes, r) + } + // Boxes that touch or overlap are one document: a frame and the text inside it, or a faint paper whose + // parts the lid's white hides, are one however far apart their blocks lie. + kept := boxes + for merged := true; merged; { + merged = false + for i := 0; i < len(kept) && !merged; i++ { + for j := i + 1; j < len(kept); j++ { + if kept[i].Overlaps(kept[j]) { + kept[i] = kept[i].Union(kept[j]) + kept = append(kept[:j], kept[j+1:]...) + merged = true + break + } + } + } + } + if len(kept) == 0 { + return []image.Rectangle{b}, false + } + out := make([]image.Rectangle, len(kept)) + for i, r := range kept { + out[i] = image.Rect(max(0, r.Min.X-margin), max(0, r.Min.Y-margin), min(w, r.Max.X+margin), min(h, r.Max.Y+margin)).Add(b.Min) + } + sort.SliceStable(out, func(i, j int) bool { + if out[i].Min.Y != out[j].Min.Y { + return out[i].Min.Y < out[j].Min.Y + } + return out[i].Min.X < out[j].Min.X + }) + if len(out) == 1 && float64(out[0].Dx()*out[0].Dy()) >= wholeShare*float64(w*h) { + return []image.Rectangle{b}, false + } + return out, true +} + +// CropAll cuts each separate document out of a scanned glass JPEG when crop is set and there are two or more; one +// document, none or crop off is Crop's answer, exactly as before. dpi is the scan's resolution. +func CropAll(scan []byte, crop bool, dpi int) ([]Cropped, error) { + one, err := Crop(scan, crop) + if err != nil || !crop { + return []Cropped{one}, err + } + img, err := jpeg.Decode(bytes.NewReader(scan)) + if err != nil { + return nil, fmt.Errorf("the scanned image cannot be read: %w", err) + } + boxes, found := Boxes(img, dpi) + if !found || len(boxes) < 2 { + return []Cropped{one}, nil + } + sub, ok := img.(interface { + SubImage(image.Rectangle) image.Image + }) + if !ok { + return []Cropped{one}, nil + } + out := make([]Cropped, 0, len(boxes)) + for _, box := range boxes { + var buf bytes.Buffer + if err := jpeg.Encode(&buf, sub.SubImage(box), &jpeg.Options{Quality: jpegQuality}); err != nil { + return nil, fmt.Errorf("the cropped image cannot be encoded: %w", err) + } + out = append(out, Cropped{JPEG: buf.Bytes(), Width: box.Dx(), Height: box.Dy(), Cropped: true}) + } + return out, nil +} diff --git a/modules/brother/cmd/brother/crop_test.go b/modules/brother/cmd/brother/crop_test.go index b61a263c..84b766a4 100644 --- a/modules/brother/cmd/brother/crop_test.go +++ b/modules/brother/cmd/brother/crop_test.go @@ -123,3 +123,107 @@ func TestCropFindsADocumentInTheMiddleOfTheGlass(t *testing.T) { t.Fatalf("box = %v, want about (200,300)-(500,700)", box) } } + +// onWhite is a lid-white image of w x h with the rectangles drawn on it in gray (generated, not captured: the +// thresholds of Boxes are in millimetres, and this is 100 dpi, 1 mm about 4 px). +func onWhite(t *testing.T, seed int64, rects ...image.Rectangle) []byte { + t.Helper() + g := lidWhite(800, 1100, seed) + for _, r := range rects { + draw.Draw(g, r, &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src) + } + return encode(t, g) +} + +// 1528: documents lying apart on the white are each cut out, in reading order; one document, as before, is one. +func TestCropAllCutsEachDocumentApartFromTheOthers(t *testing.T) { + a := image.Rect(100, 100, 300, 300) // 51 mm square + b := image.Rect(450, 100, 650, 400) // 38 mm right of a + c := image.Rect(100, 600, 400, 900) + for name, tc := range map[string]struct { + rects []image.Rectangle + want []image.Rectangle + }{ + "one": {[]image.Rectangle{a}, nil}, + "two": {[]image.Rectangle{b, a}, []image.Rectangle{a, b}}, + "three": {[]image.Rectangle{c, b, a}, []image.Rectangle{a, b, c}}, + } { + got, err := CropAll(onWhite(t, 5, tc.rects...), true, 100) + if err != nil { + t.Fatal(err) + } + if tc.want == nil { + one, _ := Crop(onWhite(t, 5, a), true) + if len(got) != 1 || !bytes.Equal(got[0].JPEG, one.JPEG) { + t.Errorf("%s: one document is not Crop's answer", name) + } + continue + } + if len(got) != len(tc.want) { + t.Fatalf("%s: %d documents, want %d", name, len(got), len(tc.want)) + } + for i, w := range tc.want { + // about the rectangle, within the margin and a block of rounding + if !got[i].Cropped || got[i].Width < w.Dx() || got[i].Width > w.Dx()+2*margin+2*4 || got[i].Height < w.Dy() || got[i].Height > w.Dy()+2*margin+2*4 { + t.Errorf("%s: document %d is %dx%d, want about %dx%d", name, i, got[i].Width, got[i].Height, w.Dx(), w.Dy()) + } + cfg, err := jpeg.DecodeConfig(bytes.NewReader(got[i].JPEG)) + if err != nil || cfg.Width != got[i].Width { + t.Errorf("%s: document %d JPEG %+v %v", name, i, cfg, err) + } + } + } +} + +// 1528: one document with a white area inside it is not split: a frame with an island inside it, a sheet with a +// white band across it narrower than the gap, and specks of dust beside it. +func TestCropAllDoesNotSplitOneDocumentWithWhiteInsideIt(t *testing.T) { + g := lidWhite(800, 1100, 6) + frame := image.Rect(100, 100, 600, 800) + draw.Draw(g, frame, &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src) + draw.Draw(g, frame.Inset(50), &image.Uniform{color.Gray{255}}, image.Point{}, draw.Src) // white inside the frame + draw.Draw(g, image.Rect(300, 400, 400, 500), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src) // an island in it + got, err := CropAll(encode(t, g), true, 100) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 { + t.Fatalf("a frame with an island inside was cut into %d documents", len(got)) + } + + g = lidWhite(800, 1100, 7) + draw.Draw(g, image.Rect(100, 100, 500, 500), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src) + draw.Draw(g, image.Rect(100, 530, 500, 900), &image.Uniform{color.Gray{200}}, image.Point{}, draw.Src) // 30 px = 7.6 mm of white between + got, err = CropAll(encode(t, g), true, 100) + if err != nil || len(got) != 1 { + t.Fatalf("a sheet with a narrow white band across it: %d documents, %v", len(got), err) + } +} + +// 1528: a speck smaller than the minimum is no document, and crop off files the whole platen as one. +func TestCropAllIgnoresSpecksAndCropOffIsOneWholeImage(t *testing.T) { + scan := onWhite(t, 8, image.Rect(100, 100, 300, 300), image.Rect(600, 800, 620, 815)) // 5 x 4 mm stain + got, err := CropAll(scan, true, 100) + if err != nil || len(got) != 1 { + t.Fatalf("a stain made %d documents (%v)", len(got), err) + } + two := onWhite(t, 9, image.Rect(100, 100, 300, 300), image.Rect(450, 100, 650, 400)) + got, err = CropAll(two, false, 100) + if err != nil || len(got) != 1 || got[0].Cropped { + t.Fatalf("crop off: %d documents, cropped %v", len(got), got[0].Cropped) + } +} + +// 1528: the captured scan (one receipt) is one document, the same bytes Crop gives. +func TestCropAllOfTheCapturedReceiptIsCropsAnswer(t *testing.T) { + scan := fixture(t, "scan.jpg") + got, err := CropAll(scan, true, 200) + one, _ := Crop(scan, true) + if err != nil || len(got) != 1 || !bytes.Equal(got[0].JPEG, one.JPEG) { + t.Fatalf("%d documents (%v), want Crop's one", len(got), err) + } + img, _ := jpeg.Decode(bytes.NewReader(scan)) + if boxes, _ := Boxes(img, 200); len(boxes) != 1 { + t.Errorf("the receipt is %d regions: %v", len(boxes), boxes) + } +} diff --git a/modules/brother/cmd/brother/deviceproc.go b/modules/brother/cmd/brother/deviceproc.go index c101c48f..39d07642 100644 --- a/modules/brother/cmd/brother/deviceproc.go +++ b/modules/brother/cmd/brother/deviceproc.go @@ -13,6 +13,7 @@ import ( "fmt" "os" "os/signal" + "strings" "sync" "syscall" "time" @@ -64,7 +65,7 @@ type deviceWorker struct { Answers Answers Node string // Capture scans; nil is the device's own (a test puts a stand-in). - Capture func(ctx context.Context, req ScanRequest) (Capture, error) + Capture func(ctx context.Context, req ScanRequest) ([]Capture, error) // answerPart is the raw size of a document part in an answer; zero is AnswerPartSize (a test makes it larger). answerPart int Now func() time.Time @@ -187,10 +188,49 @@ func (w *deviceWorker) scan(ctx context.Context, ask Ask) (map[string]any, error if w.Capture != nil { capture = w.Capture } - c, err := capture(ctx, req) + caps, err := capture(ctx, req) if err != nil { return nil, err } + return w.keep(ctx, objects, set, caps) +} + +// keep stores each captured PDF in the bucket and says scan.added for it, in that order. One document is answered +// as today: the event's body with key, size and the number of parts. Several (documents lying apart on the glass) +// are answered as documents, a list of those, each filed on its own by the tool. A document that could not be kept +// or announced is named in failed and does not hold back the others: what is announced is always filed. +func (w *deviceWorker) keep(ctx context.Context, objects Objects, set Settings, caps []Capture) (map[string]any, error) { + if len(caps) == 0 { + return nil, fmt.Errorf("the scanner gave no document") + } + var docs []map[string]any + var failed []string + for i, c := range caps { + one, err := w.keepOne(ctx, objects, set, c) + if err != nil { + if len(caps) == 1 { + return nil, err + } + failed = append(failed, fmt.Sprintf("document %d: %v", i+1, err)) + continue + } + docs = append(docs, one) + } + if len(docs) == 0 { + return nil, fmt.Errorf("no document could be kept: %s", strings.Join(failed, "; ")) + } + if len(caps) == 1 { + return docs[0], nil + } + out := map[string]any{"documents": docs} + if len(failed) > 0 { + out["failed"] = failed + } + return out, nil +} + +// keepOne keeps one captured PDF and announces it. +func (w *deviceWorker) keepOne(ctx context.Context, objects Objects, set Settings, c Capture) (map[string]any, error) { // A scan the tool could not take is not kept nor announced: an announced scan is always filed (issue 1539). if most := MostAnswerParts * AnswerPartSize; len(c.PDF) > most { return nil, fmt.Errorf("the scan's PDF is %d bytes, over the %d the tool can fetch (%d parts of %d): it is not kept; scan with a lower resolution or fewer sheets", diff --git a/modules/brother/cmd/brother/documents_test.go b/modules/brother/cmd/brother/documents_test.go index 541f5094..f0f2604b 100644 --- a/modules/brother/cmd/brother/documents_test.go +++ b/modules/brother/cmd/brother/documents_test.go @@ -474,8 +474,8 @@ func TestAScanLargerThanTheStatesValueLimitIsFiledWholeAndItsAnswerIsSmall(t *te if len(big) <= StateMaxValue { t.Fatal("the stand-in is not larger than the state's value limit") } - r.worker.Capture = func(ctx context.Context, req ScanRequest) (Capture, error) { - return Capture{PDF: big, SHA256: sha256Hex(big), Resolution: 300, At: fixedNow}, nil + r.worker.Capture = func(ctx context.Context, req ScanRequest) ([]Capture, error) { + return []Capture{{PDF: big, SHA256: sha256Hex(big), Resolution: 300, At: fixedNow}}, nil } body, err := r.scan(t) if err != nil { diff --git a/modules/brother/cmd/brother/feeder_test.go b/modules/brother/cmd/brother/feeder_test.go index d70ec46a..e0112aee 100644 --- a/modules/brother/cmd/brother/feeder_test.go +++ b/modules/brother/cmd/brother/feeder_test.go @@ -167,14 +167,14 @@ func TestDuplexOnTheGlassIsRefusedAndNothingIsAsked(t *testing.T) { } // pdfCapture is a stand-in capture of a small PDF, ready to be released. -func standInCapture(release <-chan struct{}, fail error) func(ctx context.Context, req ScanRequest) (Capture, error) { - return func(ctx context.Context, req ScanRequest) (Capture, error) { +func standInCapture(release <-chan struct{}, fail error) func(ctx context.Context, req ScanRequest) ([]Capture, error) { + return func(ctx context.Context, req ScanRequest) ([]Capture, error) { <-release if fail != nil { - return Capture{}, fail + return nil, fail } pdf := pdfOf(5000) - return Capture{PDF: pdf, SHA256: sha256Hex(pdf), Resolution: 200, Pages: 3, At: fixedNow}, nil + return []Capture{{PDF: pdf, SHA256: sha256Hex(pdf), Resolution: 200, Pages: 3, At: fixedNow}}, nil } } @@ -286,8 +286,8 @@ func TestAScanTooLargeToFetchIsNeitherKeptNorAnnounced(t *testing.T) { release := make(chan struct{}) close(release) huge := make([]byte, MostAnswerParts*AnswerPartSize+1) - r.worker.Capture = func(ctx context.Context, req ScanRequest) (Capture, error) { - return Capture{PDF: huge, SHA256: "x", Resolution: 200, Pages: 99, At: fixedNow}, nil + r.worker.Capture = func(ctx context.Context, req ScanRequest) ([]Capture, error) { + return []Capture{{PDF: huge, SHA256: "x", Resolution: 200, Pages: 99, At: fixedNow}}, nil } _, err := r.call(t, "scanner.scan", scanArgs("feeder", false)) if err == nil || !strings.Contains(err.Error(), "not kept") { diff --git a/modules/brother/cmd/brother/glass_test.go b/modules/brother/cmd/brother/glass_test.go new file mode 100644 index 00000000..459200d7 --- /dev/null +++ b/modules/brother/cmd/brother/glass_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "image" + "os" + "testing" +) + +// withDensity puts a JFIF header of dpi into a JPEG that has none (Go's encoder writes none). +func withDensity(jpg []byte, dpi int) []byte { + app0 := []byte{0xFF, 0xE0, 0, 16, 'J', 'F', 'I', 'F', 0, 1, 1, 1, byte(dpi >> 8), byte(dpi), byte(dpi >> 8), byte(dpi), 0, 0} + return append(append(append([]byte{}, jpg[:2]...), app0...), jpg[2:]...) +} + +// 1528: three receipts lying apart on the glass are three PDFs, each its own object, own scan.added and own file +// in the scan folder, one page each; the answer lists every document. +func TestSeveralDocumentsOnTheGlassAreFiledEachOnItsOwn(t *testing.T) { + scan := withDensity(onWhite(t, 11, image.Rect(100, 100, 300, 300), image.Rect(450, 100, 650, 400), image.Rect(100, 600, 400, 900)), 100) + f := newFakeScanner(t, scan) + r, folder := scanRig(t, f, Settings{Crop: true, Resolution: 100}) + body, err := r.scan(t) + if err != nil { + t.Fatal(err) + } + docs, _ := body["documents"].([]any) + if len(docs) != 3 || body["count"] != 3 { + t.Fatalf("answer = %v, want three documents", body) + } + seen := map[string]bool{} + for _, d := range docs { + m := d.(map[string]any) + path, _ := m["path"].(string) + if _, err := os.Stat(path); err != nil || m["pages"] != float64(1) || m["cropped"] != true || seen[path] { + t.Errorf("document %v: %v", m, err) + } + seen[path] = true + } + if e, _ := os.ReadDir(folder); len(e) != 3 { + t.Errorf("%d files in the scan folder, want 3", len(e)) + } + out := *r.said + if len(out) != 3 { + t.Fatalf("%d events, want one each", len(out)) + } + keys := map[any]bool{} + for _, ev := range out { + keys[ev.body["object"]] = true + if !ev.seen { + t.Error("an event was said before its PDF was kept") + } + } + if len(keys) != 3 { + t.Errorf("the events name %d different objects, want 3", len(keys)) + } +} + +// 1528: one document on the glass is answered as today: a flat body with its path. +func TestOneDocumentOnTheGlassIsAnsweredAsBefore(t *testing.T) { + scan := withDensity(onWhite(t, 12, image.Rect(100, 100, 300, 300)), 100) + f := newFakeScanner(t, scan) + r, _ := scanRig(t, f, Settings{Crop: true, Resolution: 100}) + body, err := r.scan(t) + if err != nil { + t.Fatal(err) + } + if body["documents"] != nil || body["path"] == nil || len(*r.said) != 1 { + t.Errorf("answer = %v, %d events", body, len(*r.said)) + } +} diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index dfead8e5..5191d7c9 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -242,7 +242,7 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool { Description: "Scan with the configured network scanner, from the glass (source glass, the default: one page, cut out of " + "the platen's white when crop is set) or from the document feeder (source feeder: every sheet in it, whole, up to 50; " + "duplex true scans both sides of each sheet). The device process keeps the result as one PDF in the module's bucket " + - "under its sha256 (one page for the glass, one page per side for the feeder) and says scan.added on the bus; this " + + "under its sha256 (one page for the glass, one page per side for the feeder; when crop is set and several documents lie apart on the glass, each is its own one-page PDF with its own object and scan.added) and says scan.added on the bus; this " + "tool then fetches it and files it in the scan folder, in the background, so a scan is filed even when you stop waiting. " + "A glass scan is waited for (up to three minutes). A feeder scan is waited for 25 seconds (the mesh console gives up on a " + "call after 30): when it is not done by then the answer is {started: true, id, state: running}; the scan goes on (the " + @@ -250,7 +250,7 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool { "scanner.scan {id} reads the outcome: state running, state done with the answer below, or state failed with why. " + "The id is known to the tool process that started the scan; scan.added also announces it. Answers the event's body: " + "the object's key, its size and sha256, the first page's real size in millimetres, the resolution the scanner really gave, " + - "whether it was cropped, the number of pages and the file's path. The image itself is never sent. " + + "whether it was cropped, the number of pages and the file's path; for several documents on the glass {documents: [that, for each], count} (a failure of one is in failed). The image itself is never sent. " + "Duplex on the glass is refused.", Input: map[string]any{"type": "object", "properties": map[string]any{ "source": map[string]any{"type": "string", "enum": []string{"glass", "feeder"}, "description": "glass (default) or feeder"}, diff --git a/modules/brother/cmd/brother/scan.go b/modules/brother/cmd/brother/scan.go index a224db9e..c50970ea 100644 --- a/modules/brother/cmd/brother/scan.go +++ b/modules/brother/cmd/brother/scan.go @@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "fmt" + "strings" "sync" "time" ) @@ -143,8 +144,42 @@ func (s *Service) finishScan(ctx context.Context, id string, wait time.Duration) return s.fileAnswer(ctx, ans) } -// fileAnswer fetches the scan the device process's answer names and files it. +// fileAnswer fetches the scan the device process's answer names and files it; an answer that lists documents (several +// lying apart on the glass, issue 1528) is each fetched and filed as a PDF of its own, and answered as documents, +// in the order the scanner found them. One that cannot be filed does not hold back the others: it is named in failed. func (s *Service) fileAnswer(ctx context.Context, ans map[string]any) (map[string]any, error) { + list, many := ans["documents"].([]any) + if !many { + return s.fileOne(ctx, ans) + } + var filed []any + var failed []string + for i, d := range list { + one, _ := d.(map[string]any) + body, err := s.fileOne(ctx, one) + if err != nil { + failed = append(failed, fmt.Sprintf("document %d: %v", i+1, err)) + continue + } + filed = append(filed, body) + } + if bad, ok := ans["failed"].([]any); ok { + for _, b := range bad { + failed = append(failed, fmt.Sprint(b)) + } + } + if len(filed) == 0 { + return nil, fmt.Errorf("no document could be filed: %s", strings.Join(failed, "; ")) + } + out := map[string]any{"documents": filed, "count": len(filed)} + if len(failed) > 0 { + out["failed"] = failed + } + return out, nil +} + +// fileOne fetches the one scan the answer names and files it. +func (s *Service) fileOne(ctx context.Context, ans map[string]any) (map[string]any, error) { key, _ := ans["key"].(string) sum, _ := ans["sha256"].(string) parts, _ := ans["parts"].(float64)