PrintToVeraeTimeStamp/internal/winprint/ipp.go
2026-10-03 18:50:49 -04:00

643 lines
24 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 {
if req.RequestID == 0 {
return s.newResp(req, goipp.StatusErrorBadRequest, "request-id must be non-zero")
}
if attrString(req.Operation, "attributes-charset") == "" || attrString(req.Operation, "attributes-natural-language") == "" {
return s.newResp(req, goipp.StatusErrorBadRequest, "missing attributes-charset / attributes-natural-language")
}
if attrString(req.Operation, "printer-uri") == "" && attrString(req.Operation, "job-uri") == "" {
return s.newResp(req, goipp.StatusErrorBadRequest, "missing printer-uri")
}
switch goipp.Op(req.Code) {
case goipp.OpIdentifyPrinter, goipp.OpCancelMyJobs:
return s.newResp(req, goipp.StatusOk, "")
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(goipp.MakeAttribute("printer-config-change-date-time", goipp.TagDateTime, goipp.Time{Time: s.started}))
add(goipp.MakeAttribute("printer-state-change-date-time", goipp.TagDateTime, goipp.Time{Time: s.started}))
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), int(goipp.OpCancelMyJobs), int(goipp.OpIdentifyPrinter)))
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(goipp.MakeAttribute("job-ids-supported", goipp.TagBoolean, goipp.Boolean(true)))
add(goipp.MakeAttribute("page-ranges-supported", goipp.TagBoolean, goipp.Boolean(false)))
add(goipp.MakeAttribute("preferred-attributes-supported", goipp.TagBoolean, goipp.Boolean(false)))
add(ints("pages-per-minute", goipp.TagInteger, 60))
add(ints("pages-per-minute-color", goipp.TagInteger, 60))
add(kw("identify-actions-default", "display"))
add(kw("identify-actions-supported", "display"))
add(kw("overrides-supported", "document-number", "pages"))
add(kw("print-content-optimize-default", "auto"))
add(kw("print-content-optimize-supported", "auto"))
add(kw("print-rendering-intent-default", "auto"))
add(kw("print-rendering-intent-supported", "auto"))
add(kw("printer-get-attributes-supported", "document-format"))
add(goipp.MakeAttribute("printer-geo-location", goipp.TagUnknown, goipp.Void{}))
add(goipp.MakeAttribute("printer-organization", goipp.TagText, goipp.String("")))
add(goipp.MakeAttribute("printer-organizational-unit", goipp.TagText, goipp.String("")))
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] + "…"
}