issue 1440: the scan module's eSCL client, settings and image density

This commit is contained in:
2026-10-20 20:09:47 +02:00
parent a67f01963f
commit 36a34a49b8
12 changed files with 755 additions and 0 deletions
+201
View File
@@ -0,0 +1,201 @@
package main
import (
"bytes"
"context"
"encoding/xml"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
// eSCL (the AirScan protocol) over HTTP: POST <base>/ScanJobs answers 201 and the job's Location, GET
// <Location>/NextDocument answers the image, and a second NextDocument answers 404 once the job is done.
// maxImage bounds what a scanner may send: a 600 dpi A4 JPEG is a few tens of megabytes.
const maxImage = 256 << 20
// Client speaks eSCL to one scanner.
type Client struct {
Base string
HTTP *http.Client
}
// NewClient is a client for the eSCL base URL, with a timeout that outlasts a slow scan.
func NewClient(base string) *Client {
return &Client{Base: strings.TrimRight(base, "/"), HTTP: &http.Client{Timeout: 3 * time.Minute}}
}
// Capabilities are the platen's largest scan region, in the 300ths of an inch the region is asked in.
type Capabilities struct {
MaxWidth, MaxHeight int
}
type capabilitiesXML struct {
MaxWidth int `xml:"Platen>PlatenInputCaps>MaxWidth"`
MaxHeight int `xml:"Platen>PlatenInputCaps>MaxHeight"`
}
// Status is what /ScannerStatus says.
type Status struct {
State string `xml:"State" json:"state"`
AdfState string `xml:"AdfState" json:"adf-state"`
}
func (c *Client) get(ctx context.Context, rawURL string) (*http.Response, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
return c.HTTP.Do(req)
}
func readBody(resp *http.Response, limit int64) ([]byte, error) {
defer resp.Body.Close()
return io.ReadAll(io.LimitReader(resp.Body, limit))
}
// Capabilities asks the scanner how large its glass is.
func (c *Client) Capabilities(ctx context.Context) (Capabilities, error) {
resp, err := c.get(ctx, c.Base+"/ScannerCapabilities")
if err != nil {
return Capabilities{}, fmt.Errorf("the scanner does not answer: %w", err)
}
body, err := readBody(resp, 1<<20)
if err != nil {
return Capabilities{}, fmt.Errorf("the scanner's capabilities cannot be read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return Capabilities{}, fmt.Errorf("the scanner refused its capabilities: %s", resp.Status)
}
var x capabilitiesXML
if err := xml.Unmarshal(body, &x); err != nil {
return Capabilities{}, fmt.Errorf("the scanner's capabilities are not XML: %w", err)
}
if x.MaxWidth <= 0 || x.MaxHeight <= 0 {
return Capabilities{}, fmt.Errorf("the scanner's capabilities name no platen size")
}
return Capabilities{MaxWidth: x.MaxWidth, MaxHeight: x.MaxHeight}, nil
}
// Status asks what the scanner is doing.
func (c *Client) Status(ctx context.Context) (Status, error) {
resp, err := c.get(ctx, c.Base+"/ScannerStatus")
if err != nil {
return Status{}, fmt.Errorf("the scanner does not answer: %w", err)
}
body, err := readBody(resp, 1<<20)
if err != nil {
return Status{}, fmt.Errorf("the scanner's status cannot be read: %w", err)
}
if resp.StatusCode != http.StatusOK {
return Status{}, fmt.Errorf("the scanner refused its status: %s", resp.Status)
}
var s Status
if err := xml.Unmarshal(body, &s); err != nil {
return Status{}, fmt.Errorf("the scanner's status is not XML: %w", err)
}
return s, nil
}
// colorModes maps the config's words to eSCL's.
var colorModes = map[string]string{"color": "RGB24", "gray": "Grayscale8"}
// 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
}
// 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.
func (c *Client) Scan(ctx context.Context, resolution int, colorMode string) ([]byte, error) {
caps, err := c.Capabilities(ctx)
if err != nil {
return nil, err
}
settings, err := ScanSettings(caps, resolution, colorMode)
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.Base+"/ScanJobs", bytes.NewReader([]byte(settings)))
if err != nil {
return nil, err
}
req.Header.Set("Content-Type", "text/xml")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, fmt.Errorf("the scanner does not answer: %w", err)
}
reply, _ := readBody(resp, 1<<20)
if resp.StatusCode != http.StatusCreated {
return nil, fmt.Errorf("the scanner refused the scan job: %s %s", resp.Status, strings.TrimSpace(string(reply)))
}
loc := resp.Header.Get("Location")
if loc == "" {
return nil, fmt.Errorf("the scanner took the scan job but named no Location")
}
base, _ := url.Parse(c.Base + "/")
ref, err := url.Parse(loc)
if err != nil {
return nil, fmt.Errorf("the scan job's Location %q is not a URL: %w", loc, err)
}
doc := strings.TrimRight(base.ResolveReference(ref).String(), "/") + "/NextDocument"
got, err := c.get(ctx, doc)
if err != nil {
return nil, fmt.Errorf("the scanned page cannot be fetched: %w", err)
}
image, err := readBody(got, maxImage)
if err != nil {
return nil, fmt.Errorf("the scanned page cannot be read: %w", err)
}
if got.StatusCode != http.StatusOK {
return nil, fmt.Errorf("the scanner gave no page: %s", got.Status)
}
if ct := got.Header.Get("Content-Type"); !strings.HasPrefix(ct, "image/jpeg") {
return nil, fmt.Errorf("the scanner sent %q, not image/jpeg", ct)
}
return image, nil
}
// Density is the image's own resolution in dpi, from its JFIF header (units dpi, or dots per cm).
func Density(jpegBytes []byte) (int, error) {
b := jpegBytes
if len(b) < 4 || b[0] != 0xFF || b[1] != 0xD8 {
return 0, fmt.Errorf("the image is not a JPEG")
}
for i := 2; i+4 <= len(b) && b[i] == 0xFF; {
marker, n := b[i+1], int(b[i+2])<<8|int(b[i+3])
if marker == 0xE0 && n >= 16 && i+4+12 <= len(b) && string(b[i+4:i+9]) == "JFIF\x00" {
units := b[i+11]
x := int(b[i+12])<<8 | int(b[i+13])
switch {
case units == 1 && x > 0:
return x, nil
case units == 2 && x > 0:
return int(float64(x)*2.54 + 0.5), nil
}
return 0, fmt.Errorf("the image's JFIF header gives no resolution")
}
if marker == 0xDA || n < 2 {
break
}
i += 2 + n
}
return 0, fmt.Errorf("the image has no JFIF header, so its resolution is unknown")
}
+106
View File
@@ -0,0 +1,106 @@
package main
import (
"context"
"strings"
"testing"
)
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 TestScanPostsOnePlatenJobOverTheWholeGlassAndFetchesTheDocumentOnce(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
image, err := NewClient(f.base()).Scan(context.Background(), 300, "color")
if err != nil {
t.Fatal(err)
}
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)
}
if f.documents != 1 {
t.Fatalf("%d NextDocument requests, want exactly 1", f.documents)
}
if want := fixture(t, "scan.jpg"); string(image) != string(want) {
t.Fatalf("the returned image is %d bytes, want the scanner's %d", len(image), len(want))
}
}
// 1440/AC1: the colour mode and resolution asked are the configured ones.
func TestScanAsksForTheConfiguredResolutionAndColourMode(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
if _, err := NewClient(f.base()).Scan(context.Background(), 600, "gray"); err != nil {
t.Fatal(err)
}
for _, want := range []string{"<scan:ColorMode>Grayscale8</scan:ColorMode>", "<scan:XResolution>600</scan:XResolution>", "<scan:YResolution>600</scan:YResolution>"} {
if !strings.Contains(f.postBody, want) {
t.Errorf("the job lacks %s:\n%s", want, f.postBody)
}
}
}
// 1440/AC1: a job the scanner refuses is an error that says so, and no page is fetched.
func TestScanSaysWhyAScannerRefusal(t *testing.T) {
f := newFakeScanner(t, fixture(t, "scan.jpg"))
f.refuse = 503
_, err := NewClient(f.base()).Scan(context.Background(), 300, "color")
if err == nil || !strings.Contains(err.Error(), "503") {
t.Fatalf("error = %v, want the refusal's 503", err)
}
if f.documents != 0 {
t.Fatalf("%d documents fetched after a refusal", f.documents)
}
}
// 1440/AC2: the image's own density, never the request's.
func TestDensityIsTheImagesOwn(t *testing.T) {
dpi, err := Density(fixture(t, "scan.jpg"))
if err != nil {
t.Fatal(err)
}
if dpi != 200 {
t.Fatalf("density = %d dpi, want the captured scan's 200 (the request said 300)", dpi)
}
}
func TestDensityOfAnImageWithNoneIsAnError(t *testing.T) {
if _, err := Density([]byte{0xFF, 0xD8, 0xFF, 0xDA, 0, 2}); err == nil {
t.Fatal("an image with no JFIF header has a resolution")
}
}
// 1440/AC8: the captured status replies.
func TestStatusReadsStateAndAdfState(t *testing.T) {
f := newFakeScanner(t, nil)
s, err := NewClient(f.base()).Status(context.Background())
if err != nil {
t.Fatal(err)
}
if s.State != "Idle" || s.AdfState != "ScannerAdfEmpty" {
t.Fatalf("status = %+v, want Idle / ScannerAdfEmpty", s)
}
}
// 1440/AC7: which value is unset is said by name.
func TestMissingNamesWhatIsUnset(t *testing.T) {
for _, c := range []struct {
s Settings
want string
}{
{Settings{Folder: "/x"}, "scanner"},
{Settings{Scanner: "http://192.0.2.10/eSCL"}, "scan-folder"},
} {
err := c.s.Missing()
if err == nil || !strings.Contains(err.Error(), c.want) {
t.Errorf("Missing(%+v) = %v, want it to name %s", c.s, err, c.want)
}
}
if err := (Settings{Scanner: "a", Folder: "/x"}).Missing(); err != nil {
t.Errorf("both set: %v", err)
}
}
+81
View File
@@ -0,0 +1,81 @@
package main
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"sync"
"testing"
)
// fixture reads a capture of the real scanner's answers (testdata/escl, 2026-10-20). Tests never reach a real scanner.
func fixture(t *testing.T, name string) []byte {
t.Helper()
b, err := os.ReadFile(filepath.Join("testdata", "escl", name))
if err != nil {
t.Fatal(err)
}
return b
}
// fakeScanner serves the captured eSCL answers and records what it was asked.
type fakeScanner struct {
*httptest.Server
mu sync.Mutex
image []byte
postBody string
posts int
documents int // NextDocument requests
refuse int // when set, the status ScanJobs answers instead of 201
noPage bool
}
func newFakeScanner(t *testing.T, image []byte) *fakeScanner {
t.Helper()
f := &fakeScanner{image: image}
mux := http.NewServeMux()
mux.HandleFunc("/eSCL/ScannerCapabilities", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
w.Write(fixture(t, "capabilities.xml"))
})
mux.HandleFunc("/eSCL/ScannerStatus", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/xml")
w.Write(fixture(t, "status-idle.xml"))
})
mux.HandleFunc("/eSCL/ScanJobs", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
if r.Method != http.MethodPost {
http.Error(w, "post only", http.StatusMethodNotAllowed)
return
}
b := make([]byte, 1<<16)
n, _ := r.Body.Read(b)
f.postBody = string(b[:n])
f.posts++
if f.refuse != 0 {
http.Error(w, "busy", f.refuse)
return
}
// The captured Location names the real scanner's host; the test's own server stands in for it.
w.Header().Set("Location", f.URL+"/eSCL/ScanJobs/7320ead5")
w.WriteHeader(http.StatusCreated)
})
mux.HandleFunc("/eSCL/ScanJobs/7320ead5/NextDocument", func(w http.ResponseWriter, r *http.Request) {
f.mu.Lock()
defer f.mu.Unlock()
f.documents++
if f.documents > 1 || f.noPage {
http.NotFound(w, r)
return
}
w.Header().Set("Content-Type", "image/jpeg")
w.Write(f.image)
})
f.Server = httptest.NewServer(mux)
t.Cleanup(f.Close)
return f
}
func (f *fakeScanner) base() string { return f.URL + "/eSCL" }
+76
View File
@@ -0,0 +1,76 @@
// Package main is the scan module's Go tools bundle (novox/hq ADR 0464, issue 1440): it scans the glass of a
// network scanner over eSCL, crops the document, files it as a one-page PDF in a folder and says so on the bus.
// A scan happens when a tool is called; the module runs no process of its own.
package main
import (
"encoding/json"
"fmt"
"os"
"strings"
)
// Settings are the module's five config values, as the mesh writes them into settings.json (ADR 0348).
type Settings struct {
// Scanner is the eSCL base URL, such as http://192.0.2.10/eSCL.
Scanner string
// Folder is the absolute path the PDFs are filed in. It is not a manifest resource: a directory's path
// cannot take a config value (issue 1441), so the code creates it.
Folder string
// Resolution is the dpi asked of the scanner: 100, 200, 300 or 600.
Resolution int
// ColorMode is "color" or "gray".
ColorMode string
// Crop says whether the document is cut out of the platen's white.
Crop bool
}
// LoadSettings reads settings.json (the file named by SCAN_SETTINGS) and lays the defaults under it. A missing
// or unreadable file is an error that names it; an unset scanner or folder is Missing's to say.
func LoadSettings(path string) (Settings, error) {
s := Settings{Resolution: 300, ColorMode: "color", Crop: true}
if path == "" {
return s, fmt.Errorf("SCAN_SETTINGS names no settings file: the module's config does not reach the code")
}
raw, err := os.ReadFile(path)
if err != nil {
return s, fmt.Errorf("settings cannot be read: %w", err)
}
var in map[string]any
if err := json.Unmarshal(raw, &in); err != nil {
return s, fmt.Errorf("settings are not JSON (%s): %w", path, err)
}
if v, ok := in["scanner"].(string); ok {
s.Scanner = strings.TrimSpace(v)
}
if v, ok := in["scan-folder"].(string); ok {
s.Folder = strings.TrimSpace(v)
}
if v, ok := in["resolution"].(float64); ok && v > 0 {
s.Resolution = int(v)
}
if v, ok := in["color-mode"].(string); ok && v != "" {
s.ColorMode = v
}
if v, ok := in["crop"].(bool); ok {
s.Crop = v
}
return s, nil
}
// Missing is nil when both required values are set, and otherwise says which is not: no tool writes or
// asks anything before that (1440/AC7).
func (s Settings) Missing() error {
var unset []string
if s.Scanner == "" {
unset = append(unset, "scanner")
}
if s.Folder == "" {
unset = append(unset, "scan-folder")
}
if len(unset) == 0 {
return nil
}
return fmt.Errorf("config value %s is not set: the operator gives it with the config verb for the scan module on this node",
strings.Join(unset, " and "))
}
+261
View File
@@ -0,0 +1,261 @@
<?xml version="1.0" encoding="UTF-8"?>
<scan:ScannerCapabilities
xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03"
xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm"
xmlns:dest="http://schemas.hp.com/imaging/destination/2011/06/06"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://schemas.hp.com/imaging/escl/2011/05/03 ../../schemas/eSCL-1_92.xsd">
<pwg:Version>2.93</pwg:Version>
<pwg:MakeAndModel>Brother MFC-L8390CDW series</pwg:MakeAndModel>
<pwg:SerialNumber>E82414G4N581626</pwg:SerialNumber>
<scan:Manufacturer>Brother</scan:Manufacturer>
<scan:UUID>e3248000-80ce-11db-8000-94ddf83ec78a</scan:UUID>
<scan:AdminURI>http://BRW58CDC9DFC80B.local./net/net/airprint.html</scan:AdminURI>
<scan:IconURI>http://BRW58CDC9DFC80B.local./icons/device-icons-128.png</scan:IconURI>
<scan:Certifications>
<scan:Certification>
<scan:Name>mopria-certified-scan</scan:Name>
<scan:Version>1.4</scan:Version>
</scan:Certification>
</scan:Certifications>
<scan:Platen>
<scan:PlatenInputCaps>
<scan:MinWidth>64</scan:MinWidth>
<scan:MaxWidth>2550</scan:MaxWidth>
<scan:MinHeight>64</scan:MinHeight>
<scan:MaxHeight>3507</scan:MaxHeight>
<scan:MaxXOffset>2486</scan:MaxXOffset>
<scan:MaxYOffset>3443</scan:MaxYOffset>
<scan:MaxScanRegions>1</scan:MaxScanRegions>
<scan:SettingProfiles>
<scan:SettingProfile>
<scan:ColorModes>
<scan:ColorMode>BlackAndWhite1</scan:ColorMode>
<scan:ColorMode>Grayscale8</scan:ColorMode>
<scan:ColorMode>RGB24</scan:ColorMode>
</scan:ColorModes>
<scan:DocumentFormats>
<pwg:DocumentFormat>application/pdf</pwg:DocumentFormat>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
<scan:DocumentFormatExt>application/pdf</scan:DocumentFormatExt>
<scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt>
</scan:DocumentFormats>
<scan:SupportedResolutions>
<scan:DiscreteResolutions>
<scan:DiscreteResolution>
<scan:XResolution>100</scan:XResolution>
<scan:YResolution>100</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>200</scan:XResolution>
<scan:YResolution>200</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>300</scan:XResolution>
<scan:YResolution>300</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>600</scan:XResolution>
<scan:YResolution>600</scan:YResolution>
</scan:DiscreteResolution>
</scan:DiscreteResolutions>
</scan:SupportedResolutions>
<scan:ColorSpaces>
<scan:ColorSpace>CMYK</scan:ColorSpace>
<scan:ColorSpace>YCC</scan:ColorSpace>
<scan:ColorSpace>sRGB</scan:ColorSpace>
</scan:ColorSpaces>
<scan:CcdChannels>
<scan:CcdChannel>Red</scan:CcdChannel>
<scan:CcdChannel>Green</scan:CcdChannel>
<scan:CcdChannel>Blue</scan:CcdChannel>
<scan:CcdChannel>NTSC</scan:CcdChannel>
<scan:CcdChannel>GrayCcd</scan:CcdChannel>
<scan:CcdChannel>GrayCcdEmulated</scan:CcdChannel>
</scan:CcdChannels>
<scan:BinaryRenderings>
<scan:BinaryRendering>Halftone</scan:BinaryRendering>
<scan:BinaryRendering>Threshold</scan:BinaryRendering>
</scan:BinaryRenderings>
</scan:SettingProfile>
</scan:SettingProfiles>
<scan:SupportedIntents>
<scan:Intent>Document</scan:Intent>
<scan:Intent>TextAndGraphic</scan:Intent>
<scan:Intent>Photo</scan:Intent>
<scan:Intent>Preview</scan:Intent>
</scan:SupportedIntents>
<scan:MaxOpticalXResolution>1200</scan:MaxOpticalXResolution>
<scan:MaxOpticalYResolution>2400</scan:MaxOpticalYResolution>
<scan:RiskyLeftMargin>0</scan:RiskyLeftMargin>
<scan:RiskyRightMargin>0</scan:RiskyRightMargin>
<scan:RiskyTopMargin>0</scan:RiskyTopMargin>
<scan:RiskyBottomMargin>0</scan:RiskyBottomMargin>
<scan:MaxPhysicalWidth>2550</scan:MaxPhysicalWidth>
<scan:MaxPhysicalHeight>3507</scan:MaxPhysicalHeight>
</scan:PlatenInputCaps>
</scan:Platen>
<scan:Adf>
<scan:AdfSimplexInputCaps>
<scan:MinWidth>64</scan:MinWidth>
<scan:MaxWidth>2550</scan:MaxWidth>
<scan:MinHeight>1740</scan:MinHeight>
<scan:MaxHeight>4200</scan:MaxHeight>
<scan:MaxXOffset>2486</scan:MaxXOffset>
<scan:MaxYOffset>2460</scan:MaxYOffset>
<scan:MaxScanRegions>1</scan:MaxScanRegions>
<scan:SettingProfiles>
<scan:SettingProfile>
<scan:ColorModes>
<scan:ColorMode>BlackAndWhite1</scan:ColorMode>
<scan:ColorMode>Grayscale8</scan:ColorMode>
<scan:ColorMode>RGB24</scan:ColorMode>
</scan:ColorModes>
<scan:DocumentFormats>
<pwg:DocumentFormat>application/pdf</pwg:DocumentFormat>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
<scan:DocumentFormatExt>application/pdf</scan:DocumentFormatExt>
<scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt>
</scan:DocumentFormats>
<scan:SupportedResolutions>
<scan:DiscreteResolutions>
<scan:DiscreteResolution>
<scan:XResolution>100</scan:XResolution>
<scan:YResolution>100</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>200</scan:XResolution>
<scan:YResolution>200</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>300</scan:XResolution>
<scan:YResolution>300</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>600</scan:XResolution>
<scan:YResolution>600</scan:YResolution>
</scan:DiscreteResolution>
</scan:DiscreteResolutions>
</scan:SupportedResolutions>
<scan:ColorSpaces>
<scan:ColorSpace>CMYK</scan:ColorSpace>
<scan:ColorSpace>YCC</scan:ColorSpace>
<scan:ColorSpace>sRGB</scan:ColorSpace>
</scan:ColorSpaces>
<scan:CcdChannels>
<scan:CcdChannel>Red</scan:CcdChannel>
<scan:CcdChannel>Green</scan:CcdChannel>
<scan:CcdChannel>Blue</scan:CcdChannel>
<scan:CcdChannel>NTSC</scan:CcdChannel>
<scan:CcdChannel>GrayCcd</scan:CcdChannel>
<scan:CcdChannel>GrayCcdEmulated</scan:CcdChannel>
</scan:CcdChannels>
<scan:BinaryRenderings>
<scan:BinaryRendering>Halftone</scan:BinaryRendering>
<scan:BinaryRendering>Threshold</scan:BinaryRendering>
</scan:BinaryRenderings>
</scan:SettingProfile>
</scan:SettingProfiles>
<scan:SupportedIntents>
<scan:Intent>Document</scan:Intent>
<scan:Intent>TextAndGraphic</scan:Intent>
<scan:Intent>Photo</scan:Intent>
<scan:Intent>Preview</scan:Intent>
</scan:SupportedIntents>
<scan:MaxOpticalXResolution>1200</scan:MaxOpticalXResolution>
<scan:MaxOpticalYResolution>600</scan:MaxOpticalYResolution>
<scan:RiskyLeftMargin>0</scan:RiskyLeftMargin>
<scan:RiskyRightMargin>0</scan:RiskyRightMargin>
<scan:RiskyTopMargin>0</scan:RiskyTopMargin>
<scan:RiskyBottomMargin>0</scan:RiskyBottomMargin>
<scan:MaxPhysicalWidth>2550</scan:MaxPhysicalWidth>
<scan:MaxPhysicalHeight>4200</scan:MaxPhysicalHeight>
</scan:AdfSimplexInputCaps>
<scan:AdfDuplexInputCaps>
<scan:MinWidth>64</scan:MinWidth>
<scan:MaxWidth>2550</scan:MaxWidth>
<scan:MinHeight>1740</scan:MinHeight>
<scan:MaxHeight>4200</scan:MaxHeight>
<scan:MaxXOffset>2486</scan:MaxXOffset>
<scan:MaxYOffset>2460</scan:MaxYOffset>
<scan:MaxScanRegions>1</scan:MaxScanRegions>
<scan:SettingProfiles>
<scan:SettingProfile>
<scan:ColorModes>
<scan:ColorMode>BlackAndWhite1</scan:ColorMode>
<scan:ColorMode>Grayscale8</scan:ColorMode>
<scan:ColorMode>RGB24</scan:ColorMode>
</scan:ColorModes>
<scan:DocumentFormats>
<pwg:DocumentFormat>application/pdf</pwg:DocumentFormat>
<pwg:DocumentFormat>image/jpeg</pwg:DocumentFormat>
<scan:DocumentFormatExt>application/pdf</scan:DocumentFormatExt>
<scan:DocumentFormatExt>image/jpeg</scan:DocumentFormatExt>
</scan:DocumentFormats>
<scan:SupportedResolutions>
<scan:DiscreteResolutions>
<scan:DiscreteResolution>
<scan:XResolution>100</scan:XResolution>
<scan:YResolution>100</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>200</scan:XResolution>
<scan:YResolution>200</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>300</scan:XResolution>
<scan:YResolution>300</scan:YResolution>
</scan:DiscreteResolution>
<scan:DiscreteResolution>
<scan:XResolution>600</scan:XResolution>
<scan:YResolution>600</scan:YResolution>
</scan:DiscreteResolution>
</scan:DiscreteResolutions>
</scan:SupportedResolutions>
<scan:ColorSpaces>
<scan:ColorSpace>CMYK</scan:ColorSpace>
<scan:ColorSpace>YCC</scan:ColorSpace>
<scan:ColorSpace>sRGB</scan:ColorSpace>
</scan:ColorSpaces>
<scan:CcdChannels>
<scan:CcdChannel>Red</scan:CcdChannel>
<scan:CcdChannel>Green</scan:CcdChannel>
<scan:CcdChannel>Blue</scan:CcdChannel>
<scan:CcdChannel>NTSC</scan:CcdChannel>
<scan:CcdChannel>GrayCcd</scan:CcdChannel>
<scan:CcdChannel>GrayCcdEmulated</scan:CcdChannel>
</scan:CcdChannels>
<scan:BinaryRenderings>
<scan:BinaryRendering>Halftone</scan:BinaryRendering>
<scan:BinaryRendering>Threshold</scan:BinaryRendering>
</scan:BinaryRenderings>
</scan:SettingProfile>
</scan:SettingProfiles>
<scan:SupportedIntents>
<scan:Intent>Document</scan:Intent>
<scan:Intent>TextAndGraphic</scan:Intent>
<scan:Intent>Photo</scan:Intent>
<scan:Intent>Preview</scan:Intent>
</scan:SupportedIntents>
<scan:MaxOpticalXResolution>600</scan:MaxOpticalXResolution>
<scan:MaxOpticalYResolution>600</scan:MaxOpticalYResolution>
<scan:RiskyLeftMargin>0</scan:RiskyLeftMargin>
<scan:RiskyRightMargin>0</scan:RiskyRightMargin>
<scan:RiskyTopMargin>0</scan:RiskyTopMargin>
<scan:RiskyBottomMargin>0</scan:RiskyBottomMargin>
<scan:MaxPhysicalWidth>2550</scan:MaxPhysicalWidth>
<scan:MaxPhysicalHeight>4200</scan:MaxPhysicalHeight>
</scan:AdfDuplexInputCaps>
<scan:FeederCapacity>50</scan:FeederCapacity>
<scan:AdfOptions>
<scan:AdfOption>DetectPaperLoaded</scan:AdfOption>
<scan:AdfOption>Duplex</scan:AdfOption>
</scan:AdfOptions>
</scan:Adf>
<scan:CompressionFactorSupport>
<scan:Min>1</scan:Min>
<scan:Max>3</scan:Max>
<scan:Normal>2</scan:Normal>
<scan:Step>1</scan:Step>
</scan:CompressionFactorSupport>
</scan:ScannerCapabilities>
+6
View File
@@ -0,0 +1,6 @@
HTTP/1.1 200 OK
Content-Location: /eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a/doc-1
Accept-Encoding: identity
Content-Type: image/jpeg
Transfer-Encoding: chunked
+10
View File
@@ -0,0 +1,10 @@
HTTP/1.1 201 Created
Cache-Control: no-store
Accept-Encoding: identity
X-Frame-Options: DENY
Content-Length: 0
Content-Type: text/xml
Connection: close
Pragma: no-cache
Location: http://192.168.1.171/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a
Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

