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
+63 -16
View File
@@ -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),
}
}
+9 -5
View File
@@ -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
}
@@ -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)
+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}}
}
// 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(`<?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.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
// 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 = "<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
// 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.
+4 -6
View File
@@ -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 {
+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"
"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(`<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 {
@@ -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
+23 -6
View File
@@ -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)
},
},
{
+47 -25
View File
@@ -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 {
+2
View File
@@ -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.
+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 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
}
+5 -5
View File
@@ -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)
@@ -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"?>
<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>
<?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>
File diff suppressed because one or more lines are too long