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