Cut each of several documents lying apart on the glass out and file it as its own PDF (issue 1528)

This commit is contained in:
2026-10-21 01:44:05 +02:00
parent a5bc37459b
commit 17f3c45182
10 changed files with 528 additions and 47 deletions
+8
View File
@@ -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.
+39 -19
View File
@@ -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
+220 -15
View File
@@ -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
}
+104
View File
@@ -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)
}
}
+42 -2
View File
@@ -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",
@@ -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 {
+6 -6
View File
@@ -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") {
+69
View File
@@ -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))
}
}
+2 -2
View File
@@ -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"},
+36 -1
View File
@@ -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)