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:
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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") {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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"},
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user