From d5f9cd465c961bbfa0c07ddeb265a2ebfe37f6dd Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 21 Oct 2026 01:05:55 +0200 Subject: [PATCH] Send brother's scan settings on one line and scan from the feeder, both sides, into one PDF (issue 1452) The Brother ignores the whole body when it holds a line break, so every setting was ignored and the glass was scanned at its default. --- modules/brother/README.md | 17 +- modules/brother/cmd/brother/capture.go | 79 +++++++-- modules/brother/cmd/brother/deviceproc.go | 14 +- modules/brother/cmd/brother/documents_test.go | 2 +- modules/brother/cmd/brother/escl.go | 150 +++++++++++++--- modules/brother/cmd/brother/escl_test.go | 10 +- modules/brother/cmd/brother/feeder_test.go | 165 ++++++++++++++++++ modules/brother/cmd/brother/helpers_test.go | 55 +++++- modules/brother/cmd/brother/main.go | 29 ++- modules/brother/cmd/brother/pdf.go | 72 +++++--- modules/brother/cmd/brother/printer.go | 2 + modules/brother/cmd/brother/scan.go | 7 +- modules/brother/cmd/brother/service_test.go | 10 +- .../testdata/escl/doc-headers-feeder.txt | 6 + .../cmd/brother/testdata/escl/settings.xml | 10 +- .../testdata/escl/status-feeder-after.xml | 1 + modules/brother/module.json | 2 +- 17 files changed, 523 insertions(+), 108 deletions(-) create mode 100644 modules/brother/cmd/brother/feeder_test.go create mode 100644 modules/brother/cmd/brother/testdata/escl/doc-headers-feeder.txt create mode 100644 modules/brother/cmd/brother/testdata/escl/status-feeder-after.xml diff --git a/modules/brother/README.md b/modules/brother/README.md index 98328181..ff4449cc 100644 --- a/modules/brother/README.md +++ b/modules/brother/README.md @@ -14,14 +14,19 @@ the module runs no process of its own, and it runs as the node's operator accoun ## What a scan does -1. Asks the scanner for its glass size and puts one job to it (`POST /ScanJobs`): the platen, the whole - glass, at the configured resolution and colour mode, as a JPEG; it fetches the page once. +1. Asks the scanner for its sizes and puts one job to it (`POST /ScanJobs`) at the configured resolution + and colour mode, as a JPEG: from the glass (`source` glass, the default) or from the document feeder (`source` + feeder, with `duplex` true for both sides of each sheet; refused on the glass). The region is A4, cut to what the + input can scan (the glass is 3507 high, not A4's 3508). The body is written on one line: the Brother ignores the + whole body, and runs its default job, when it holds a line break (issue 1452). The glass page is fetched once; + a feeder job is asked `NextDocument` until the scanner answers 404, at most 100 pages. A job that fails is deleted. 2. Reads the resolution from the image's own JFIF density, never from the request: a scanner may answer a 300 dpi request with 200 dpi, and the page size and the event's millimetres follow what it gave. -3. Crops: the lid's level is measured in the corner farthest from where documents lie, a pixel is document when +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. -4. Files a one-page PDF holding the JPEG: written whole beside its place as `.scan-*.tmp` and renamed to +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. 5. Says `scan.added` once, after the rename, under the file's sha256 as its message id. It never carries the image. @@ -50,7 +55,7 @@ makes it (mode 0755) when it files the first scan. | verb (tool) | does | |---|---| -| `scanner.scan` | asks the device process to scan, crop, keep the PDF in the bucket under its sha256 and say `scan.added`; then fetches it in parts, checks the sha256 and files it in `scan-folder`. Waits up to 180 s. Answers the event's body and `path` | +| `scanner.scan` | `source` (`glass`, default, or `feeder`) and `duplex` (feeder only): asks the device process to scan, crop (glass only), keep the PDF in the bucket under its sha256 and say `scan.added`; then fetches it in parts, checks the sha256 and files it in `scan-folder`. Waits up to 180 s. Answers the event's body and `path` | | `scanner.document` | one part of a kept scan (`object`, `part` from 1): `part`, `parts`, `size`, `sha256` (of the whole), `data` (base64). Join the parts and check the sha256 | | `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on | | `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only | @@ -110,7 +115,7 @@ the login page again. The logout follows a logout link or form on the page; a pa `scan.added`, said by this module, on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body: `path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it), -`color-mode`, `cropped`, `scanned-at` (RFC 3339, UTC). +`color-mode`, `cropped`, `pages` (1 for the glass), `scanned-at` (RFC 3339, UTC). ## Files it does not know diff --git a/modules/brother/cmd/brother/capture.go b/modules/brother/cmd/brother/capture.go index fbf1bb30..c917874c 100644 --- a/modules/brother/cmd/brother/capture.go +++ b/modules/brother/cmd/brother/capture.go @@ -1,20 +1,24 @@ package main import ( + "bytes" "context" + "fmt" + "image/jpeg" "math" "time" ) // The device process's scan (novox/hq ADR 0468 clause 4): the glass scanned over eSCL, the document cut out of -// the platen's white and built into a one-page PDF. Keeping it in the bucket and saying scan.added are the +// the platen's white and built into a one-page PDF, or the feeder's sheets (both sides on request) into one multi-page PDF. Keeping it in the bucket and saying scan.added are the // device process's (deviceproc.go); filing it in the scan folder is the tool's (scan.go). func mm(pixels, dpi int) float64 { return math.Round(float64(pixels)/float64(dpi)*25.4*10) / 10 } -// Capture is a scanned, cropped page as a PDF, with what the event says of it. +// Capture is a scanned page or pages as a PDF, with what the event says of it. Width, height and resolution are +// the first page's. type Capture struct { PDF []byte SHA256 string @@ -22,36 +26,78 @@ type Capture struct { HeightMM float64 Resolution int Cropped bool + Pages int At time.Time } -// Capture scans the glass, crops and builds the PDF. Nothing is kept and nothing is said here; an error says why +// ScanRequest is what one scan asks beyond the configuration: the feeder instead of the glass, and both sides. +type ScanRequest struct { + Feeder bool + Duplex bool +} + +// ParseSource reads the scanner.scan arguments source (glass, the default, or feeder) and duplex. Duplex is for the +// feeder: the glass holds one sheet. +func ParseSource(source string, duplex bool) (ScanRequest, error) { + switch source { + case "", "glass": + if duplex { + return ScanRequest{}, fmt.Errorf("duplex is for the feeder: the glass holds one page (set source to feeder)") + } + return ScanRequest{}, nil + case "feeder": + return ScanRequest{Feeder: true, Duplex: duplex}, nil + } + 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) (Capture, error) { +func (s *Service) Capture(ctx context.Context, req ScanRequest) (Capture, error) { at := s.Now() - scan, err := NewClient(s.Settings.Scanner).Scan(ctx, s.Settings.Resolution, s.Settings.ColorMode) + 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 } - dpi, err := Density(scan) + pages := make([]PDFPage, len(scans)) + var first Cropped + var firstDPI int + for i, scan := range scans { + dpi, err := Density(scan) + if err != nil { + return Capture{}, 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 + } + pages[i] = PDFPage{JPEG: cut.JPEG, DPI: dpi} + if i == 0 { + first, firstDPI = cut, dpi + } + } + pdf, err := PDFPages(pages) if err != nil { return Capture{}, err } - cut, err := Crop(scan, s.Settings.Crop) - if err != nil { - return Capture{}, err - } - pdf, err := PDF(cut.JPEG, dpi) - if err != nil { - return Capture{}, err - } - return Capture{PDF: pdf, SHA256: sha256Hex(pdf), WidthMM: mm(cut.Width, dpi), HeightMM: mm(cut.Height, dpi), - Resolution: dpi, Cropped: cut.Cropped, At: at}, nil + return Capture{PDF: pdf, SHA256: sha256Hex(pdf), WidthMM: mm(first.Width, firstDPI), HeightMM: mm(first.Height, firstDPI), + Resolution: firstDPI, Cropped: first.Cropped, Pages: len(pages), At: at}, nil } // Body is the event scan.added's body (ADR 0464 ยง5 with the object key of ADR 0468 clause 2): never the image, // and no path, since filing in a folder is the tool's, after the event. func (c Capture) Body(node, colorMode string) map[string]any { + pages := max(c.Pages, 1) return map[string]any{ "object": c.SHA256 + ".pdf", "node": node, @@ -62,6 +108,7 @@ func (c Capture) Body(node, colorMode string) map[string]any { "resolution": c.Resolution, "color-mode": colorMode, "cropped": c.Cropped, + "pages": pages, "scanned-at": c.At.UTC().Format(time.RFC3339), } } diff --git a/modules/brother/cmd/brother/deviceproc.go b/modules/brother/cmd/brother/deviceproc.go index b080fdfc..2910c1fc 100644 --- a/modules/brother/cmd/brother/deviceproc.go +++ b/modules/brother/cmd/brother/deviceproc.go @@ -64,7 +64,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) (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 @@ -145,7 +145,7 @@ func (w *deviceWorker) run(ctx context.Context, ask Ask) (map[string]any, error) case OpSettings: return w.read(ctx, ask.Page, ask.Links, ask.Hidden) case OpScan: - return w.scan(ctx) + return w.scan(ctx, ask) case OpDocument: return w.document(ctx, ask) case OpUpload: @@ -156,11 +156,15 @@ func (w *deviceWorker) run(ctx context.Context, ask Ask) (map[string]any, error) return nil, fmt.Errorf("the ask %s names an operation, %q, the device process does not do", ask.ID, ask.Op) } -// scan scans the glass, crops, builds the PDF, keeps it in the bucket under its SHA-256 and says scan.added with +// scan scans the glass (cropped) or the feeder (whole sheets, one PDF of all pages), builds the PDF, keeps it in the bucket under its SHA-256 and says scan.added with // the object's key (ADR 0468 clause 2), in that order: nothing is said of a scan that is not kept, and nothing is // kept nor said when the scanner gives no page (1440/AC6). The answer is the event's body with key, size and the // number of parts. -func (w *deviceWorker) scan(ctx context.Context) (map[string]any, error) { +func (w *deviceWorker) scan(ctx context.Context, ask Ask) (map[string]any, error) { + req, err := ParseSource(ask.Source, ask.Duplex) + if err != nil { + return nil, err + } set, err := w.Settings() if err != nil { return nil, err @@ -177,7 +181,7 @@ func (w *deviceWorker) scan(ctx context.Context) (map[string]any, error) { if w.Capture != nil { capture = w.Capture } - c, err := capture(ctx) + c, err := capture(ctx, req) if err != nil { return nil, err } diff --git a/modules/brother/cmd/brother/documents_test.go b/modules/brother/cmd/brother/documents_test.go index 9e96c1ef..ceaff0ba 100644 --- a/modules/brother/cmd/brother/documents_test.go +++ b/modules/brother/cmd/brother/documents_test.go @@ -474,7 +474,7 @@ 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) (Capture, error) { + 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) diff --git a/modules/brother/cmd/brother/escl.go b/modules/brother/cmd/brother/escl.go index 1f79c348..a16f1e31 100644 --- a/modules/brother/cmd/brother/escl.go +++ b/modules/brother/cmd/brother/escl.go @@ -32,14 +32,23 @@ func NewClient(base string) *Client { return &Client{Base: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 3 * time.Minute}} } -// Capabilities are the platen's largest scan region, in the 300ths of an inch the region is asked in. +// Size is the largest scan region of one input, in the 300ths of an inch the region is asked in. +type Size struct{ MaxWidth, MaxHeight int } + +// Capabilities are the largest scan regions of the platen and of the document feeder (simplex and duplex). A +// scanner with no feeder has zero sizes for it. type Capabilities struct { - MaxWidth, MaxHeight int + MaxWidth, MaxHeight int // the platen + Feeder, FeederDuplex Size } type capabilitiesXML struct { - MaxWidth int `xml:"Platen>PlatenInputCaps>MaxWidth"` - MaxHeight int `xml:"Platen>PlatenInputCaps>MaxHeight"` + MaxWidth int `xml:"Platen>PlatenInputCaps>MaxWidth"` + MaxHeight int `xml:"Platen>PlatenInputCaps>MaxHeight"` + AdfWidth int `xml:"Adf>AdfSimplexInputCaps>MaxWidth"` + AdfHeight int `xml:"Adf>AdfSimplexInputCaps>MaxHeight"` + DuplexWidth int `xml:"Adf>AdfDuplexInputCaps>MaxWidth"` + DuplexHeight int `xml:"Adf>AdfDuplexInputCaps>MaxHeight"` } // Status is what /ScannerStatus says. @@ -74,6 +83,10 @@ func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) { if resp.StatusCode != http.StatusOK { return Capabilities{}, fmt.Errorf("the scanner refused its capabilities: %s", resp.Status) } + return parseCapabilities(body) +} + +func parseCapabilities(body []byte) (Capabilities, error) { var x capabilitiesXML if err := xml.Unmarshal(body, &x); err != nil { return Capabilities{}, fmt.Errorf("the scanner's capabilities are not XML: %w", err) @@ -81,7 +94,8 @@ func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) { if x.MaxWidth <= 0 || x.MaxHeight <= 0 { return Capabilities{}, fmt.Errorf("the scanner's capabilities name no platen size") } - return Capabilities{MaxWidth: x.MaxWidth, MaxHeight: x.MaxHeight}, nil + return Capabilities{MaxWidth: x.MaxWidth, MaxHeight: x.MaxHeight, + Feeder: Size{x.AdfWidth, x.AdfHeight}, FeederDuplex: Size{x.DuplexWidth, x.DuplexHeight}}, nil } // Status asks what the scanner is doing. @@ -107,36 +121,78 @@ func (c *Client) Status(ctx context.Context) (Status, error) { // colorModes maps the config's words to eSCL's. var colorModes = map[string]string{"color": "RGB24", "gray": "Grayscale8"} -// ScanSettings is the request body: the Platen, the whole glass, the colour mode and resolution, as a JPEG. -func ScanSettings(c Capabilities, resolution int, colorMode string) (string, error) { - escl, ok := colorModes[colorMode] - if !ok { - return "", fmt.Errorf("color-mode %q is not color or gray", colorMode) - } - return fmt.Sprintf(` - -2.63 -escl:ThreeHundredthsOfInches00%d%d -Platen -%s -%d%d -image/jpeg - -`, c.MaxWidth, c.MaxHeight, escl, resolution, resolution), nil +// ScanOptions is what one job asks: the resolution and colour mode of the configuration, and the input. Duplex is +// for the feeder. +type ScanOptions struct { + Resolution int + ColorMode string + Feeder bool + Duplex bool } -// Scan puts one job to the scanner and returns the JPEG it sends. The resolution asked is only a request: the -// real one is the image's own density (Density), for the real scanner answered 300 with 200. +// A4 in the 300ths of an inch the region is asked in; a region is cut to what the input can scan. +const a4Width, a4Height = 2480, 3508 + +// maxFeederPages bounds one feeder job: the feeder holds 50 sheets, which duplex is 100 pages. After them the +// scanner has to answer 404. +const maxFeederPages = 100 + +// ScanSettings is the request body, on ONE line: the Brother ignores the whole body, and runs its default job (the +// platen, 200 dpi), when there is a line break anywhere before the closing root tag (issue 1452). The region is A4 +// cut to the input's largest region. +func ScanSettings(c Capabilities, o ScanOptions) (string, error) { + escl, ok := colorModes[o.ColorMode] + if !ok { + return "", fmt.Errorf("color-mode %q is not color or gray", o.ColorMode) + } + size, source := Size{c.MaxWidth, c.MaxHeight}, "Platen" + if o.Duplex && !o.Feeder { + return "", fmt.Errorf("duplex is for the feeder") + } + if o.Feeder { + source, size = "Feeder", c.Feeder + if o.Duplex { + size = c.FeederDuplex + } + if size.MaxWidth <= 0 || size.MaxHeight <= 0 { + return "", fmt.Errorf("the scanner names no document feeder%s", map[bool]string{true: " that scans both sides", false: ""}[o.Duplex]) + } + } + duplex := "" + if o.Duplex { + duplex = "true" + } + return fmt.Sprintf(``+ + `2.0Document`+ + `escl:ThreeHundredthsOfInches00%d%d`+ + `%s%simage/jpeg`+ + `%d%d%s`, + min(a4Width, size.MaxWidth), min(a4Height, size.MaxHeight), source, escl, o.Resolution, o.Resolution, duplex), nil +} + +// Scan puts one job for the glass to the scanner and returns the JPEG it sends. The resolution asked is only a +// request: the real one is the image's own density (Density). // // A job stays open on the scanner (pwg:State Processing, and 503 to the next ScanJobs) until NextDocument has // answered 404 or the job is DELETEd, so Scan always ends the job it made: after the page it asks NextDocument // again, and on any failure after the job was created it deletes the job. func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([]byte, error) { + pages, err := c.ScanPages(ctx, ScanOptions{Resolution: resolution, ColorMode: colorMode}) + if err != nil { + return nil, err + } + return pages[0], nil +} + +// ScanPages puts one job to the scanner, the glass or the feeder, and returns its pages in order. The glass holds +// one page (a second is an error); the feeder is asked NextDocument until it answers 404, at most maxFeederPages +// times. Either way the job is ended, and deleted on any failure after it was created. +func (c *Client) ScanPages(ctx context.Context, o ScanOptions) ([][]byte, error) { caps, err := c.Capabilities(ctx) if err != nil { return nil, err } - settings, err := ScanSettings(caps, resolution, colorMode) + settings, err := ScanSettings(caps, o) if err != nil { return nil, err } @@ -145,6 +201,14 @@ func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([] return nil, err } doc := job + "/NextDocument" + if o.Feeder { + pages, err := c.feederPages(ctx, doc) + if err != nil { + c.deleteJob(job) + return nil, err + } + return pages, nil + } image, err := c.page(ctx, doc) if err != nil { c.deleteJob(job) @@ -157,7 +221,43 @@ func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([] if extra > 0 { return nil, fmt.Errorf("the scanner gave more pages than the glass holds: %d", 1+extra) } - return image, nil + return [][]byte{image}, nil +} + +// feederPages asks NextDocument until the scanner answers 404, which ends the job. The first page missing means no +// paper was in the feeder. +func (c *Client) feederPages(ctx context.Context, doc string) ([][]byte, error) { + var pages [][]byte + for len(pages) <= maxFeederPages { + got, err := c.get(ctx, doc) + if err != nil { + return nil, fmt.Errorf("the scanned page %d cannot be fetched: %w", len(pages)+1, err) + } + image, err := readBody(got, maxImage) + if err != nil { + return nil, fmt.Errorf("the scanned page %d cannot be read: %w", len(pages)+1, err) + } + switch got.StatusCode { + case http.StatusOK: + case http.StatusNotFound: + if len(pages) == 0 { + return nil, fmt.Errorf("the scanner gave no page: is paper in the feeder?") + } + return pages, nil + case http.StatusGone: + return nil, fmt.Errorf("the scanner dropped the job: %s", got.Status) + default: + return nil, fmt.Errorf("the scanner gave no page %d: %s", len(pages)+1, got.Status) + } + if len(pages) == maxFeederPages { + break + } + if ct := got.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") { + return nil, fmt.Errorf("the scanner sent %q, not image/jpeg", ct) + } + pages = append(pages, image) + } + return nil, fmt.Errorf("the feeder gave more than %d pages and the scanner did not end the job", maxFeederPages) } // leftOpen is the job URL, per scanner, that brother could neither finish nor delete. diff --git a/modules/brother/cmd/brother/escl_test.go b/modules/brother/cmd/brother/escl_test.go index 6418fb9c..489ca195 100644 --- a/modules/brother/cmd/brother/escl_test.go +++ b/modules/brother/cmd/brother/escl_test.go @@ -7,8 +7,6 @@ import ( "time" ) -func squeeze(s string) string { return strings.ReplaceAll(s, "\n", "") } - // 1440/AC1: the captured capabilities, job creation, document and status replies, served by a test server. func TestScanPostsOnePlatenJobOverTheWholeGlassAndAsksNextDocumentAgainToCloseIt(t *testing.T) { f := newFakeScanner(t, fixture(t, "scan.jpg")) @@ -19,10 +17,10 @@ func TestScanPostsOnePlatenJobOverTheWholeGlassAndAsksNextDocumentAgainToCloseIt if f.posts != 1 { t.Fatalf("%d ScanJobs posts, want 1", f.posts) } - // The body is the request the real scanner accepted (testdata/escl/settings.xml): Platen, 2550x3507 - // 300ths of an inch from the capabilities, RGB24, 300 dpi, image/jpeg. - if got, want := squeeze(f.postBody), squeeze(string(fixture(t, "settings.xml"))); got != want { - t.Fatalf("the job is not the captured request\n got: %s\nwant: %s", got, want) + // The scanner ignores the whole body when it holds a line break (issue 1452), so the settings must have taken + // effect: the Platen, 300 dpi, A4 cut to the glass (2480 x 3507). + if f.job != (scannerJob{Source: "Platen", Resolution: 300, Region: "2480x3507"}) { + t.Fatalf("the scanner took %+v from the body %q", f.job, f.postBody) } // The page, then the second request that the scanner answers 404, which closes the job. if f.documents != 2 || f.open || f.deletes != 0 { diff --git a/modules/brother/cmd/brother/feeder_test.go b/modules/brother/cmd/brother/feeder_test.go new file mode 100644 index 00000000..66dd63f8 --- /dev/null +++ b/modules/brother/cmd/brother/feeder_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "bytes" + "context" + "os" + "strings" + "testing" +) + +// 1452: the Brother ignores the whole body when it holds a line break before the closing root tag, and runs its +// default job (Platen, 200 dpi). The body is one line. +func TestScanSettingsAreOneLineAndTheCapturedRequest(t *testing.T) { + caps, err := parseCapabilities(fixture(t, "capabilities.xml")) + if err != nil { + t.Fatal(err) + } + body, err := ScanSettings(caps, ScanOptions{Resolution: 200, ColorMode: "color", Feeder: true, Duplex: true}) + if err != nil { + t.Fatal(err) + } + end := strings.Index(body, "") + if end < 0 || strings.Contains(body[:end], "\n") { + t.Fatalf("the body has a line break before the closing root tag: %q", body) + } + // It is the request the real scanner took to feed three sheets, both sides (testdata/escl/settings.xml). + if want := strings.TrimSpace(string(fixture(t, "settings.xml"))); strings.TrimSpace(body) != want { + t.Fatalf("not the captured request\n got: %s\nwant: %s", body, want) + } + glass, _ := ScanSettings(caps, ScanOptions{Resolution: 300, ColorMode: "gray"}) + if strings.Contains(glass[:strings.Index(glass, "")], "\n") || strings.Contains(glass, "Duplex") || + !strings.Contains(glass, "Platen") || + !strings.Contains(glass, "24803507") { + t.Fatalf("the glass body is not one line of Platen, A4 cut to the glass's 3507: %q", glass) + } +} + +// 1452: the fake is the real scanner in that: a body with a line break gets the defaults. +func TestTheFakeScannerIgnoresABodyWithALineBreak(t *testing.T) { + multi := strings.Replace(string(fixture(t, "settings.xml")), "", "\n", 1) + if j := readSettings(multi); j.Source != "Platen" || j.Resolution != 200 || j.Duplex { + t.Fatalf("a body with a line break was read as %+v", j) + } + if j := readSettings(strings.TrimSpace(string(fixture(t, "settings.xml"))) + "\n"); j.Source != "Feeder" || !j.Duplex || j.Resolution != 200 { + t.Fatalf("the captured body with a trailing newline was read as %+v", j) + } +} + +// 1452: three sheets, both sides, are six images from one job, fetched until NextDocument answers 404. +func TestAFeederDuplexScanOfThreeSheetsGivesSixPagesAndClosesTheJob(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.sheets = 3 + pages, err := NewClient(f.base()).ScanPages(context.Background(), ScanOptions{Resolution: 200, ColorMode: "color", Feeder: true, Duplex: true}) + if err != nil { + t.Fatal(err) + } + if f.job.Source != "Feeder" || !f.job.Duplex || f.job.Resolution != 200 || f.job.Region != "2480x3508" { + t.Fatalf("the scanner took %+v", f.job) + } + if len(pages) != 6 { + t.Fatalf("%d pages, want 6", len(pages)) + } + if f.open || f.deletes != 0 || f.documents != 7 { + t.Fatalf("job open %v, %d DELETEs, %d NextDocument requests, want closed, 0, 7 (6 pages and the 404)", f.open, f.deletes, f.documents) + } +} + +// 1452: an empty feeder is an error that says so, and no job stays open. +func TestAnEmptyFeederIsAnErrorAndLeavesNoJob(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.sheets = 0 + _, err := NewClient(f.base()).ScanPages(context.Background(), ScanOptions{Resolution: 200, ColorMode: "color", Feeder: true}) + if err == nil || !strings.Contains(err.Error(), "feeder") { + t.Fatalf("error = %v, want it to name the feeder", err) + } + if f.open { + t.Fatal("the job stays open") + } +} + +// 1452: a feeder that never says 404 is cut at 100 pages and the job is deleted. +func TestAFeederThatNeverEndsIsCutAndTheJobDeleted(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.sheets = 500 + _, err := NewClient(f.base()).ScanPages(context.Background(), ScanOptions{Resolution: 100, ColorMode: "gray", Feeder: true}) + if err == nil || !strings.Contains(err.Error(), "100") { + t.Fatalf("error = %v, want the 100-page bound", err) + } + if f.open || f.deletes != 1 { + t.Fatalf("job open %v, %d DELETEs, want closed by 1 DELETE", f.open, f.deletes) + } +} + +// 1452: a page that cannot be had in the middle of a feeder job deletes the job. +func TestAFeederJobThatDropsMidwayIsDeleted(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.sheets = 3 + f.dropConn = true + if _, err := NewClient(f.base()).ScanPages(context.Background(), ScanOptions{Resolution: 200, ColorMode: "color", Feeder: true}); err == nil { + t.Fatal("a lost answer is a scan") + } + if f.open || f.deletes != 1 { + t.Fatalf("job open %v, %d DELETEs, want closed by 1 DELETE", f.open, f.deletes) + } +} + +func scanArgs(source string, duplex bool) map[string]any { + a := map[string]any{} + if source != "" { + a["source"] = source + } + if duplex { + a["duplex"] = true + } + return a +} + +// 1452: through the tool, a duplex feeder scan is ONE PDF of six pages, filed once, and scan.added says pages 6. +func TestTheToolFilesOneSixPagePDFForAFeederDuplexScan(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + f.sheets = 3 + r, folder := scanRig(t, f, Settings{Crop: true, Resolution: 200}) + got, err := r.call(t, "scanner.scan", scanArgs("feeder", true)) + if err != nil { + t.Fatal(err) + } + body := got.(map[string]any) + if body["pages"] != float64(6) || body["cropped"] != false { + t.Errorf("pages = %v, cropped = %v, want 6, false (a feeder page is a whole sheet)", body["pages"], body["cropped"]) + } + pdf, err := os.ReadFile(body["path"].(string)) + if err != nil { + t.Fatal(err) + } + if n := bytes.Count(pdf, []byte("/Type /Page /Parent")); n != 6 || !bytes.Contains(pdf, []byte("/Count 6")) { + t.Errorf("the PDF has %d page objects, want 6 and /Count 6", n) + } + if f.open || f.deletes != 0 || f.posts != 1 { + t.Errorf("job open %v, %d DELETEs, %d posts", f.open, f.deletes, f.posts) + } + if entries, _ := os.ReadDir(folder); len(entries) != 1 { + t.Errorf("%d files in the scan folder, want 1", len(entries)) + } + if out := *r.said; len(out) != 1 || out[0].body["pages"] != 6 || !out[0].seen { + t.Errorf("events = %+v, want one scan.added with pages 6, said after the scan was kept", out) + } +} + +// 1452: the glass is one page, and duplex is for the feeder. +func TestDuplexOnTheGlassIsRefusedAndNothingIsAsked(t *testing.T) { + f := newFakeScanner(t, fixture(t, "scan.jpg")) + r, _ := scanRig(t, f, Settings{}) + for _, args := range []map[string]any{scanArgs("", true), scanArgs("glass", true)} { + _, err := r.call(t, "scanner.scan", args) + if err == nil || !strings.Contains(err.Error(), "duplex") || !strings.Contains(err.Error(), "feeder") { + t.Errorf("%v: error = %v, want duplex is for the feeder", args, err) + } + } + if _, err := r.call(t, "scanner.scan", scanArgs("tray", false)); err == nil || !strings.Contains(err.Error(), "glass or feeder") { + t.Errorf("an unknown source: error = %v", err) + } + if r.bus.asks() != 0 || f.posts != 0 { + t.Errorf("%d asks queued, %d jobs posted", r.bus.asks(), f.posts) + } +} diff --git a/modules/brother/cmd/brother/helpers_test.go b/modules/brother/cmd/brother/helpers_test.go index 1869a718..4ce7c010 100644 --- a/modules/brother/cmd/brother/helpers_test.go +++ b/modules/brother/cmd/brother/helpers_test.go @@ -5,6 +5,9 @@ import ( "net/http/httptest" "os" "path/filepath" + "regexp" + "strconv" + "strings" "sync" "testing" ) @@ -39,6 +42,55 @@ type fakeScanner struct { gone bool // the first NextDocument answers 410 (the job was cancelled at the device) dropConn bool // the first NextDocument loses its answer stuck bool // the job cannot be closed: NextDocument and DELETE both fail + // sheets is what the feeder holds; a duplex job gives two pages a sheet. + sheets int + // job is what the scanner took of the last body. The real Brother ignores the WHOLE body when it holds a line + // break before the closing root tag (issue 1452) and runs its default job: Platen, 200 dpi, one page. + job scannerJob +} + +type scannerJob struct { + Source string + Resolution int + Duplex bool + Region string // width x height +} + +var ( + reSource = regexp.MustCompile(`(\w+)`) + reXRes = regexp.MustCompile(`(\d+)`) + reRegion = regexp.MustCompile(`(\d+)(\d+)`) +) + +// readSettings is the real scanner's reading of a body: a line break before the closing root tag and every setting +// is ignored. +func readSettings(body string) scannerJob { + end := strings.Index(body, "") + if end < 0 || strings.Contains(body[:end], "\n") { + return scannerJob{Source: "Platen", Resolution: 200, Region: "1696x2203"} + } + j := scannerJob{Source: "Platen", Resolution: 200, Duplex: strings.Contains(body, "true")} + if m := reSource.FindStringSubmatch(body); m != nil { + j.Source = m[1] + } + if m := reXRes.FindStringSubmatch(body); m != nil { + j.Resolution, _ = strconv.Atoi(m[1]) + } + if m := reRegion.FindStringSubmatch(body); m != nil { + j.Region = m[1] + "x" + m[2] + } + return j +} + +// total is how many pages the job holds. +func (f *fakeScanner) total() int { + if f.job.Source == "Feeder" { + if f.job.Duplex { + return 2 * f.sheets + } + return f.sheets + } + return 1 + f.pages } func newFakeScanner(t *testing.T, image []byte) *fakeScanner { @@ -64,6 +116,7 @@ func newFakeScanner(t *testing.T, image []byte) *fakeScanner { n, _ := r.Body.Read(b) f.postBody = string(b[:n]) f.posts++ + f.job = readSettings(f.postBody) if f.refuse != 0 { http.Error(w, "busy", f.refuse) return @@ -97,7 +150,7 @@ func newFakeScanner(t *testing.T, image []byte) *fakeScanner { } return } - if f.noPage || (f.delivered && f.served > f.pages) { + if f.noPage || f.served >= f.total() { f.open = false http.NotFound(w, r) return diff --git a/modules/brother/cmd/brother/main.go b/modules/brother/cmd/brother/main.go index bd654be1..99ec4b1f 100644 --- a/modules/brother/cmd/brother/main.go +++ b/modules/brother/cmd/brother/main.go @@ -213,19 +213,36 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool { }, { Name: "scanner.scan", - Description: "Scan the glass of the configured network scanner: the device process cuts the document out of the platen's " + - "white, keeps it as a one-page PDF in the module's bucket under its sha256 and says scan.added on the bus; this " + + 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 " + "tool then fetches it and files it in the scan folder. Waits up to three minutes. Answers the event's body: " + - "the object's key, its size and sha256, its real size in millimetres, the resolution the scanner really gave, " + - "whether it was cropped, and the file's path. The image itself is never sent.", - Run: func(map[string]any) (any, error) { + "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. " + + "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"}, + "duplex": map[string]any{"type": "boolean", "description": "feeder only: scan both sides of each sheet (default false)"}, + }}, + Run: func(args map[string]any) (any, error) { + source, err := strArg(args, "source") + if err != nil { + return nil, err + } + duplex := false + if v, ok := args["duplex"]; ok { + if duplex, ok = v.(bool); !ok { + return nil, fmt.Errorf("duplex is true or false") + } + } s, err := service() if err != nil { return nil, err } ctx, cancel := context.WithTimeout(context.Background(), scanWait+10*time.Minute) defer cancel() - return s.ScanNow(ctx) + return s.ScanNow(ctx, source, duplex) }, }, { diff --git a/modules/brother/cmd/brother/pdf.go b/modules/brother/cmd/brother/pdf.go index f394d924..a55e6381 100644 --- a/modules/brother/cmd/brother/pdf.go +++ b/modules/brother/cmd/brother/pdf.go @@ -5,42 +5,64 @@ import ( "fmt" "image/color" "image/jpeg" + "strings" ) // PDF writes a one-page PDF holding the JPEG as it is (DCTDecode), the page as large as the image really is: // pixels over dpi, in points (72 to the inch). func PDF(jpegBytes []byte, dpi int) ([]byte, error) { - if dpi <= 0 { - return nil, fmt.Errorf("resolution %d dpi is no resolution", dpi) - } - cfg, err := jpeg.DecodeConfig(bytes.NewReader(jpegBytes)) - if err != nil { - return nil, fmt.Errorf("the image cannot be read: %w", err) - } - space := "DeviceRGB" - if cfg.ColorModel == color.GrayModel { - space = "DeviceGray" - } - wpt := float64(cfg.Width) / float64(dpi) * 72 - hpt := float64(cfg.Height) / float64(dpi) * 72 - content := fmt.Sprintf("q %.2f 0 0 %.2f 0 0 cm /Im0 Do Q\n", wpt, hpt) + return PDFPages([]PDFPage{{JPEG: jpegBytes, DPI: dpi}}) +} +// PDFPage is one image and its own resolution. +type PDFPage struct { + JPEG []byte + DPI int +} + +// PDFPages writes a PDF with one page for each image, in order, each as it is (DCTDecode) and as large as it really +// is. +func PDFPages(pages []PDFPage) ([]byte, error) { + if len(pages) == 0 { + return nil, fmt.Errorf("a PDF needs a page") + } var b bytes.Buffer var offsets []int - obj := func(body string) { + b.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n") + // Objects 1 and 2 are the catalog and the page tree; page i is objects 3+3i (page), 4+3i (image), 5+3i (content). + add := func(body string) { offsets = append(offsets, b.Len()) fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", len(offsets), body) } - b.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n") - obj("<< /Type /Catalog /Pages 2 0 R >>") - obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") - obj(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f] /Resources << /XObject << /Im0 4 0 R >> >> /Contents 5 0 R >>", wpt, hpt)) - offsets = append(offsets, b.Len()) - fmt.Fprintf(&b, "4 0 obj\n<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /%s /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", - cfg.Width, cfg.Height, space, len(jpegBytes)) - b.Write(jpegBytes) - b.WriteString("\nendstream\nendobj\n") - obj(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content)) + kids := make([]string, len(pages)) + for i := range pages { + kids[i] = fmt.Sprintf("%d 0 R", 3+3*i) + } + add("<< /Type /Catalog /Pages 2 0 R >>") + add(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), len(pages))) + for i, pg := range pages { + if pg.DPI <= 0 { + return nil, fmt.Errorf("resolution %d dpi is no resolution", pg.DPI) + } + cfg, err := jpeg.DecodeConfig(bytes.NewReader(pg.JPEG)) + if err != nil { + return nil, fmt.Errorf("the image of page %d cannot be read: %w", i+1, err) + } + space := "DeviceRGB" + if cfg.ColorModel == color.GrayModel { + space = "DeviceGray" + } + wpt := float64(cfg.Width) / float64(pg.DPI) * 72 + hpt := float64(cfg.Height) / float64(pg.DPI) * 72 + content := fmt.Sprintf("q %.2f 0 0 %.2f 0 0 cm /Im0 Do Q\n", wpt, hpt) + add(fmt.Sprintf("<< /Type /Page /Parent 2 0 R /MediaBox [0 0 %.2f %.2f] /Resources << /XObject << /Im0 %d 0 R >> >> /Contents %d 0 R >>", wpt, hpt, 4+3*i, 5+3*i)) + offsets = append(offsets, b.Len()) + fmt.Fprintf(&b, "%d 0 obj\n<< /Type /XObject /Subtype /Image /Width %d /Height %d /ColorSpace /%s /BitsPerComponent 8 /Filter /DCTDecode /Length %d >>\nstream\n", + len(offsets), cfg.Width, cfg.Height, space, len(pg.JPEG)) + b.Write(pg.JPEG) + b.WriteString("\nendstream\nendobj\n") + add(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content)) + } xref := b.Len() fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1) for _, o := range offsets { diff --git a/modules/brother/cmd/brother/printer.go b/modules/brother/cmd/brother/printer.go index ddb2b4de..d149dcaf 100644 --- a/modules/brother/cmd/brother/printer.go +++ b/modules/brother/cmd/brother/printer.go @@ -71,6 +71,8 @@ type Ask struct { Copies int `json:"copies,omitempty"` // print Sides string `json:"sides,omitempty"` // print Media string `json:"media,omitempty"` // print + Source string `json:"source,omitempty"` // scan: glass (default) or feeder + Duplex bool `json:"duplex,omitempty"` // scan: both sides of each sheet, feeder only } // AskBus is how the tools reach the device process: they queue an ask and read an answer back. diff --git a/modules/brother/cmd/brother/scan.go b/modules/brother/cmd/brother/scan.go index 0c939669..c97c3471 100644 --- a/modules/brother/cmd/brother/scan.go +++ b/modules/brother/cmd/brother/scan.go @@ -29,11 +29,14 @@ type Service struct { // the stored scan's key, fetches its parts through the device process, joins them, checks their SHA-256 and files // the PDF in the scan folder (temp and rename, no overwrite). It answers the event's body and the path. Nothing is // asked before scanner and scan-folder are known to be set (1440/AC7); the tool never reads a credential. -func (s *Service) ScanNow(ctx context.Context) (map[string]any, error) { +func (s *Service) ScanNow(ctx context.Context, source string, duplex bool) (map[string]any, error) { if err := s.Settings.Missing(); err != nil { return nil, err } - ans, err := s.call(ctx, Ask{Op: OpScan}, s.ScanWait) + if _, err := ParseSource(source, duplex); err != nil { + return nil, err + } + ans, err := s.call(ctx, Ask{Op: OpScan, Source: source, Duplex: duplex}, s.ScanWait) if err != nil { return nil, err } diff --git a/modules/brother/cmd/brother/service_test.go b/modules/brother/cmd/brother/service_test.go index f91a8901..01f6fc8b 100644 --- a/modules/brother/cmd/brother/service_test.go +++ b/modules/brother/cmd/brother/service_test.go @@ -90,13 +90,13 @@ func TestScanNowEmitsOneEventAfterTheScanIsKeptUnderTheSha256(t *testing.T) { if !ev.seen { t.Error("the event was said before the PDF was kept in the bucket") } - for _, k := range []string{"object", "node", "bytes", "sha256", "width-mm", "height-mm", "resolution", "color-mode", "cropped", "scanned-at"} { + for _, k := range []string{"object", "node", "bytes", "sha256", "width-mm", "height-mm", "resolution", "color-mode", "cropped", "pages", "scanned-at"} { if _, ok := ev.body[k]; !ok { t.Errorf("the event lacks %s", k) } } - if len(ev.body) != 10 { - t.Errorf("the event has %d keys, want the ten named (no path: filing is the tool's)", len(ev.body)) + if len(ev.body) != 11 { + t.Errorf("the event has %d keys, want the eleven named (no path: filing is the tool's)", len(ev.body)) } pdf, err := os.ReadFile(body["path"].(string)) if err != nil { @@ -116,8 +116,8 @@ func TestScanNowEmitsOneEventAfterTheScanIsKeptUnderTheSha256(t *testing.T) { if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want { t.Errorf("scanned-at = %v, want %v", ev.body["scanned-at"], want) } - if bytes.Contains(pdf, []byte("scanned-at")) || len(body) != 11 { - t.Errorf("the tool's answer has %d keys, want the event's ten and the path", len(body)) + if bytes.Contains(pdf, []byte("scanned-at")) || len(body) != 12 { + t.Errorf("the tool's answer has %d keys, want the event's eleven and the path", len(body)) } if _, has := body["key"]; has { t.Errorf("the device process's own answer keys leak into the tool's: %v", body) diff --git a/modules/brother/cmd/brother/testdata/escl/doc-headers-feeder.txt b/modules/brother/cmd/brother/testdata/escl/doc-headers-feeder.txt new file mode 100644 index 00000000..77d8d204 --- /dev/null +++ b/modules/brother/cmd/brother/testdata/escl/doc-headers-feeder.txt @@ -0,0 +1,6 @@ +HTTP/1.1 200 OK +Content-Location: /eSCL/ScanJobs/1b61ed02-ccda-11f1-809a-94ddf83ec78a/doc-33 +Accept-Encoding: identity +Content-Type: image/jpeg +Transfer-Encoding: chunked + diff --git a/modules/brother/cmd/brother/testdata/escl/settings.xml b/modules/brother/cmd/brother/testdata/escl/settings.xml index ad341752..49875c03 100644 --- a/modules/brother/cmd/brother/testdata/escl/settings.xml +++ b/modules/brother/cmd/brother/testdata/escl/settings.xml @@ -1,9 +1 @@ - - -2.63 -escl:ThreeHundredthsOfInches0025503507 -Platen -RGB24 -300300 -image/jpeg - +2.0Documentescl:ThreeHundredthsOfInches0024803508FeederRGB24image/jpeg200200true \ No newline at end of file diff --git a/modules/brother/cmd/brother/testdata/escl/status-feeder-after.xml b/modules/brother/cmd/brother/testdata/escl/status-feeder-after.xml new file mode 100644 index 00000000..8fc68e7c --- /dev/null +++ b/modules/brother/cmd/brother/testdata/escl/status-feeder-after.xml @@ -0,0 +1 @@ +2.93IdleScannerAdfEmpty/eSCL/ScanJobs/b485ab7c-ccd5-11f1-808b-94ddf83ec78aurn:uuid:b485ab7c-ccd5-11f1-808b-94ddf83ec78a4414011CanceledJobTimedOut/eSCL/ScanJobs/4e7214ce-ccd9-11f1-808c-94ddf83ec78aurn:uuid:4e7214ce-ccd9-11f1-808c-94ddf83ec78a4265311CompletedJobCompletedSuccessfully/eSCL/ScanJobs/7b54fca6-ccd9-11f1-808d-94ddf83ec78aurn:uuid:7b54fca6-ccd9-11f1-808d-94ddf83ec78a4257711CompletedJobCompletedSuccessfully/eSCL/ScanJobs/b4131a1f-ccd9-11f1-808e-94ddf83ec78aurn:uuid:b4131a1f-ccd9-11f1-808e-94ddf83ec78a4248311CompletedJobCompletedSuccessfully/eSCL/ScanJobs/c7806726-ccd9-11f1-808f-94ddf83ec78aurn:uuid:c7806726-ccd9-11f1-808f-94ddf83ec78a4244611CompletedJobCompletedSuccessfully/eSCL/ScanJobs/d191b5e8-ccd9-11f1-8090-94ddf83ec78aurn:uuid:d191b5e8-ccd9-11f1-8090-94ddf83ec78a4241911CompletedJobCompletedSuccessfully/eSCL/ScanJobs/dcf4d7a8-ccd9-11f1-8091-94ddf83ec78aurn:uuid:dcf4d7a8-ccd9-11f1-8091-94ddf83ec78a4241011CompletedJobCompletedSuccessfully/eSCL/ScanJobs/e7297661-ccd9-11f1-8092-94ddf83ec78aurn:uuid:e7297661-ccd9-11f1-8092-94ddf83ec78a4239611CompletedJobCompletedSuccessfully/eSCL/ScanJobs/ea648438-ccd9-11f1-8093-94ddf83ec78aurn:uuid:ea648438-ccd9-11f1-8093-94ddf83ec78a4238811CompletedJobCompletedSuccessfully/eSCL/ScanJobs/eef481b7-ccd9-11f1-8094-94ddf83ec78aurn:uuid:eef481b7-ccd9-11f1-8094-94ddf83ec78a4238111CompletedJobCompletedSuccessfully/eSCL/ScanJobs/f3905534-ccd9-11f1-8095-94ddf83ec78aurn:uuid:f3905534-ccd9-11f1-8095-94ddf83ec78a4237111CompletedJobCompletedSuccessfully/eSCL/ScanJobs/fcc4b0d9-ccd9-11f1-8096-94ddf83ec78aurn:uuid:fcc4b0d9-ccd9-11f1-8096-94ddf83ec78a4236111CompletedJobCompletedSuccessfully/eSCL/ScanJobs/ff65aed7-ccd9-11f1-8097-94ddf83ec78aurn:uuid:ff65aed7-ccd9-11f1-8097-94ddf83ec78a4235011CompletedJobCompletedSuccessfully/eSCL/ScanJobs/0a839f54-ccda-11f1-8098-94ddf83ec78aurn:uuid:0a839f54-ccda-11f1-8098-94ddf83ec78a4233811CompletedJobCompletedSuccessfully/eSCL/ScanJobs/0d2ad6a6-ccda-11f1-8099-94ddf83ec78aurn:uuid:0d2ad6a6-ccda-11f1-8099-94ddf83ec78a4232911CompletedJobCompletedSuccessfully/eSCL/ScanJobs/1b61ed02-ccda-11f1-809a-94ddf83ec78aurn:uuid:1b61ed02-ccda-11f1-809a-94ddf83ec78a4229360CompletedJobCompletedSuccessfully \ No newline at end of file diff --git a/modules/brother/module.json b/modules/brother/module.json index 929cd643..e30094fb 100644 --- a/modules/brother/module.json +++ b/modules/brother/module.json @@ -63,7 +63,7 @@ }, "crop": { "type": "boolean", - "description": "Whether the document is cut out of the platen's white lid; false files the whole platen.", + "description": "Whether the document is cut out of the platen's white lid; false files the whole platen. A feeder scan is never cropped.", "trusted": false, "default": true }