Send brother's scan settings on one line and scan from the feeder, both sides, into one PDF (issue 1452)
mesh/merge-gate head d5f9cd465c96 merged into trunk d62c2edc35f3 — pass: builds brother → shanks; no bus step; every machine composes with the change as it…
mesh/repo-check head d5f9cd465c96 merged into trunk d62c2edc35f3 — pass: its merge-check.sh passed, each part in its toolchain: merge-check.sh (go), merge-…
mesh/delivery delivered

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.
This commit is contained in:
2026-10-21 01:05:55 +02:00
parent 82649bc770
commit d5f9cd465c
17 changed files with 523 additions and 108 deletions
+11 -6
View File
@@ -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 ## What a scan does
1. Asks the scanner for its glass size and puts one job to it (`POST <scanner>/ScanJobs`): the platen, the whole 1. Asks the scanner for its sizes and puts one job to it (`POST <scanner>/ScanJobs`) at the configured resolution
glass, at the configured resolution and colour mode, as a JPEG; it fetches the page once. 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 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. 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 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. 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. `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. 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 | | 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.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.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 | | `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: `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), `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 ## Files it does not know
+63 -16
View File
@@ -1,20 +1,24 @@
package main package main
import ( import (
"bytes"
"context" "context"
"fmt"
"image/jpeg"
"math" "math"
"time" "time"
) )
// The device process's scan (novox/hq ADR 0468 clause 4): the glass scanned over eSCL, the document cut out of // 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). // device process's (deviceproc.go); filing it in the scan folder is the tool's (scan.go).
func mm(pixels, dpi int) float64 { func mm(pixels, dpi int) float64 {
return math.Round(float64(pixels)/float64(dpi)*25.4*10) / 10 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 { type Capture struct {
PDF []byte PDF []byte
SHA256 string SHA256 string
@@ -22,36 +26,78 @@ type Capture struct {
HeightMM float64 HeightMM float64
Resolution int Resolution int
Cropped bool Cropped bool
Pages int
At time.Time 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). // 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() 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 { if err != nil {
return Capture{}, err 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 { if err != nil {
return Capture{}, err return Capture{}, err
} }
cut, err := Crop(scan, s.Settings.Crop) return Capture{PDF: pdf, SHA256: sha256Hex(pdf), WidthMM: mm(first.Width, firstDPI), HeightMM: mm(first.Height, firstDPI),
if err != nil { Resolution: firstDPI, Cropped: first.Cropped, Pages: len(pages), At: at}, 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
} }
// Body is the event scan.added's body (ADR 0464 §5 with the object key of ADR 0468 clause 2): never the image, // 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. // 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 { func (c Capture) Body(node, colorMode string) map[string]any {
pages := max(c.Pages, 1)
return map[string]any{ return map[string]any{
"object": c.SHA256 + ".pdf", "object": c.SHA256 + ".pdf",
"node": node, "node": node,
@@ -62,6 +108,7 @@ func (c Capture) Body(node, colorMode string) map[string]any {
"resolution": c.Resolution, "resolution": c.Resolution,
"color-mode": colorMode, "color-mode": colorMode,
"cropped": c.Cropped, "cropped": c.Cropped,
"pages": pages,
"scanned-at": c.At.UTC().Format(time.RFC3339), "scanned-at": c.At.UTC().Format(time.RFC3339),
} }
} }
+9 -5
View File
@@ -64,7 +64,7 @@ type deviceWorker struct {
Answers Answers Answers Answers
Node string Node string
// Capture scans; nil is the device's own (a test puts a stand-in). // 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 is the raw size of a document part in an answer; zero is AnswerPartSize (a test makes it larger).
answerPart int answerPart int
Now func() time.Time Now func() time.Time
@@ -145,7 +145,7 @@ func (w *deviceWorker) run(ctx context.Context, ask Ask) (map[string]any, error)
case OpSettings: case OpSettings:
return w.read(ctx, ask.Page, ask.Links, ask.Hidden) return w.read(ctx, ask.Page, ask.Links, ask.Hidden)
case OpScan: case OpScan:
return w.scan(ctx) return w.scan(ctx, ask)
case OpDocument: case OpDocument:
return w.document(ctx, ask) return w.document(ctx, ask)
case OpUpload: 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) 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 // 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 // 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. // 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() set, err := w.Settings()
if err != nil { if err != nil {
return nil, err return nil, err
@@ -177,7 +181,7 @@ func (w *deviceWorker) scan(ctx context.Context) (map[string]any, error) {
if w.Capture != nil { if w.Capture != nil {
capture = w.Capture capture = w.Capture
} }
c, err := capture(ctx) c, err := capture(ctx, req)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -474,7 +474,7 @@ func TestAScanLargerThanTheStatesValueLimitIsFiledWholeAndItsAnswerIsSmall(t *te
if len(big) <= StateMaxValue { if len(big) <= StateMaxValue {
t.Fatal("the stand-in is not larger than the state's value limit") 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 return Capture{PDF: big, SHA256: sha256Hex(big), Resolution: 300, At: fixedNow}, nil
} }
body, err := r.scan(t) body, err := r.scan(t)
+125 -25
View File
@@ -32,14 +32,23 @@ func NewClient(base string) *Client {
return &Client{Base: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 3 * time.Minute}} 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 { type Capabilities struct {
MaxWidth, MaxHeight int MaxWidth, MaxHeight int // the platen
Feeder, FeederDuplex Size
} }
type capabilitiesXML struct { type capabilitiesXML struct {
MaxWidth int `xml:"Platen>PlatenInputCaps>MaxWidth"` MaxWidth int `xml:"Platen>PlatenInputCaps>MaxWidth"`
MaxHeight int `xml:"Platen>PlatenInputCaps>MaxHeight"` 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. // Status is what /ScannerStatus says.
@@ -74,6 +83,10 @@ func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) {
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return Capabilities{}, fmt.Errorf("the scanner refused its capabilities: %s", resp.Status) return Capabilities{}, fmt.Errorf("the scanner refused its capabilities: %s", resp.Status)
} }
return parseCapabilities(body)
}
func parseCapabilities(body []byte) (Capabilities, error) {
var x capabilitiesXML var x capabilitiesXML
if err := xml.Unmarshal(body, &x); err != nil { if err := xml.Unmarshal(body, &x); err != nil {
return Capabilities{}, fmt.Errorf("the scanner's capabilities are not XML: %w", err) 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 { if x.MaxWidth <= 0 || x.MaxHeight <= 0 {
return Capabilities{}, fmt.Errorf("the scanner's capabilities name no platen size") 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. // 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. // colorModes maps the config's words to eSCL's.
var colorModes = map[string]string{"color": "RGB24", "gray": "Grayscale8"} 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. // ScanOptions is what one job asks: the resolution and colour mode of the configuration, and the input. Duplex is
func ScanSettings(c Capabilities, resolution int, colorMode string) (string, error) { // for the feeder.
escl, ok := colorModes[colorMode] type ScanOptions struct {
if !ok { Resolution int
return "", fmt.Errorf("color-mode %q is not color or gray", colorMode) ColorMode string
} Feeder bool
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?> Duplex bool
<scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
<pwg:Version>2.63</pwg:Version>
<pwg:ScanRegions><pwg:ScanRegion><pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits><pwg:XOffset>0</pwg:XOffset><pwg:YOffset>0</pwg:YOffset><pwg:Width>%d</pwg:Width><pwg:Height>%d</pwg:Height></pwg:ScanRegion></pwg:ScanRegions>
<pwg:InputSource>Platen</pwg:InputSource>
<scan:ColorMode>%s</scan:ColorMode>
<scan:XResolution>%d</scan:XResolution><scan:YResolution>%d</scan:YResolution>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
</scan:ScanSettings>
`, c.MaxWidth, c.MaxHeight, escl, resolution, resolution), nil
} }
// Scan puts one job to the scanner and returns the JPEG it sends. The resolution asked is only a request: the // A4 in the 300ths of an inch the region is asked in; a region is cut to what the input can scan.
// real one is the image's own density (Density), for the real scanner answered 300 with 200. 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 = "<scan:Duplex>true</scan:Duplex>"
}
return fmt.Sprintf(`<?xml version="1.0" encoding="UTF-8"?><scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">`+
`<pwg:Version>2.0</pwg:Version><scan:Intent>Document</scan:Intent>`+
`<pwg:ScanRegions><pwg:ScanRegion><pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits><pwg:XOffset>0</pwg:XOffset><pwg:YOffset>0</pwg:YOffset><pwg:Width>%d</pwg:Width><pwg:Height>%d</pwg:Height></pwg:ScanRegion></pwg:ScanRegions>`+
`<pwg:InputSource>%s</pwg:InputSource><scan:ColorMode>%s</scan:ColorMode><pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>`+
`<scan:XResolution>%d</scan:XResolution><scan:YResolution>%d</scan:YResolution>%s</scan:ScanSettings>`,
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 // 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 // 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. // 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) { 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) caps, err := c.Capabilities(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
settings, err := ScanSettings(caps, resolution, colorMode) settings, err := ScanSettings(caps, o)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -145,6 +201,14 @@ func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([]
return nil, err return nil, err
} }
doc := job + "/NextDocument" 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) image, err := c.page(ctx, doc)
if err != nil { if err != nil {
c.deleteJob(job) c.deleteJob(job)
@@ -157,7 +221,43 @@ func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([]
if extra > 0 { if extra > 0 {
return nil, fmt.Errorf("the scanner gave more pages than the glass holds: %d", 1+extra) 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. // leftOpen is the job URL, per scanner, that brother could neither finish nor delete.
+4 -6
View File
@@ -7,8 +7,6 @@ import (
"time" "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. // 1440/AC1: the captured capabilities, job creation, document and status replies, served by a test server.
func TestScanPostsOnePlatenJobOverTheWholeGlassAndAsksNextDocumentAgainToCloseIt(t *testing.T) { func TestScanPostsOnePlatenJobOverTheWholeGlassAndAsksNextDocumentAgainToCloseIt(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg")) f := newFakeScanner(t, fixture(t, "scan.jpg"))
@@ -19,10 +17,10 @@ func TestScanPostsOnePlatenJobOverTheWholeGlassAndAsksNextDocumentAgainToCloseIt
if f.posts != 1 { if f.posts != 1 {
t.Fatalf("%d ScanJobs posts, want 1", f.posts) t.Fatalf("%d ScanJobs posts, want 1", f.posts)
} }
// The body is the request the real scanner accepted (testdata/escl/settings.xml): Platen, 2550x3507 // The scanner ignores the whole body when it holds a line break (issue 1452), so the settings must have taken
// 300ths of an inch from the capabilities, RGB24, 300 dpi, image/jpeg. // effect: the Platen, 300 dpi, A4 cut to the glass (2480 x 3507).
if got, want := squeeze(f.postBody), squeeze(string(fixture(t, "settings.xml"))); got != want { if f.job != (scannerJob{Source: "Platen", Resolution: 300, Region: "2480x3507"}) {
t.Fatalf("the job is not the captured request\n got: %s\nwant: %s", got, want) 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. // The page, then the second request that the scanner answers 404, which closes the job.
if f.documents != 2 || f.open || f.deletes != 0 { if f.documents != 2 || f.open || f.deletes != 0 {
+165
View File
@@ -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, "</scan:ScanSettings>")
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, "</scan:ScanSettings>")], "\n") || strings.Contains(glass, "Duplex") ||
!strings.Contains(glass, "<pwg:InputSource>Platen</pwg:InputSource>") ||
!strings.Contains(glass, "<pwg:Width>2480</pwg:Width><pwg:Height>3507</pwg:Height>") {
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")), "<pwg:Version>", "\n<pwg:Version>", 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)
}
}
+54 -1
View File
@@ -5,6 +5,9 @@ import (
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"regexp"
"strconv"
"strings"
"sync" "sync"
"testing" "testing"
) )
@@ -39,6 +42,55 @@ type fakeScanner struct {
gone bool // the first NextDocument answers 410 (the job was cancelled at the device) gone bool // the first NextDocument answers 410 (the job was cancelled at the device)
dropConn bool // the first NextDocument loses its answer dropConn bool // the first NextDocument loses its answer
stuck bool // the job cannot be closed: NextDocument and DELETE both fail 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(`<pwg:InputSource>(\w+)</pwg:InputSource>`)
reXRes = regexp.MustCompile(`<scan:XResolution>(\d+)</scan:XResolution>`)
reRegion = regexp.MustCompile(`<pwg:Width>(\d+)</pwg:Width><pwg:Height>(\d+)</pwg:Height>`)
)
// 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, "</scan:ScanSettings>")
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, "<scan:Duplex>true</scan:Duplex>")}
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 { 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) n, _ := r.Body.Read(b)
f.postBody = string(b[:n]) f.postBody = string(b[:n])
f.posts++ f.posts++
f.job = readSettings(f.postBody)
if f.refuse != 0 { if f.refuse != 0 {
http.Error(w, "busy", f.refuse) http.Error(w, "busy", f.refuse)
return return
@@ -97,7 +150,7 @@ func newFakeScanner(t *testing.T, image []byte) *fakeScanner {
} }
return return
} }
if f.noPage || (f.delivered && f.served > f.pages) { if f.noPage || f.served >= f.total() {
f.open = false f.open = false
http.NotFound(w, r) http.NotFound(w, r)
return return
+23 -6
View File
@@ -213,19 +213,36 @@ func tools(settingsFile string, now func() time.Time) []stdio.Tool {
}, },
{ {
Name: "scanner.scan", Name: "scanner.scan",
Description: "Scan the glass of the configured network scanner: the device process cuts the document out of the platen's " + Description: "Scan with the configured network scanner, from the glass (source glass, the default: one page, cut out of " +
"white, keeps it as a one-page PDF in the module's bucket under its sha256 and says scan.added on the bus; this " + "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: " + "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, " + "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, and the file's path. The image itself is never sent.", "whether it was cropped, the number of pages and the file's path. The image itself is never sent. " +
Run: func(map[string]any) (any, error) { "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() s, err := service()
if err != nil { if err != nil {
return nil, err return nil, err
} }
ctx, cancel := context.WithTimeout(context.Background(), scanWait+10*time.Minute) ctx, cancel := context.WithTimeout(context.Background(), scanWait+10*time.Minute)
defer cancel() defer cancel()
return s.ScanNow(ctx) return s.ScanNow(ctx, source, duplex)
}, },
}, },
{ {
+47 -25
View File
@@ -5,42 +5,64 @@ import (
"fmt" "fmt"
"image/color" "image/color"
"image/jpeg" "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: // 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). // pixels over dpi, in points (72 to the inch).
func PDF(jpegBytes []byte, dpi int) ([]byte, error) { func PDF(jpegBytes []byte, dpi int) ([]byte, error) {
if dpi <= 0 { return PDFPages([]PDFPage{{JPEG: jpegBytes, DPI: dpi}})
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)
// 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 b bytes.Buffer
var offsets []int 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()) offsets = append(offsets, b.Len())
fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", len(offsets), body) fmt.Fprintf(&b, "%d 0 obj\n%s\nendobj\n", len(offsets), body)
} }
b.WriteString("%PDF-1.4\n%\xE2\xE3\xCF\xD3\n") kids := make([]string, len(pages))
obj("<< /Type /Catalog /Pages 2 0 R >>") for i := range pages {
obj("<< /Type /Pages /Kids [3 0 R] /Count 1 >>") kids[i] = fmt.Sprintf("%d 0 R", 3+3*i)
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()) add("<< /Type /Catalog /Pages 2 0 R >>")
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", add(fmt.Sprintf("<< /Type /Pages /Kids [%s] /Count %d >>", strings.Join(kids, " "), len(pages)))
cfg.Width, cfg.Height, space, len(jpegBytes)) for i, pg := range pages {
b.Write(jpegBytes) if pg.DPI <= 0 {
b.WriteString("\nendstream\nendobj\n") return nil, fmt.Errorf("resolution %d dpi is no resolution", pg.DPI)
obj(fmt.Sprintf("<< /Length %d >>\nstream\n%sendstream", len(content), content)) }
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() xref := b.Len()
fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1) fmt.Fprintf(&b, "xref\n0 %d\n0000000000 65535 f \n", len(offsets)+1)
for _, o := range offsets { for _, o := range offsets {
+2
View File
@@ -71,6 +71,8 @@ type Ask struct {
Copies int `json:"copies,omitempty"` // print Copies int `json:"copies,omitempty"` // print
Sides string `json:"sides,omitempty"` // print Sides string `json:"sides,omitempty"` // print
Media string `json:"media,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. // AskBus is how the tools reach the device process: they queue an ask and read an answer back.
+5 -2
View File
@@ -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 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 // 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. // 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 { if err := s.Settings.Missing(); err != nil {
return nil, err 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 { if err != nil {
return nil, err return nil, err
} }
+5 -5
View File
@@ -90,13 +90,13 @@ func TestScanNowEmitsOneEventAfterTheScanIsKeptUnderTheSha256(t *testing.T) {
if !ev.seen { if !ev.seen {
t.Error("the event was said before the PDF was kept in the bucket") 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 { if _, ok := ev.body[k]; !ok {
t.Errorf("the event lacks %s", k) t.Errorf("the event lacks %s", k)
} }
} }
if len(ev.body) != 10 { if len(ev.body) != 11 {
t.Errorf("the event has %d keys, want the ten named (no path: filing is the tool's)", len(ev.body)) 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)) pdf, err := os.ReadFile(body["path"].(string))
if err != nil { if err != nil {
@@ -116,8 +116,8 @@ func TestScanNowEmitsOneEventAfterTheScanIsKeptUnderTheSha256(t *testing.T) {
if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want { if want := fixedNow.UTC().Format(time.RFC3339); ev.body["scanned-at"] != want {
t.Errorf("scanned-at = %v, want %v", 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 { if bytes.Contains(pdf, []byte("scanned-at")) || len(body) != 12 {
t.Errorf("the tool's answer has %d keys, want the event's ten and the path", len(body)) t.Errorf("the tool's answer has %d keys, want the event's eleven and the path", len(body))
} }
if _, has := body["key"]; has { if _, has := body["key"]; has {
t.Errorf("the device process's own answer keys leak into the tool's: %v", body) t.Errorf("the device process's own answer keys leak into the tool's: %v", body)
@@ -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
+1 -9
View File
@@ -1,9 +1 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?><scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm"><pwg:Version>2.0</pwg:Version><scan:Intent>Document</scan:Intent><pwg:ScanRegions><pwg:ScanRegion><pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits><pwg:XOffset>0</pwg:XOffset><pwg:YOffset>0</pwg:YOffset><pwg:Width>2480</pwg:Width><pwg:Height>3508</pwg:Height></pwg:ScanRegion></pwg:ScanRegions><pwg:InputSource>Feeder</pwg:InputSource><scan:ColorMode>RGB24</scan:ColorMode><pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat><scan:XResolution>200</scan:XResolution><scan:YResolution>200</scan:YResolution><scan:Duplex>true</scan:Duplex></scan:ScanSettings>
<scan:ScanSettings xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm">
<pwg:Version>2.63</pwg:Version>
<pwg:ScanRegions><pwg:ScanRegion><pwg:ContentRegionUnits>escl:ThreeHundredthsOfInches</pwg:ContentRegionUnits><pwg:XOffset>0</pwg:XOffset><pwg:YOffset>0</pwg:YOffset><pwg:Width>2550</pwg:Width><pwg:Height>3507</pwg:Height></pwg:ScanRegion></pwg:ScanRegions>
<pwg:InputSource>Platen</pwg:InputSource>
<scan:ColorMode>RGB24</scan:ColorMode>
<scan:XResolution>300</scan:XResolution><scan:YResolution>300</scan:YResolution>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
</scan:ScanSettings>
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -63,7 +63,7 @@
}, },
"crop": { "crop": {
"type": "boolean", "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, "trusted": false,
"default": true "default": true
} }