+9
View File
@@ -0,0 +1,9 @@
<?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>
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><scan:ScannerStatus xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm" xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://schemas.hp.com/imaging/escl/2011/05/03 ../../schemas/eSCL-1_92.xsd"><pwg:Version>2.93</pwg:Version><pwg:State>Idle</pwg:State><scan:AdfState>ScannerAdfEmpty</scan:AdfState><scan:Jobs><scan:JobInfo><pwg:JobUri>/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUri><pwg:JobUuid>urn:uuid:7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUuid><scan:Age>9291071</scan:Age><pwg:ImagesCompleted>1</pwg:ImagesCompleted><pwg:ImagesToTransfer>1</pwg:ImagesToTransfer><pwg:JobState>Completed</pwg:JobState><pwg:JobStateReasons><pwg:JobStateReason>JobCompletedSuccessfully</pwg:JobStateReason></pwg:JobStateReasons></scan:JobInfo></scan:Jobs></scan:ScannerStatus>
+1
View File
@@ -0,0 +1 @@
<?xml version="1.0" encoding="UTF-8"?><scan:ScannerStatus xmlns:pwg="http://www.pwg.org/schemas/2010/12/sm" xmlns:scan="http://schemas.hp.com/imaging/escl/2011/05/03" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://schemas.hp.com/imaging/escl/2011/05/03 ../../schemas/eSCL-1_92.xsd"><pwg:Version>2.93</pwg:Version><pwg:State>Idle</pwg:State><scan:AdfState>ScannerAdfEmpty</scan:AdfState><scan:Jobs><scan:JobInfo><pwg:JobUri>/eSCL/ScanJobs/7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUri><pwg:JobUuid>urn:uuid:7320ead5-ccb0-11f1-8ff9-94ddf83ec78a</pwg:JobUuid><scan:Age>9292192</scan:Age><pwg:ImagesCompleted>1</pwg:ImagesCompleted><pwg:ImagesToTransfer>1</pwg:ImagesToTransfer><pwg:JobState>Completed</pwg:JobState><pwg:JobStateReasons><pwg:JobStateReason>JobCompletedSuccessfully</pwg:JobStateReason></pwg:JobStateReasons></scan:JobInfo></scan:Jobs></scan:ScannerStatus>
+3
View File
@@ -0,0 +1,3 @@
module scan
go 1.26.0