615 lines
22 KiB
Go
615 lines
22 KiB
Go
// Package winprint implements the Windows "Verae TimeStamp PDF" virtual
|
|
// printer receivers: a minimal IPP Everywhere loopback server (primary path,
|
|
// used with the inbox Microsoft IPP Class Driver) and a folder watcher for the
|
|
// Microsoft Print To PDF + Local file port fallback. Pure Go, no cgo, so it
|
|
// cross-compiles from macOS and is unit-testable there.
|
|
package winprint
|
|
|
|
import (
|
|
"bytes"
|
|
"crypto/sha1"
|
|
"fmt"
|
|
"io"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/OpenPrinting/goipp"
|
|
|
|
"github.com/VeraeLLC/PrintToVeraeTimeStamp/internal/vlog"
|
|
)
|
|
|
|
// Job is a captured print job handed to the processor.
|
|
type Job struct {
|
|
ID string // vlog job id
|
|
IPPJobID int
|
|
Source string // "ipp" or "watch"
|
|
Title string
|
|
User string
|
|
Format string
|
|
Path string // spooled document on disk
|
|
Bytes int64
|
|
Received time.Time
|
|
}
|
|
|
|
// Processor turns a captured job into a stamp. Return error to mark aborted.
|
|
type Processor func(Job) error
|
|
|
|
// IPPServer is a tiny IPP/2.0 printer.
|
|
type IPPServer struct {
|
|
Addr string // e.g. 127.0.0.1:8631
|
|
Name string // printer-name / info
|
|
SpoolDir string // where documents are written
|
|
Formats []string // document-format-supported
|
|
Process Processor
|
|
started time.Time
|
|
mu sync.Mutex
|
|
nextID int
|
|
jobs map[int]*ippJob
|
|
queue chan Job
|
|
}
|
|
|
|
type ippJob struct {
|
|
id int
|
|
logID string
|
|
name string
|
|
user string
|
|
format string
|
|
state int // 3 pending,5 processing,7 canceled,8 aborted,9 completed
|
|
reasons string
|
|
message string
|
|
created time.Time
|
|
done time.Time
|
|
}
|
|
|
|
const (
|
|
jsPending = 3
|
|
jsProcessing = 5
|
|
jsCanceled = 7
|
|
jsAborted = 8
|
|
jsCompleted = 9
|
|
)
|
|
|
|
// DefaultFormats are advertised unless VERAE_IPP_FORMATS overrides.
|
|
var DefaultFormats = []string{"application/pdf", "image/pwg-raster"}
|
|
|
|
// ListenAndServe binds and serves until error. Jobs are processed serially.
|
|
func (s *IPPServer) ListenAndServe() error {
|
|
if s.Addr == "" {
|
|
s.Addr = "127.0.0.1:8631"
|
|
}
|
|
if s.Name == "" {
|
|
s.Name = "Verae TimeStamp PDF"
|
|
}
|
|
if len(s.Formats) == 0 {
|
|
s.Formats = DefaultFormats
|
|
if env := strings.TrimSpace(os.Getenv("VERAE_IPP_FORMATS")); env != "" {
|
|
s.Formats = strings.Split(env, ",")
|
|
}
|
|
}
|
|
if err := os.MkdirAll(s.SpoolDir, 0o700); err != nil {
|
|
return fmt.Errorf("spool dir: %w", err)
|
|
}
|
|
s.started = time.Now()
|
|
s.jobs = map[int]*ippJob{}
|
|
s.queue = make(chan Job, 64)
|
|
go s.worker()
|
|
ln, err := net.Listen("tcp", s.Addr)
|
|
if err != nil {
|
|
return fmt.Errorf("listen %s: %w (is another helper / user session already running?)", s.Addr, err)
|
|
}
|
|
vlog.Printf("ipp: listening on http://%s/ipp/print formats=%v spool=%s", s.Addr, s.Formats, s.SpoolDir)
|
|
srv := &http.Server{Handler: s, ReadHeaderTimeout: 30 * time.Second}
|
|
return srv.Serve(ln)
|
|
}
|
|
|
|
func (s *IPPServer) worker() {
|
|
for j := range s.queue {
|
|
lj := vlog.Job(j.ID)
|
|
s.setState(j.IPPJobID, jsProcessing, "job-printing", "stamping")
|
|
start := time.Now()
|
|
err := func() (err error) {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
err = fmt.Errorf("panic: %v", r)
|
|
}
|
|
}()
|
|
if s.Process == nil {
|
|
return fmt.Errorf("no processor configured")
|
|
}
|
|
return s.Process(j)
|
|
}()
|
|
if err != nil {
|
|
lj.Errorf("ipp: job %d FAILED after %s: %v", j.IPPJobID, time.Since(start).Round(time.Millisecond), err)
|
|
s.setState(j.IPPJobID, jsAborted, "aborted-by-system", truncate(err.Error(), 200))
|
|
continue
|
|
}
|
|
lj.Printf("ipp: job %d completed in %s", j.IPPJobID, time.Since(start).Round(time.Millisecond))
|
|
s.setState(j.IPPJobID, jsCompleted, "job-completed-successfully", "timestamped")
|
|
}
|
|
}
|
|
|
|
func (s *IPPServer) setState(id, st int, reasons, msg string) {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
if j := s.jobs[id]; j != nil {
|
|
j.state, j.reasons, j.message = st, reasons, msg
|
|
if st >= jsCanceled {
|
|
j.done = time.Now()
|
|
}
|
|
}
|
|
}
|
|
|
|
// ServeHTTP handles IPP POSTs (any path) and a plain-text GET status page.
|
|
func (s *IPPServer) ServeHTTP(w http.ResponseWriter, r *http.Request) {
|
|
vlog.Debugf("http: %s %s from=%s ct=%q ua=%q len=%d te=%v", r.Method, r.URL.Path, r.RemoteAddr, r.Header.Get("Content-Type"), r.UserAgent(), r.ContentLength, r.TransferEncoding)
|
|
if r.Method == http.MethodGet || r.Method == http.MethodHead {
|
|
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
|
|
s.mu.Lock()
|
|
n := len(s.jobs)
|
|
s.mu.Unlock()
|
|
fmt.Fprintf(w, "Verae TimeStamp PDF IPP helper\nprinter-uri: ipp://%s/ipp/print\nformats: %s\njobs-seen: %d\nup-since: %s\npid: %d\n",
|
|
s.Addr, strings.Join(s.Formats, ","), n, s.started.Format(time.RFC3339), os.Getpid())
|
|
return
|
|
}
|
|
if r.Method != http.MethodPost {
|
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
|
return
|
|
}
|
|
var req goipp.Message
|
|
if err := req.Decode(r.Body); err != nil {
|
|
vlog.Errorf("ipp: decode error from %s: %v", r.RemoteAddr, err)
|
|
http.Error(w, "bad ipp", http.StatusBadRequest)
|
|
return
|
|
}
|
|
op := goipp.Op(req.Code)
|
|
if vlog.DebugEnabled() {
|
|
var b bytes.Buffer
|
|
req.Print(&b, true)
|
|
vlog.Debugf("ipp: request %s id=%d\n%s", op, req.RequestID, b.String())
|
|
} else {
|
|
vlog.Printf("ipp: request %s id=%d user=%q", op, req.RequestID, attrString(req.Operation, "requesting-user-name"))
|
|
}
|
|
resp := s.dispatch(&req, r)
|
|
if vlog.DebugEnabled() {
|
|
var b bytes.Buffer
|
|
resp.Print(&b, false)
|
|
vlog.Debugf("ipp: response %s id=%d\n%s", goipp.Status(resp.Code), req.RequestID, b.String())
|
|
}
|
|
// drain anything left so keep-alive works
|
|
_, _ = io.Copy(io.Discard, r.Body)
|
|
w.Header().Set("Content-Type", goipp.ContentType)
|
|
if err := resp.Encode(w); err != nil {
|
|
vlog.Errorf("ipp: encode response: %v", err)
|
|
}
|
|
}
|
|
|
|
func (s *IPPServer) newResp(req *goipp.Message, st goipp.Status, msg string) *goipp.Message {
|
|
resp := goipp.NewResponse(req.Version, st, req.RequestID)
|
|
resp.Operation.Add(goipp.MakeAttribute("attributes-charset", goipp.TagCharset, goipp.String("utf-8")))
|
|
resp.Operation.Add(goipp.MakeAttribute("attributes-natural-language", goipp.TagLanguage, goipp.String("en")))
|
|
if msg != "" {
|
|
resp.Operation.Add(goipp.MakeAttribute("status-message", goipp.TagText, goipp.String(msg)))
|
|
}
|
|
return resp
|
|
}
|
|
|
|
func (s *IPPServer) dispatch(req *goipp.Message, r *http.Request) *goipp.Message {
|
|
switch goipp.Op(req.Code) {
|
|
case goipp.OpGetPrinterAttributes:
|
|
resp := s.newResp(req, goipp.StatusOk, "")
|
|
resp.Printer = s.printerAttrs(req, r)
|
|
return resp
|
|
case goipp.OpValidateJob:
|
|
if st, msg := s.checkFormat(req); st != goipp.StatusOk {
|
|
return s.newResp(req, st, msg)
|
|
}
|
|
return s.newResp(req, goipp.StatusOk, "")
|
|
case goipp.OpPrintJob:
|
|
if st, msg := s.checkFormat(req); st != goipp.StatusOk {
|
|
return s.newResp(req, st, msg)
|
|
}
|
|
j := s.createJob(req)
|
|
return s.receiveDocument(req, r, j)
|
|
case goipp.OpCreateJob:
|
|
j := s.createJob(req)
|
|
resp := s.newResp(req, goipp.StatusOk, "")
|
|
resp.Job = s.jobAttrs(j, r)
|
|
return resp
|
|
case goipp.OpSendDocument:
|
|
id := int(attrInt(req.Operation, "job-id"))
|
|
s.mu.Lock()
|
|
j := s.jobs[id]
|
|
s.mu.Unlock()
|
|
if j == nil {
|
|
return s.newResp(req, goipp.StatusErrorNotFound, "no such job")
|
|
}
|
|
if f := attrString(req.Operation, "document-format"); f != "" {
|
|
j.format = f
|
|
}
|
|
return s.receiveDocument(req, r, j)
|
|
case goipp.OpCloseJob:
|
|
return s.newResp(req, goipp.StatusOk, "")
|
|
case goipp.OpGetJobAttributes:
|
|
id := int(attrInt(req.Operation, "job-id"))
|
|
s.mu.Lock()
|
|
j := s.jobs[id]
|
|
s.mu.Unlock()
|
|
if j == nil {
|
|
return s.newResp(req, goipp.StatusErrorNotFound, "no such job")
|
|
}
|
|
resp := s.newResp(req, goipp.StatusOk, "")
|
|
resp.Job = s.jobAttrs(j, r)
|
|
return resp
|
|
case goipp.OpGetJobs:
|
|
resp := s.newResp(req, goipp.StatusOk, "")
|
|
which := attrString(req.Operation, "which-jobs")
|
|
s.mu.Lock()
|
|
var groups goipp.Groups
|
|
groups.Add(goipp.Group{Tag: goipp.TagOperationGroup, Attrs: resp.Operation})
|
|
for _, j := range s.jobs {
|
|
done := j.state >= jsCanceled
|
|
if (which == "completed") != done {
|
|
continue
|
|
}
|
|
groups.Add(goipp.Group{Tag: goipp.TagJobGroup, Attrs: s.jobAttrsLocked(j, r)})
|
|
}
|
|
s.mu.Unlock()
|
|
resp.Groups = groups
|
|
return resp
|
|
case goipp.OpCancelJob:
|
|
id := int(attrInt(req.Operation, "job-id"))
|
|
s.setState(id, jsCanceled, "job-canceled-by-user", "canceled")
|
|
return s.newResp(req, goipp.StatusOk, "")
|
|
default:
|
|
vlog.Printf("ipp: unsupported operation %s (0x%04x)", goipp.Op(req.Code), req.Code)
|
|
return s.newResp(req, goipp.StatusErrorOperationNotSupported, "operation not supported")
|
|
}
|
|
}
|
|
|
|
func (s *IPPServer) checkFormat(req *goipp.Message) (goipp.Status, string) {
|
|
f := attrString(req.Operation, "document-format")
|
|
if f == "" || f == "application/octet-stream" {
|
|
return goipp.StatusOk, ""
|
|
}
|
|
for _, x := range s.Formats {
|
|
if strings.EqualFold(strings.TrimSpace(x), f) {
|
|
return goipp.StatusOk, ""
|
|
}
|
|
}
|
|
vlog.Errorf("ipp: client asked for unsupported document-format %q", f)
|
|
return goipp.StatusErrorDocumentFormatNotSupported, "document-format not supported: " + f
|
|
}
|
|
|
|
func (s *IPPServer) createJob(req *goipp.Message) *ippJob {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
s.nextID++
|
|
j := &ippJob{
|
|
id: s.nextID,
|
|
logID: vlog.NewJobID("ipp"),
|
|
name: firstNonEmpty(attrString(req.Operation, "job-name"), attrString(req.Operation, "document-name"), "document"),
|
|
user: attrString(req.Operation, "requesting-user-name"),
|
|
format: firstNonEmpty(attrString(req.Operation, "document-format"), "application/octet-stream"),
|
|
state: jsPending,
|
|
reasons: "none",
|
|
created: time.Now(),
|
|
}
|
|
s.jobs[j.id] = j
|
|
vlog.Job(j.logID).Printf("ipp: created job %d name=%q user=%q format=%s", j.id, j.name, j.user, j.format)
|
|
return j
|
|
}
|
|
|
|
func (s *IPPServer) receiveDocument(req *goipp.Message, r *http.Request, j *ippJob) *goipp.Message {
|
|
lj := vlog.Job(j.logID)
|
|
ext := ".bin"
|
|
switch {
|
|
case strings.Contains(j.format, "pdf"):
|
|
ext = ".pdf"
|
|
case strings.Contains(j.format, "pwg-raster"):
|
|
ext = ".pwg"
|
|
case strings.Contains(j.format, "urf"):
|
|
ext = ".urf"
|
|
case strings.Contains(j.format, "postscript"):
|
|
ext = ".ps"
|
|
}
|
|
path := filepath.Join(s.SpoolDir, fmt.Sprintf("%s%s", j.logID, ext))
|
|
f, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600)
|
|
if err != nil {
|
|
lj.Errorf("ipp: create spool file: %v", err)
|
|
s.setState(j.id, jsAborted, "aborted-by-system", err.Error())
|
|
return s.newResp(req, goipp.StatusErrorInternal, "spool failed")
|
|
}
|
|
n, err := io.Copy(f, r.Body)
|
|
cerr := f.Close()
|
|
if err == nil {
|
|
err = cerr
|
|
}
|
|
if err != nil {
|
|
lj.Errorf("ipp: read document after %d bytes: %v", n, err)
|
|
s.setState(j.id, jsAborted, "aborted-by-system", err.Error())
|
|
return s.newResp(req, goipp.StatusErrorInternal, "read failed")
|
|
}
|
|
magic := sniff(path)
|
|
lj.Printf("ipp: received %d bytes format=%s magic=%q → %s", n, j.format, magic, path)
|
|
if n == 0 {
|
|
s.setState(j.id, jsAborted, "aborted-by-system", "empty document")
|
|
resp := s.newResp(req, goipp.StatusOk, "empty document")
|
|
resp.Job = s.jobAttrs(j, r)
|
|
return resp
|
|
}
|
|
s.queue <- Job{
|
|
ID: j.logID, IPPJobID: j.id, Source: "ipp", Title: j.name, User: j.user,
|
|
Format: j.format, Path: path, Bytes: n, Received: time.Now(),
|
|
}
|
|
resp := s.newResp(req, goipp.StatusOk, "")
|
|
resp.Job = s.jobAttrs(j, r)
|
|
return resp
|
|
}
|
|
|
|
func sniff(path string) string {
|
|
f, err := os.Open(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
defer f.Close()
|
|
b := make([]byte, 8)
|
|
n, _ := f.Read(b)
|
|
return strings.Map(func(r rune) rune {
|
|
if r < 32 || r > 126 {
|
|
return '.'
|
|
}
|
|
return r
|
|
}, string(b[:n]))
|
|
}
|
|
|
|
func (s *IPPServer) printerURI(req *goipp.Message, r *http.Request) string {
|
|
if u := attrString(req.Operation, "printer-uri"); u != "" {
|
|
return u
|
|
}
|
|
host := r.Host
|
|
if host == "" {
|
|
host = s.Addr
|
|
}
|
|
return "ipp://" + host + "/ipp/print"
|
|
}
|
|
|
|
func (s *IPPServer) jobAttrs(j *ippJob, r *http.Request) goipp.Attributes {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.jobAttrsLocked(j, r)
|
|
}
|
|
|
|
func (s *IPPServer) jobAttrsLocked(j *ippJob, r *http.Request) goipp.Attributes {
|
|
host := r.Host
|
|
if host == "" {
|
|
host = s.Addr
|
|
}
|
|
var a goipp.Attributes
|
|
a.Add(goipp.MakeAttribute("job-id", goipp.TagInteger, goipp.Integer(j.id)))
|
|
a.Add(goipp.MakeAttribute("job-uri", goipp.TagURI, goipp.String(fmt.Sprintf("ipp://%s/ipp/print/%d", host, j.id))))
|
|
a.Add(goipp.MakeAttribute("job-printer-uri", goipp.TagURI, goipp.String("ipp://"+host+"/ipp/print")))
|
|
a.Add(goipp.MakeAttribute("job-name", goipp.TagName, goipp.String(j.name)))
|
|
a.Add(goipp.MakeAttribute("job-originating-user-name", goipp.TagName, goipp.String(firstNonEmpty(j.user, "anonymous"))))
|
|
a.Add(goipp.MakeAttribute("job-state", goipp.TagEnum, goipp.Integer(j.state)))
|
|
a.Add(goipp.MakeAttribute("job-state-reasons", goipp.TagKeyword, goipp.String(firstNonEmpty(j.reasons, "none"))))
|
|
if j.message != "" {
|
|
a.Add(goipp.MakeAttribute("job-state-message", goipp.TagText, goipp.String(j.message)))
|
|
}
|
|
a.Add(goipp.MakeAttribute("time-at-creation", goipp.TagInteger, goipp.Integer(int(j.created.Sub(s.started).Seconds())+1)))
|
|
if !j.done.IsZero() {
|
|
a.Add(goipp.MakeAttribute("time-at-completed", goipp.TagInteger, goipp.Integer(int(j.done.Sub(s.started).Seconds())+1)))
|
|
}
|
|
a.Add(goipp.MakeAttribute("job-impressions-completed", goipp.TagInteger, goipp.Integer(0)))
|
|
return a
|
|
}
|
|
|
|
func kw(name string, vals ...string) goipp.Attribute { return multi(name, goipp.TagKeyword, vals...) }
|
|
|
|
func multi(name string, tag goipp.Tag, vals ...string) goipp.Attribute {
|
|
a := goipp.Attribute{Name: name}
|
|
for _, v := range vals {
|
|
a.Values.Add(tag, goipp.String(v))
|
|
}
|
|
return a
|
|
}
|
|
|
|
func ints(name string, tag goipp.Tag, vals ...int) goipp.Attribute {
|
|
a := goipp.Attribute{Name: name}
|
|
for _, v := range vals {
|
|
a.Values.Add(tag, goipp.Integer(v))
|
|
}
|
|
return a
|
|
}
|
|
|
|
func mediaCol(w, h int, margins int) goipp.Attribute {
|
|
size := goipp.Collection{}
|
|
size.Add(goipp.MakeAttribute("x-dimension", goipp.TagInteger, goipp.Integer(w)))
|
|
size.Add(goipp.MakeAttribute("y-dimension", goipp.TagInteger, goipp.Integer(h)))
|
|
col := goipp.Collection{}
|
|
col.Add(goipp.MakeAttribute("media-size", goipp.TagBeginCollection, size))
|
|
for _, m := range []string{"media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin"} {
|
|
col.Add(goipp.MakeAttribute(m, goipp.TagInteger, goipp.Integer(margins)))
|
|
}
|
|
col.Add(goipp.MakeAttribute("media-source", goipp.TagKeyword, goipp.String("auto")))
|
|
col.Add(goipp.MakeAttribute("media-type", goipp.TagKeyword, goipp.String("stationery")))
|
|
return goipp.MakeAttribute("media-col", goipp.TagBeginCollection, col)
|
|
}
|
|
|
|
func (s *IPPServer) uuid() string {
|
|
h := sha1.Sum([]byte("verae-timestamp-pdf|" + s.Addr))
|
|
return fmt.Sprintf("urn:uuid:%x-%x-%x-%x-%x", h[0:4], h[4:6], h[6:8], h[8:10], h[10:16])
|
|
}
|
|
|
|
func (s *IPPServer) printerAttrs(req *goipp.Message, r *http.Request) goipp.Attributes {
|
|
uri := s.printerURI(req, r)
|
|
sec := "none"
|
|
if strings.HasPrefix(uri, "ipps:") || strings.HasPrefix(uri, "https:") {
|
|
sec = "tls"
|
|
}
|
|
s.mu.Lock()
|
|
queued := 0
|
|
busy := false
|
|
for _, j := range s.jobs {
|
|
if j.state < jsCanceled {
|
|
queued++
|
|
busy = busy || j.state == jsProcessing
|
|
}
|
|
}
|
|
s.mu.Unlock()
|
|
state := 3
|
|
if busy {
|
|
state = 4
|
|
}
|
|
up := int(time.Since(s.started).Seconds()) + 1
|
|
|
|
var a goipp.Attributes
|
|
add := func(x goipp.Attribute) { a.Add(x) }
|
|
add(goipp.MakeAttribute("printer-uri-supported", goipp.TagURI, goipp.String(uri)))
|
|
add(kw("uri-security-supported", sec))
|
|
add(kw("uri-authentication-supported", "none"))
|
|
add(goipp.MakeAttribute("printer-name", goipp.TagName, goipp.String(s.Name)))
|
|
add(goipp.MakeAttribute("printer-info", goipp.TagText, goipp.String(s.Name)))
|
|
add(goipp.MakeAttribute("printer-make-and-model", goipp.TagText, goipp.String("Verae TimeStamp PDF")))
|
|
add(goipp.MakeAttribute("printer-location", goipp.TagText, goipp.String("This PC (loopback)")))
|
|
add(goipp.MakeAttribute("printer-more-info", goipp.TagURI, goipp.String("http://"+s.Addr+"/")))
|
|
add(goipp.MakeAttribute("printer-uuid", goipp.TagURI, goipp.String(s.uuid())))
|
|
add(goipp.MakeAttribute("printer-device-id", goipp.TagText, goipp.String("MFG:Verae;MDL:TimeStamp PDF;CMD:PDF,PWGRaster;CLS:PRINTER;DES:Verae TimeStamp PDF;")))
|
|
add(ints("printer-state", goipp.TagEnum, state))
|
|
add(kw("printer-state-reasons", "none"))
|
|
add(goipp.MakeAttribute("printer-state-message", goipp.TagText, goipp.String("ready")))
|
|
add(goipp.MakeAttribute("printer-is-accepting-jobs", goipp.TagBoolean, goipp.Boolean(true)))
|
|
add(ints("queued-job-count", goipp.TagInteger, queued))
|
|
add(ints("printer-up-time", goipp.TagInteger, up))
|
|
add(ints("printer-state-change-time", goipp.TagInteger, 1))
|
|
add(ints("printer-config-change-time", goipp.TagInteger, 1))
|
|
add(goipp.MakeAttribute("printer-current-time", goipp.TagDateTime, goipp.Time{Time: time.Now()}))
|
|
add(kw("ipp-versions-supported", "1.1", "2.0"))
|
|
add(kw("ipp-features-supported", "ipp-everywhere"))
|
|
add(ints("operations-supported", goipp.TagEnum,
|
|
int(goipp.OpPrintJob), int(goipp.OpValidateJob), int(goipp.OpCreateJob), int(goipp.OpSendDocument),
|
|
int(goipp.OpCancelJob), int(goipp.OpGetJobAttributes), int(goipp.OpGetJobs), int(goipp.OpGetPrinterAttributes),
|
|
int(goipp.OpCloseJob)))
|
|
add(multi("charset-configured", goipp.TagCharset, "utf-8"))
|
|
add(multi("charset-supported", goipp.TagCharset, "utf-8"))
|
|
add(multi("natural-language-configured", goipp.TagLanguage, "en"))
|
|
add(multi("generated-natural-language-supported", goipp.TagLanguage, "en"))
|
|
add(multi("document-format-supported", goipp.TagMimeType, s.Formats...))
|
|
add(multi("document-format-default", goipp.TagMimeType, s.Formats[0]))
|
|
add(multi("document-format-preferred", goipp.TagMimeType, "application/pdf"))
|
|
add(kw("pdf-versions-supported", "adobe-1.3", "adobe-1.4", "adobe-1.5", "adobe-1.6", "adobe-1.7", "iso-32000-1_2008"))
|
|
add(kw("compression-supported", "none"))
|
|
add(kw("pdl-override-supported", "attempted"))
|
|
add(goipp.MakeAttribute("multiple-document-jobs-supported", goipp.TagBoolean, goipp.Boolean(false)))
|
|
add(kw("multiple-document-handling-supported", "separate-documents-uncollated-copies", "separate-documents-collated-copies"))
|
|
add(ints("multiple-operation-time-out", goipp.TagInteger, 120))
|
|
add(kw("multiple-operation-time-out-action", "process-job"))
|
|
add(kw("which-jobs-supported", "completed", "not-completed"))
|
|
add(kw("job-ids-supported", "true"))
|
|
add(kw("printer-kind", "document"))
|
|
add(goipp.MakeAttribute("color-supported", goipp.TagBoolean, goipp.Boolean(true)))
|
|
add(goipp.MakeAttribute("copies-default", goipp.TagInteger, goipp.Integer(1)))
|
|
add(goipp.MakeAttribute("copies-supported", goipp.TagRange, goipp.Range{Lower: 1, Upper: 1}))
|
|
add(ints("finishings-default", goipp.TagEnum, 3))
|
|
add(ints("finishings-supported", goipp.TagEnum, 3))
|
|
add(ints("orientation-requested-default", goipp.TagEnum, 3))
|
|
add(ints("orientation-requested-supported", goipp.TagEnum, 3, 4, 5, 6))
|
|
add(kw("sides-default", "one-sided"))
|
|
add(kw("sides-supported", "one-sided"))
|
|
add(kw("print-color-mode-default", "color"))
|
|
add(kw("print-color-mode-supported", "auto", "color", "monochrome"))
|
|
add(ints("print-quality-default", goipp.TagEnum, 4))
|
|
add(ints("print-quality-supported", goipp.TagEnum, 3, 4, 5))
|
|
add(kw("output-bin-default", "face-up"))
|
|
add(kw("output-bin-supported", "face-up"))
|
|
add(kw("print-scaling-default", "auto"))
|
|
add(kw("print-scaling-supported", "auto", "auto-fit", "fill", "fit", "none"))
|
|
res := goipp.Resolution{Xres: 600, Yres: 600, Units: goipp.UnitsDpi}
|
|
res3 := goipp.Resolution{Xres: 300, Yres: 300, Units: goipp.UnitsDpi}
|
|
add(goipp.MakeAttribute("printer-resolution-default", goipp.TagResolution, res))
|
|
pr := goipp.Attribute{Name: "printer-resolution-supported"}
|
|
pr.Values.Add(goipp.TagResolution, res3)
|
|
pr.Values.Add(goipp.TagResolution, res)
|
|
add(pr)
|
|
pw := goipp.Attribute{Name: "pwg-raster-document-resolution-supported"}
|
|
pw.Values.Add(goipp.TagResolution, res3)
|
|
pw.Values.Add(goipp.TagResolution, res)
|
|
add(pw)
|
|
add(kw("pwg-raster-document-type-supported", "sgray_8", "srgb_8"))
|
|
add(kw("pwg-raster-document-sheet-back", "normal"))
|
|
// media (hundredths of mm)
|
|
add(kw("media-default", "na_letter_8.5x11in"))
|
|
add(kw("media-supported", "na_letter_8.5x11in", "na_legal_8.5x14in", "iso_a4_210x297mm"))
|
|
add(kw("media-ready", "na_letter_8.5x11in", "iso_a4_210x297mm"))
|
|
add(kw("media-type-supported", "stationery"))
|
|
add(kw("media-source-supported", "auto"))
|
|
add(ints("media-bottom-margin-supported", goipp.TagInteger, 0))
|
|
add(ints("media-left-margin-supported", goipp.TagInteger, 0))
|
|
add(ints("media-right-margin-supported", goipp.TagInteger, 0))
|
|
add(ints("media-top-margin-supported", goipp.TagInteger, 0))
|
|
letter, legal, a4 := mediaCol(21590, 27940, 0), mediaCol(21590, 35560, 0), mediaCol(21000, 29700, 0)
|
|
def := letter
|
|
def.Name = "media-col-default"
|
|
add(def)
|
|
db := goipp.Attribute{Name: "media-col-database"}
|
|
for _, m := range []goipp.Attribute{letter, legal, a4} {
|
|
db.Values = append(db.Values, m.Values...)
|
|
}
|
|
add(db)
|
|
rd := goipp.Attribute{Name: "media-col-ready"}
|
|
rd.Values = append(rd.Values, letter.Values...)
|
|
rd.Values = append(rd.Values, a4.Values...)
|
|
add(rd)
|
|
ms := goipp.Attribute{Name: "media-size-supported"}
|
|
for _, wh := range [][2]int{{21590, 27940}, {21590, 35560}, {21000, 29700}} {
|
|
c := goipp.Collection{}
|
|
c.Add(goipp.MakeAttribute("x-dimension", goipp.TagInteger, goipp.Integer(wh[0])))
|
|
c.Add(goipp.MakeAttribute("y-dimension", goipp.TagInteger, goipp.Integer(wh[1])))
|
|
ms.Values.Add(goipp.TagBeginCollection, c)
|
|
}
|
|
add(ms)
|
|
add(kw("media-col-supported", "media-size", "media-type", "media-source", "media-bottom-margin", "media-left-margin", "media-right-margin", "media-top-margin"))
|
|
add(kw("job-creation-attributes-supported", "copies", "media", "media-col", "orientation-requested", "print-color-mode", "print-quality", "sides", "output-bin", "print-scaling", "printer-resolution"))
|
|
add(goipp.MakeAttribute("job-k-octets-supported", goipp.TagRange, goipp.Range{Lower: 0, Upper: 2000000}))
|
|
return a
|
|
}
|
|
|
|
func attrString(attrs goipp.Attributes, name string) string {
|
|
for _, a := range attrs {
|
|
if a.Name == name && len(a.Values) > 0 {
|
|
return a.Values[0].V.String()
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func attrInt(attrs goipp.Attributes, name string) int32 {
|
|
for _, a := range attrs {
|
|
if a.Name == name && len(a.Values) > 0 {
|
|
if v, ok := a.Values[0].V.(goipp.Integer); ok {
|
|
return int32(v)
|
|
}
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func firstNonEmpty(ss ...string) string {
|
|
for _, s := range ss {
|
|
if strings.TrimSpace(s) != "" {
|
|
return s
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "…"
|
|
}
|