Merge remote-tracking branch 'origin/main' into feat/mail-reading
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
|
||||
|
||||
1. Asks the scanner for its glass size and puts one job to it (`POST <scanner>/ScanJobs`): the platen, the whole
|
||||
glass, at the configured resolution and colour mode, as a JPEG; it fetches the page once.
|
||||
1. Asks the scanner for its sizes and puts one job to it (`POST <scanner>/ScanJobs`) at the configured resolution
|
||||
and colour mode, as a JPEG: from the glass (`source` glass, the default) or from the document feeder (`source`
|
||||
feeder, with `duplex` true for both sides of each sheet; refused on the glass). The region is A4, cut to what the
|
||||
input can scan (the glass is 3507 high, not A4's 3508). The body is written on one line: the Brother ignores the
|
||||
whole body, and runs its default job, when it holds a line break (issue 1452). The glass page is fetched once;
|
||||
a feeder job is asked `NextDocument` until the scanner answers 404, at most 100 pages. A job that fails is deleted.
|
||||
2. Reads the resolution from the image's own JFIF density, never from the request: a scanner may answer a
|
||||
300 dpi request with 200 dpi, and the page size and the event's millimetres follow what it gave.
|
||||
3. Crops: the lid's level is measured in the corner farthest from where documents lie, a pixel is document when
|
||||
3. Crops the glass page (a feeder page is a whole sheet and is never cropped): the lid's level is measured in the corner farthest from where documents lie, a pixel is document when
|
||||
it is darker than the lid, and the rows and columns holding enough of them give the box. No document, or one
|
||||
filling nearly the whole glass, keeps the whole image (`cropped` false), and so does `crop` false.
|
||||
4. Files a one-page PDF holding the JPEG: written whole beside its place as `.scan-*.tmp` and renamed to
|
||||
4. Files a PDF holding the JPEGs, one page for the glass and one per image for the feeder (so six for three
|
||||
sheets both sides), each page as large as its image really is: written whole beside its place as `.scan-*.tmp` and renamed to
|
||||
`scan-YYYY-MM-DD-HHMMSS.pdf` (local time); a taken name gets `-2`, `-3`, and no file is ever overwritten.
|
||||
5. Says `scan.added` once, after the rename, under the file's sha256 as its message id. It never carries the image.
|
||||
|
||||
@@ -50,7 +55,7 @@ makes it (mode 0755) when it files the first scan.
|
||||
|
||||
| verb (tool) | does |
|
||||
|---|---|
|
||||
| `scanner.scan` | asks the device process to scan, crop, keep the PDF in the bucket under its sha256 and say `scan.added`; then fetches it in parts, checks the sha256 and files it in `scan-folder`. Waits up to 180 s. Answers the event's body and `path` |
|
||||
| `scanner.scan` | `source` (`glass`, default, or `feeder`) and `duplex` (feeder only): asks the device process to scan, crop (glass only), keep the PDF in the bucket under its sha256 and say `scan.added`; then fetches it in parts, checks the sha256 and files it in `scan-folder`. Waits up to 180 s. Answers the event's body and `path` |
|
||||
| `scanner.document` | one part of a kept scan (`object`, `part` from 1): `part`, `parts`, `size`, `sha256` (of the whole), `data` (base64). Join the parts and check the sha256 |
|
||||
| `scanner.status` | whether the scanner answers, its state (Idle or Processing) and its document feeder's state; a scanner that does not answer is `answers: false` with why. This is how its health is read: the module has no long-running resource to declare a health check on |
|
||||
| `scanner.list` | the newest filed scans, newest first (`limit`, default 20). Read-only |
|
||||
@@ -110,7 +115,7 @@ the login page again. The logout follows a logout link or form on the page; a pa
|
||||
|
||||
`scan.added`, said by this module, on the wire `mesh.mod.brother.event.scan.added`; a consumer declares `brother.scan.added`. Its body:
|
||||
`path`, `node`, `bytes`, `sha256`, `width-mm`, `height-mm`, `resolution` (dpi, as the scanner gave it),
|
||||
`color-mode`, `cropped`, `scanned-at` (RFC 3339, UTC).
|
||||
`color-mode`, `cropped`, `pages` (1 for the glass), `scanned-at` (RFC 3339, UTC).
|
||||
|
||||
## Files it does not know
|
||||
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"
|
||||
"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
|
||||
|
||||
@@ -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)
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -63,7 +63,7 @@
|
||||
},
|
||||
"crop": {
|
||||
"type": "boolean",
|
||||
"description": "Whether the document is cut out of the platen's white lid; false files the whole platen.",
|
||||
"description": "Whether the document is cut out of the platen's white lid; false files the whole platen. A feeder scan is never cropped.",
|
||||
"trusted": false,
|
||||
"default": true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user