455 lines
16 KiB
Go
455 lines
16 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"encoding/xml"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"os"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// eSCL (the AirScan protocol) over HTTP: POST <base>/ScanJobs answers 201 and the job's Location, GET
|
|
// <Location>/NextDocument answers the image, and a second NextDocument answers 404 once the job is done (that is what closes it; DELETE of the job does too).
|
|
|
|
// maxImage bounds what a scanner may send: a 600 dpi A4 JPEG is a few tens of megabytes.
|
|
const maxImage = 256 << 20
|
|
|
|
// Client speaks eSCL to one scanner.
|
|
type Client struct {
|
|
Base string
|
|
HTTP *http.Client
|
|
RetryWait time.Duration // the wait before the one retry of a busy scanner (default 4 s)
|
|
Logf func(format string, a ...any)
|
|
}
|
|
|
|
// NewClient is a client for the eSCL base URL, with a timeout that outlasts a slow scan.
|
|
func NewClient(base string) *Client {
|
|
return &Client{Base: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 3 * time.Minute}}
|
|
}
|
|
|
|
// 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 // the platen
|
|
Feeder, FeederDuplex Size
|
|
}
|
|
|
|
type capabilitiesXML struct {
|
|
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.
|
|
type Status struct {
|
|
State string `xml:"State" json:"state"`
|
|
AdfState string `xml:"AdfState" json:"adf-state"`
|
|
}
|
|
|
|
func (c *Client) get(ctx context.Context, rawURL string) (*http.Response, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return c.HTTP.Do(req)
|
|
}
|
|
|
|
func readBody(resp *http.Response, limit int64) ([]byte, error) {
|
|
defer resp.Body.Close()
|
|
return io.ReadAll(io.LimitReader(resp.Body, limit))
|
|
}
|
|
|
|
// Capabilities asks the scanner how large its glass is.
|
|
func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) {
|
|
resp, err := c.get(ctx, c.Base+"/ScannerCapabilities")
|
|
if err != nil {
|
|
return Capabilities{}, fmt.Errorf("the scanner does not answer: %w", err)
|
|
}
|
|
body, err := readBody(resp, 1<<20)
|
|
if err != nil {
|
|
return Capabilities{}, fmt.Errorf("the scanner's capabilities cannot be read: %w", err)
|
|
}
|
|
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)
|
|
}
|
|
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,
|
|
Feeder: Size{x.AdfWidth, x.AdfHeight}, FeederDuplex: Size{x.DuplexWidth, x.DuplexHeight}}, nil
|
|
}
|
|
|
|
// Status asks what the scanner is doing.
|
|
func (c *Client) Status(ctx context.Context) (Status, error) {
|
|
resp, err := c.get(ctx, c.Base+"/ScannerStatus")
|
|
if err != nil {
|
|
return Status{}, fmt.Errorf("the scanner does not answer: %w", err)
|
|
}
|
|
body, err := readBody(resp, 1<<20)
|
|
if err != nil {
|
|
return Status{}, fmt.Errorf("the scanner's status cannot be read: %w", err)
|
|
}
|
|
if resp.StatusCode != http.StatusOK {
|
|
return Status{}, fmt.Errorf("the scanner refused its status: %s", resp.Status)
|
|
}
|
|
var s Status
|
|
if err := xml.Unmarshal(body, &s); err != nil {
|
|
return Status{}, fmt.Errorf("the scanner's status is not XML: %w", err)
|
|
}
|
|
return s, nil
|
|
}
|
|
|
|
// colorModes maps the config's words to eSCL's.
|
|
var colorModes = map[string]string{"color": "RGB24", "gray": "Grayscale8"}
|
|
|
|
// 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
|
|
// Bound is how long a feeder job may have run when it has this many pages and is asked for the next; nil is
|
|
// FeederBound. The job is deleted and an error given when it has run longer.
|
|
Bound func(pages int) time.Duration
|
|
}
|
|
|
|
// FeederBound is how long a feeder job may run when it has this many pages (issue 1539): 20 seconds for each,
|
|
// at least 3 minutes and at most 30. The scanner gives a duplex sheet about every 3.4 seconds, so this is generous
|
|
// and only ends a job that is stuck.
|
|
func FeederBound(pages int) time.Duration {
|
|
return min(max(time.Duration(pages)*20*time.Second, 3*time.Minute), FeederMaxBound)
|
|
}
|
|
|
|
// FeederMaxBound is the longest a feeder job may run.
|
|
const FeederMaxBound = 30 * time.Minute
|
|
|
|
// 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, o)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
job, err := c.createJob(ctx, settings)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
doc := job + "/NextDocument"
|
|
if o.Feeder {
|
|
pages, err := c.feederPages(ctx, doc, o.Bound)
|
|
if err != nil {
|
|
c.deleteJob(job)
|
|
return nil, err
|
|
}
|
|
return pages, nil
|
|
}
|
|
image, err := c.page(ctx, doc)
|
|
if err != nil {
|
|
c.deleteJob(job)
|
|
return nil, err
|
|
}
|
|
closed, extra := c.closeJob(ctx, doc)
|
|
if !closed {
|
|
c.deleteJob(job)
|
|
}
|
|
if extra > 0 {
|
|
return nil, fmt.Errorf("the scanner gave more pages than the glass holds: %d", 1+extra)
|
|
}
|
|
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, bound func(int) time.Duration) ([][]byte, error) {
|
|
if bound == nil {
|
|
bound = FeederBound
|
|
}
|
|
start := time.Now()
|
|
var pages [][]byte
|
|
for len(pages) <= maxFeederPages {
|
|
if len(pages) > 0 {
|
|
if run, limit := time.Since(start), bound(len(pages)); run > limit {
|
|
return nil, fmt.Errorf("the feeder scan has run %s and has %d pages, past its bound of %s: it is stopped", run.Round(time.Second), len(pages), limit)
|
|
}
|
|
}
|
|
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.
|
|
var leftOpen sync.Map
|
|
|
|
func (c *Client) logf(format string, a ...any) {
|
|
if c.Logf != nil {
|
|
c.Logf(format, a...)
|
|
return
|
|
}
|
|
fmt.Fprintf(os.Stderr, format+"\n", a...)
|
|
}
|
|
|
|
func (c *Client) retryWait() time.Duration {
|
|
if c.RetryWait > 0 {
|
|
return c.RetryWait
|
|
}
|
|
return 4 * time.Second
|
|
}
|
|
|
|
// createJob posts the settings and returns the job's URL. A 503 is a scanner busy with another job; when the job
|
|
// brother itself left open on this scanner is now closed, the post goes once more, never more than once.
|
|
func (c *Client) createJob(ctx context.Context, settings string) (string, error) {
|
|
resp, reply, err := c.postJob(ctx, settings)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
if resp.StatusCode == http.StatusServiceUnavailable {
|
|
if left, ok := leftOpen.Load(c.Base); ok {
|
|
select {
|
|
case <-time.After(c.retryWait()):
|
|
case <-ctx.Done():
|
|
return "", ctx.Err()
|
|
}
|
|
if c.deleteJob(left.(string)) {
|
|
if resp, reply, err = c.postJob(ctx, settings); err != nil {
|
|
return "", err
|
|
}
|
|
}
|
|
}
|
|
if resp.StatusCode == http.StatusServiceUnavailable {
|
|
return "", fmt.Errorf("the scanner is busy with another job: %s %s", resp.Status, strings.TrimSpace(string(reply)))
|
|
}
|
|
}
|
|
if resp.StatusCode != http.StatusCreated {
|
|
return "", fmt.Errorf("the scanner refused the scan job: %s %s", resp.Status, strings.TrimSpace(string(reply)))
|
|
}
|
|
loc := resp.Header.Get("Location")
|
|
if loc == "" {
|
|
return "", fmt.Errorf("the scanner took the scan job but named no Location")
|
|
}
|
|
base, _ := url.Parse(c.Base + "/")
|
|
ref, err := url.Parse(loc)
|
|
if err != nil {
|
|
return "", fmt.Errorf("the scan job's Location %q is not a URL: %w", loc, err)
|
|
}
|
|
return strings.TrimRight(base.ResolveReference(ref).String(), "/"), nil
|
|
}
|
|
|
|
func (c *Client) postJob(ctx context.Context, settings string) (*http.Response, []byte, error) {
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/ScanJobs", strings.NewReader(settings))
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
req.Header.Set("Content-Type", "text/xml")
|
|
resp, err := c.HTTP.Do(req)
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("the scanner does not answer: %w", err)
|
|
}
|
|
reply, _ := readBody(resp, 1<<20)
|
|
return resp, reply, nil
|
|
}
|
|
|
|
// page fetches the job's one page.
|
|
func (c *Client) page(ctx context.Context, doc string) ([]byte, error) {
|
|
got, err := c.get(ctx, doc)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the scanned page cannot be fetched: %w", err)
|
|
}
|
|
image, err := readBody(got, maxImage)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("the scanned page cannot be read: %w", err)
|
|
}
|
|
if got.StatusCode == http.StatusGone {
|
|
return nil, fmt.Errorf("the scanner dropped the job: %s", got.Status)
|
|
}
|
|
if got.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("the scanner gave no page: %s", got.Status)
|
|
}
|
|
if ct := got.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
|
|
return nil, fmt.Errorf("the scanner sent %q, not image/jpeg", ct)
|
|
}
|
|
return image, nil
|
|
}
|
|
|
|
// maxClosing bounds the NextDocument requests that ask for the end of a job.
|
|
const maxClosing = 1
|
|
|
|
// closeJob asks NextDocument until the scanner answers 404, which ends the job; true when it did (or the job is
|
|
// gone already), and how many further pages it sent instead; any is an error for the caller, for a Platen job holds
|
|
// one page. It stops at the first further page, and the job is then deleted.
|
|
func (c *Client) closeJob(ctx context.Context, doc string) (closed bool, extra int) {
|
|
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
|
|
defer cancel()
|
|
for i := 0; i < maxClosing; i++ {
|
|
resp, err := c.get(ctx, doc)
|
|
if err != nil {
|
|
c.logf("brother: closing the scan job: %v", err)
|
|
return false, 0
|
|
}
|
|
readBody(resp, maxImage)
|
|
switch resp.StatusCode {
|
|
case http.StatusNotFound, http.StatusGone:
|
|
return true, 0
|
|
case http.StatusOK:
|
|
c.logf("brother: the scanner sent a further page after the glass's one")
|
|
return false, 1
|
|
}
|
|
c.logf("brother: closing the scan job: NextDocument answered %s", resp.Status)
|
|
return false, 0
|
|
}
|
|
return false, 0
|
|
}
|
|
|
|
// deleteJob deletes the job, best effort; true when the job is gone. A job that cannot be deleted is remembered, so
|
|
// a 503 on the next scan can be told from another client's job.
|
|
func (c *Client) deleteJob(job string) bool {
|
|
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
|
|
defer cancel()
|
|
req, err := http.NewRequestWithContext(ctx, http.MethodDelete, job, nil)
|
|
if err == nil {
|
|
var resp *http.Response
|
|
if resp, err = c.HTTP.Do(req); err == nil {
|
|
readBody(resp, 1<<20)
|
|
if resp.StatusCode/100 == 2 || resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusGone {
|
|
leftOpen.Delete(c.Base)
|
|
return true
|
|
}
|
|
err = fmt.Errorf("answered %s", resp.Status)
|
|
}
|
|
}
|
|
c.logf("brother: deleting the scan job %s: %v", job, err)
|
|
leftOpen.Store(c.Base, job)
|
|
return false
|
|
}
|
|
|
|
// Density is the image's own resolution in dpi, from its JFIF header (units dpi, or dots per cm).
|
|
func Density(jpegBytes []byte) (int, error) {
|
|
b := jpegBytes
|
|
if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 {
|
|
return 0, fmt.Errorf("the image is not a JPEG")
|
|
}
|
|
for i := 2; i+4 <= len(b) && b[i] == 0xFF; {
|
|
marker, n := b[i+1], int(b[i+2])<<8|int(b[i+3])
|
|
if marker == 0xE0 && n >= 16 && i+4+12 <= len(b) && string(b[i+4:i+9]) == "JFIF\x00" {
|
|
units := b[i+11]
|
|
x := int(b[i+12])<<8 | int(b[i+13])
|
|
switch {
|
|
case units == 1 && x > 0:
|
|
return x, nil
|
|
case units == 2 && x > 0:
|
|
return int(float64(x)*2.54 + 0.5), nil
|
|
}
|
|
return 0, fmt.Errorf("the image's JFIF header gives no resolution")
|
|
}
|
|
if marker == 0xDA || n < 2 {
|
|
break
|
|
}
|
|
i += 2 + n
|
|
}
|
|
return 0, fmt.Errorf("the image has no JFIF header, so its resolution is unknown")
|
|
}
|