// 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] + "…" }