diff --git a/cmd/tp/internal/cmdserver/cmdserver.go b/cmd/tp/internal/cmdserver/cmdserver.go index 2818ef9..a72db29 100644 --- a/cmd/tp/internal/cmdserver/cmdserver.go +++ b/cmd/tp/internal/cmdserver/cmdserver.go @@ -59,7 +59,7 @@ func runServer(ctx context.Context, cmd *base.Command, args []string) error { base.SetExitStatus(base.SApplicationError) return fmt.Errorf("failed to get printer: %w", err) } - ippPrn, err := ippsrv.WrapDriver(p, "default", "Thermal Printer") + ippPrn, err := ippsrv.WrapDriver(p, "default", "LX-D02 Thermal Printer") if err != nil { base.SetExitStatus(base.SApplicationError) return fmt.Errorf("failed to wrap printer: %w", err) diff --git a/cmd/tp/siginfo_generic.go b/cmd/tp/siginfo_generic.go new file mode 100644 index 0000000..2b124d0 --- /dev/null +++ b/cmd/tp/siginfo_generic.go @@ -0,0 +1,6 @@ +//go:build !darwin +package main + + +func trapSigInfo() { +} diff --git a/cupsraster/cupsraster.go b/cupsraster/cupsraster.go new file mode 100644 index 0000000..4b4040f --- /dev/null +++ b/cupsraster/cupsraster.go @@ -0,0 +1,160 @@ +// Package cupsraster decodes the raster streams produced by client-side +// rasterisation in CUPS and macOS/iOS printing: PWG Raster (PWG 5102.4, +// image/pwg-raster) and Apple Raster (URF, image/urf). Both formats carry +// one or more pre-rendered pages compressed with the same simple run-length +// scheme; the decoder converts each page to an image.Image. +// +// References: +// - https://ftp.pwg.org/pub/pwg/candidates/cs-ippraster10-20120420-5102.4.pdf +// - https://openprinting.github.io/driverless/01-standards-and-their-pdls/#apple-raster +package cupsraster + +import ( + "bufio" + "fmt" + "image" + "image/color" + "io" +) + +// Format identifies a supported raster stream format. +type Format int + +const ( + FormatUnknown Format = iota + FormatPWG // PWG Raster (image/pwg-raster) + FormatURF // Apple Raster (image/urf) +) + +func (f Format) String() string { + switch f { + case FormatPWG: + return "PWG" + case FormatURF: + return "URF" + } + return "unknown" +} + +// maxDim caps page dimensions to guard against corrupt headers. +const maxDim = 32768 + +// maxPixels caps total page size (width*height) to about 512 MiB of 8-bit +// gray, which is far beyond anything a label printer will see. +const maxPixels = 1 << 29 + +// Detect sniffs the magic bytes of data and reports the raster format, or +// FormatUnknown. PWG raster is identified by both the sync word and the +// PwgRaster header magic, which distinguishes it from little-endian CUPS +// raster streams sharing the sync word. +func Detect(data []byte) Format { + if len(data) >= len(pwgSyncWord)+len(pwgMagic) && + string(data[:len(pwgSyncWord)]) == pwgSyncWord && + string(data[len(pwgSyncWord):len(pwgSyncWord)+len(pwgMagic)]) == pwgMagic { + return FormatPWG + } + if len(data) >= len(urfMagic) && string(data[:len(urfMagic)]) == urfMagic { + return FormatURF + } + return FormatUnknown +} + +// Page is a decoded raster page together with the resolution declared in +// its header. A consumer printing at a different resolution must scale the +// image accordingly to preserve the physical size. +type Page struct { + image.Image + XDPI, YDPI int +} + +// Decode sniffs the stream format and decodes all pages. +func Decode(r io.Reader) ([]image.Image, error) { + pages, err := DecodePages(r) + if err != nil { + return nil, err + } + return images(pages), nil +} + +// DecodePages sniffs the stream format and decodes all pages with their +// declared resolutions. +func DecodePages(r io.Reader) ([]Page, error) { + br := bufio.NewReader(r) + head, err := br.Peek(len(pwgSyncWord) + len(pwgMagic)) + if err != nil && len(head) == 0 { + return nil, fmt.Errorf("reading stream header: %w", err) + } + switch Detect(head) { + case FormatPWG: + return decodePWGPages(br) + case FormatURF: + return decodeURFPages(br) + } + return nil, fmt.Errorf("unrecognised raster stream (header % x)", head) +} + +func images(pages []Page) []image.Image { + imgs := make([]image.Image, len(pages)) + for i, pg := range pages { + imgs[i] = pg.Image + } + return imgs +} + +func checkDimensions(width, height int) error { + if width <= 0 || height <= 0 || width > maxDim || height > maxDim || width*height > maxPixels { + return fmt.Errorf("invalid page dimensions %dx%d", width, height) + } + return nil +} + +// decodeGrayPage decodes a 1- or 8-bit single-channel page into image.Gray. +// blackOne selects ink semantics (K color space: max value = black) as +// opposed to luminance semantics (sGray: zero = black). +func decodeGrayPage(br *bufio.Reader, width, height, bpp, bytesPerLine int, blackOne bool) (*image.Gray, error) { + img := image.NewGray(image.Rect(0, 0, width, height)) + // The RLE blank filler must be white in the page's own semantics. + fill := byte(0xff) + if blackOne { + fill = 0x00 + } + err := decodeLines(br, height, bytesPerLine, 1, fill, func(y int, row []byte) { + dst := img.Pix[y*img.Stride : y*img.Stride+width] + switch bpp { + case 1: + for x := 0; x < width; x++ { + bit := row[x/8]>>(7-x%8)&1 == 1 + if bit == blackOne { + dst[x] = 0x00 // black + } else { + dst[x] = 0xff // white + } + } + case 8: + copy(dst, row) + if blackOne { + for x := range dst { + dst[x] = 0xff - dst[x] + } + } + } + }) + if err != nil { + return nil, err + } + return img, nil +} + +// decodeRGBPage decodes a 24-bit chunky RGB page into image.NRGBA. +func decodeRGBPage(br *bufio.Reader, width, height, bytesPerLine int) (*image.NRGBA, error) { + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + err := decodeLines(br, height, bytesPerLine, 3, 0xff, func(y int, row []byte) { + for x := 0; x < width; x++ { + img.SetNRGBA(x, y, color.NRGBA{R: row[x*3], G: row[x*3+1], B: row[x*3+2], A: 0xff}) + } + }) + if err != nil { + return nil, err + } + return img, nil +} diff --git a/cupsraster/fixture_test.go b/cupsraster/fixture_test.go new file mode 100644 index 0000000..8b1d362 --- /dev/null +++ b/cupsraster/fixture_test.go @@ -0,0 +1,112 @@ +package cupsraster + +import ( + "bytes" + "image" + "os" + "testing" +) + +// The testdata fixtures were generated on macOS from the same source image +// (black border + black bars on white, 384x320) with: +// +// cupsfilter -i image/png -m image/pwg-raster -p ippsrv/ppd/LX-D02.ppd -o Resolution=203dpi fixture.png > doc.pwg +// cupsfilter -i image/png -m image/urf -p ippsrv/ppd/LX-D02.ppd -o Resolution=203dpi fixture.png > doc.urf +// +// Decoding both must yield the same page with the same polarity: black +// content black, white background white — PWG black_1 (K space, bit 1 = +// black) and URF mono (sGray, bit 0 = black) use opposite bit semantics for +// identical content. + +func loadFixture(t *testing.T, name string, want Format) image.Image { + t.Helper() + data, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatal(err) + } + if got := Detect(data); got != want { + t.Fatalf("Detect(%s) = %v, want %v", name, got, want) + } + pages, err := Decode(bytes.NewReader(data)) + if err != nil { + t.Fatalf("Decode(%s): %v", name, err) + } + if len(pages) != 1 { + t.Fatalf("%s: got %d pages, want 1", name, len(pages)) + } + return pages[0] +} + +// blackFraction returns the fraction of pixels darker than mid-gray. +func blackFraction(img image.Image) float64 { + b := img.Bounds() + var black, total int + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + r, g, bb, _ := img.At(x, y).RGBA() + if (r+g+bb)/3 < 0x8000 { + black++ + } + total++ + } + } + return float64(black) / float64(total) +} + +func TestFixtures_PolarityAndConsistency(t *testing.T) { + pwg := loadFixture(t, "doc.pwg", FormatPWG) + urf := loadFixture(t, "doc.urf", FormatURF) + + // the macOS cupsfilter fixtures are 100dpi (it ignores -o Resolution); + // the declared resolution must be surfaced so the printer can rescale. + for _, name := range []string{"doc.pwg", "doc.urf"} { + data, err := os.ReadFile("testdata/" + name) + if err != nil { + t.Fatal(err) + } + pages, err := DecodePages(bytes.NewReader(data)) + if err != nil { + t.Fatal(err) + } + if pages[0].XDPI != 100 || pages[0].YDPI != 100 { + t.Errorf("%s: declared resolution %dx%d, want 100x100", name, pages[0].XDPI, pages[0].YDPI) + } + } + + if pwg.Bounds() != urf.Bounds() { + t.Fatalf("bounds differ: pwg %v, urf %v", pwg.Bounds(), urf.Bounds()) + } + t.Logf("fixture page: %v", pwg.Bounds()) + + for _, tt := range []struct { + name string + img image.Image + }{{"pwg", pwg}, {"urf", urf}} { + frac := blackFraction(tt.img) + t.Logf("%s black fraction: %.3f", tt.name, frac) + if frac < 0.02 { + t.Errorf("%s: black fraction %.3f — page looks blank; polarity inverted or content lost", tt.name, frac) + } + if frac > 0.5 { + t.Errorf("%s: black fraction %.3f — page mostly black; polarity likely inverted", tt.name, frac) + } + } + + // Both formats must agree pixel-perfectly on 1-bit content rendered + // from the same source through the same PPD. + b := pwg.Bounds() + diff := 0 + for y := b.Min.Y; y < b.Max.Y; y++ { + for x := b.Min.X; x < b.Max.X; x++ { + pr, _, _, _ := pwg.At(x, y).RGBA() + ur, _, _, _ := urf.At(x, y).RGBA() + if (pr < 0x8000) != (ur < 0x8000) { + diff++ + } + } + } + total := b.Dx() * b.Dy() + if frac := float64(diff) / float64(total); frac > 0.01 { + t.Errorf("pwg and urf pages disagree on %.2f%% of pixels — polarity mismatch between decoders", frac*100) + } +} diff --git a/cupsraster/pwg.go b/cupsraster/pwg.go new file mode 100644 index 0000000..16e1793 --- /dev/null +++ b/cupsraster/pwg.go @@ -0,0 +1,148 @@ +package cupsraster + +import ( + "bufio" + "encoding/binary" + "errors" + "fmt" + "image" + "io" +) + +// PWG Raster stream layout (PWG 5102.4): a 4-byte synchronisation word +// "RaS2", then for each page a 1796-byte big-endian header followed by +// RLE-compressed page data. + +const ( + pwgSyncWord = "RaS2" + pwgMagic = "PwgRaster\x00" + pwgHeaderSize = 1796 +) + +// Header field byte offsets (PWG 5102.4 §4.3, empirically verified against +// macOS cupsfilter output). +const ( + pwgOffHWResolutionX = 276 + pwgOffHWResolutionY = 280 + pwgOffWidth = 372 + pwgOffHeight = 376 + pwgOffBitsPerColor = 384 + pwgOffBitsPerPixel = 388 + pwgOffBytesPerLine = 392 + pwgOffColorOrder = 396 + pwgOffColorSpace = 400 +) + +// PWG cupsColorSpace values (subset the decoder understands). +const ( + pwgCSBlack = 3 // K, ink semantics: 1 = black + pwgCSSGray = 18 // sGray, luminance semantics: 0 = black + pwgCSSRGB = 19 // sRGB + pwgCSAdobeRGB = 20 // AdobeRGB, treated as sRGB +) + +type pwgHeader struct { + Width, Height int + BitsPerColor, BitsPerPixel int + BytesPerLine int + ColorOrder int + ColorSpace int + XRes, YRes int +} + +func parsePWGHeader(buf []byte) (pwgHeader, error) { + u32 := func(off int) int { + return int(binary.BigEndian.Uint32(buf[off : off+4])) + } + h := pwgHeader{ + Width: u32(pwgOffWidth), + Height: u32(pwgOffHeight), + BitsPerColor: u32(pwgOffBitsPerColor), + BitsPerPixel: u32(pwgOffBitsPerPixel), + BytesPerLine: u32(pwgOffBytesPerLine), + ColorOrder: u32(pwgOffColorOrder), + ColorSpace: u32(pwgOffColorSpace), + XRes: u32(pwgOffHWResolutionX), + YRes: u32(pwgOffHWResolutionY), + } + if err := checkDimensions(h.Width, h.Height); err != nil { + return h, err + } + if h.ColorOrder != 0 { + return h, fmt.Errorf("unsupported color order %d (only chunky is valid)", h.ColorOrder) + } + switch h.ColorSpace { + case pwgCSBlack, pwgCSSGray: + if h.BitsPerPixel != 1 && h.BitsPerPixel != 8 { + return h, fmt.Errorf("unsupported bits per pixel %d for color space %d", h.BitsPerPixel, h.ColorSpace) + } + case pwgCSSRGB, pwgCSAdobeRGB: + if h.BitsPerPixel != 24 { + return h, fmt.Errorf("unsupported bits per pixel %d for color space %d", h.BitsPerPixel, h.ColorSpace) + } + default: + return h, fmt.Errorf("unsupported color space %d", h.ColorSpace) + } + if want := (h.Width*h.BitsPerPixel + 7) / 8; h.BytesPerLine != want { + return h, fmt.Errorf("bytes per line %d inconsistent with width %d at %d bpp (want %d)", h.BytesPerLine, h.Width, h.BitsPerPixel, want) + } + return h, nil +} + +// DecodePWG decodes a PWG Raster stream into one image per page. +func DecodePWG(r io.Reader) ([]image.Image, error) { + pages, err := decodePWGPages(bufio.NewReader(r)) + if err != nil { + return nil, err + } + return images(pages), nil +} + +func decodePWGPages(br *bufio.Reader) ([]Page, error) { + sync := make([]byte, len(pwgSyncWord)) + if _, err := io.ReadFull(br, sync); err != nil { + return nil, fmt.Errorf("reading sync word: %w", err) + } + if string(sync) != pwgSyncWord { + return nil, fmt.Errorf("not a PWG raster stream: sync word %q", sync) + } + var pages []Page + hdr := make([]byte, pwgHeaderSize) + for page := 1; ; page++ { + if _, err := io.ReadFull(br, hdr); err != nil { + if err == io.EOF && page > 1 { + break // clean end of stream + } + return nil, fmt.Errorf("page %d: reading header: %w", page, err) + } + if string(hdr[:len(pwgMagic)]) != pwgMagic { + return nil, fmt.Errorf("page %d: header does not start with %q", page, pwgMagic[:len(pwgMagic)-1]) + } + h, err := parsePWGHeader(hdr) + if err != nil { + return nil, fmt.Errorf("page %d: %w", page, err) + } + img, err := decodePWGPage(br, h) + if err != nil { + return nil, fmt.Errorf("page %d: %w", page, err) + } + pages = append(pages, Page{Image: img, XDPI: h.XRes, YDPI: h.YRes}) + } + if len(pages) == 0 { + return nil, errors.New("no pages in PWG raster stream") + } + return pages, nil +} + +func decodePWGPage(br *bufio.Reader, h pwgHeader) (image.Image, error) { + // blackOne: 1-bit K semantics, where a set bit is black. In sGray (and + // 8-bit) luminance semantics zero is black. + blackOne := h.ColorSpace == pwgCSBlack + switch h.BitsPerPixel { + case 1, 8: + return decodeGrayPage(br, h.Width, h.Height, h.BitsPerPixel, h.BytesPerLine, blackOne) + case 24: + return decodeRGBPage(br, h.Width, h.Height, h.BytesPerLine) + } + panic("unreachable: bpp validated in parsePWGHeader") +} diff --git a/cupsraster/pwg_test.go b/cupsraster/pwg_test.go new file mode 100644 index 0000000..c8c5df4 --- /dev/null +++ b/cupsraster/pwg_test.go @@ -0,0 +1,229 @@ +package cupsraster + +import ( + "bytes" + "encoding/binary" + "image" + "strings" + "testing" +) + +// buildPWGHeader assembles a 1796-byte page header with the fields the +// decoder reads. +func buildPWGHeader(width, height, bitsPerPixel, colorSpace int) []byte { + hdr := make([]byte, pwgHeaderSize) + copy(hdr, pwgMagic) + u32 := func(off, v int) { binary.BigEndian.PutUint32(hdr[off:off+4], uint32(v)) } + u32(pwgOffHWResolutionX, 203) + u32(pwgOffHWResolutionY, 203) + u32(pwgOffWidth, width) + u32(pwgOffHeight, height) + bpc := bitsPerPixel + if bitsPerPixel == 24 { + bpc = 8 + } + u32(pwgOffBitsPerColor, bpc) + u32(pwgOffBitsPerPixel, bitsPerPixel) + u32(pwgOffBytesPerLine, (width*bitsPerPixel+7)/8) + u32(pwgOffColorOrder, 0) + u32(pwgOffColorSpace, colorSpace) + return hdr +} + +// buildPWGStream assembles a full PWG stream of pages, each given as rows. +func buildPWGStream(t *testing.T, hdrRows ...struct { + hdr []byte + rows [][]byte +}) []byte { + t.Helper() + var buf bytes.Buffer + buf.WriteString(pwgSyncWord) + for _, p := range hdrRows { + buf.Write(p.hdr) + groupSize := 1 + if bpp := int(binary.BigEndian.Uint32(p.hdr[pwgOffBitsPerPixel:])); bpp == 24 { + groupSize = 3 + } + encodePage(&buf, p.rows, groupSize) + } + return buf.Bytes() +} + +type pwgPage = struct { + hdr []byte + rows [][]byte +} + +func TestDecodePWG_Black1(t *testing.T) { + // 16x2, K space, 1 bit: set bit means BLACK. + rows := [][]byte{ + {0xff, 0x00}, // 8 black, 8 white + {0x80, 0x01}, // black at x=0 and x=15 + } + stream := buildPWGStream(t, pwgPage{buildPWGHeader(16, 2, 1, pwgCSBlack), rows}) + + pages, err := DecodePWG(bytes.NewReader(stream)) + if err != nil { + t.Fatal(err) + } + if len(pages) != 1 { + t.Fatalf("got %d pages, want 1", len(pages)) + } + img := pages[0].(*image.Gray) + if got := img.Bounds(); got != image.Rect(0, 0, 16, 2) { + t.Fatalf("bounds %v", got) + } + // K polarity: bit 1 = black (gray 0) + for x := 0; x < 8; x++ { + if img.GrayAt(x, 0).Y != 0 { + t.Errorf("pixel (%d,0): got %d, want black (0)", x, img.GrayAt(x, 0).Y) + } + } + for x := 8; x < 16; x++ { + if img.GrayAt(x, 0).Y != 0xff { + t.Errorf("pixel (%d,0): got %d, want white (255)", x, img.GrayAt(x, 0).Y) + } + } + if img.GrayAt(0, 1).Y != 0 || img.GrayAt(15, 1).Y != 0 { + t.Error("edge pixels on row 1 must be black") + } + if img.GrayAt(7, 1).Y != 0xff { + t.Error("middle pixel on row 1 must be white") + } +} + +func TestDecodePWG_SGray1Polarity(t *testing.T) { + // Same bit pattern as K test but sGray: bit 0 = BLACK (inverse). + rows := [][]byte{{0xff, 0x00}} + stream := buildPWGStream(t, pwgPage{buildPWGHeader(16, 1, 1, pwgCSSGray), rows}) + pages, err := DecodePWG(bytes.NewReader(stream)) + if err != nil { + t.Fatal(err) + } + img := pages[0].(*image.Gray) + if img.GrayAt(0, 0).Y != 0xff { + t.Error("sGray bit 1 must decode white") + } + if img.GrayAt(15, 0).Y != 0x00 { + t.Error("sGray bit 0 must decode black") + } +} + +func TestDecodePWG_SGray8(t *testing.T) { + rows := [][]byte{{0, 64, 128, 255}} + stream := buildPWGStream(t, pwgPage{buildPWGHeader(4, 1, 8, pwgCSSGray), rows}) + pages, err := DecodePWG(bytes.NewReader(stream)) + if err != nil { + t.Fatal(err) + } + img := pages[0].(*image.Gray) + for x, want := range []byte{0, 64, 128, 255} { + if got := img.GrayAt(x, 0).Y; got != want { + t.Errorf("pixel %d: got %d, want %d", x, got, want) + } + } +} + +func TestDecodePWG_SRGB24(t *testing.T) { + rows := [][]byte{{255, 0, 0, 0, 255, 0, 0, 0, 255}} + stream := buildPWGStream(t, pwgPage{buildPWGHeader(3, 1, 24, pwgCSSRGB), rows}) + pages, err := DecodePWG(bytes.NewReader(stream)) + if err != nil { + t.Fatal(err) + } + img := pages[0].(*image.NRGBA) + if c := img.NRGBAAt(0, 0); c.R != 255 || c.G != 0 || c.B != 0 { + t.Errorf("pixel 0: %v, want red", c) + } + if c := img.NRGBAAt(2, 0); c.B != 255 { + t.Errorf("pixel 2: %v, want blue", c) + } +} + +func TestDecodePWG_MultiPage(t *testing.T) { + p1 := pwgPage{buildPWGHeader(8, 2, 1, pwgCSBlack), [][]byte{{0xff}, {0xff}}} + p2 := pwgPage{buildPWGHeader(8, 1, 1, pwgCSBlack), [][]byte{{0x00}}} + stream := buildPWGStream(t, p1, p2) + pages, err := DecodePWG(bytes.NewReader(stream)) + if err != nil { + t.Fatal(err) + } + if len(pages) != 2 { + t.Fatalf("got %d pages, want 2", len(pages)) + } + if pages[0].Bounds().Dy() != 2 || pages[1].Bounds().Dy() != 1 { + t.Error("page dimensions mixed up") + } +} + +func TestDecodePWG_Errors(t *testing.T) { + valid := pwgPage{buildPWGHeader(8, 1, 1, pwgCSBlack), [][]byte{{0x00}}} + tests := []struct { + name string + stream []byte + wantSub string + }{ + {"bad sync", []byte("XXXX"), "sync word"}, + {"bad magic", append([]byte(pwgSyncWord), make([]byte, pwgHeaderSize)...), "header does not start"}, + {"truncated header", []byte(pwgSyncWord + pwgMagic), "reading header"}, + {"truncated page data", buildPWGStream(t, valid)[:len(pwgSyncWord)+pwgHeaderSize+1], "page 1"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := DecodePWG(bytes.NewReader(tt.stream)) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.wantSub) { + t.Errorf("error %q does not contain %q", err, tt.wantSub) + } + }) + } +} + +func TestParsePWGHeader_Validation(t *testing.T) { + tests := []struct { + name string + mangle func(hdr []byte) + }{ + {"zero width", func(h []byte) { binary.BigEndian.PutUint32(h[pwgOffWidth:], 0) }}, + {"huge height", func(h []byte) { binary.BigEndian.PutUint32(h[pwgOffHeight:], 1e6) }}, + {"banded color order", func(h []byte) { binary.BigEndian.PutUint32(h[pwgOffColorOrder:], 1) }}, + {"unknown color space", func(h []byte) { binary.BigEndian.PutUint32(h[pwgOffColorSpace:], 99) }}, + {"bpl mismatch", func(h []byte) { binary.BigEndian.PutUint32(h[pwgOffBytesPerLine:], 7) }}, + {"bpp/cspace mismatch", func(h []byte) { binary.BigEndian.PutUint32(h[pwgOffBitsPerPixel:], 24) }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hdr := buildPWGHeader(16, 4, 1, pwgCSBlack) + tt.mangle(hdr) + if _, err := parsePWGHeader(hdr); err == nil { + t.Error("expected error") + } + }) + } +} + +func TestDetect(t *testing.T) { + pwg := buildPWGStream(t, pwgPage{buildPWGHeader(8, 1, 1, pwgCSBlack), [][]byte{{0x00}}}) + tests := []struct { + name string + data []byte + want Format + }{ + {"pwg", pwg, FormatPWG}, + {"urf", []byte("UNIRAST\x00\x00\x00\x00\x01"), FormatURF}, + {"cups raster LE lookalike", []byte("RaS2" + "NotPwgRas\x00" + "xxxx"), FormatUnknown}, + {"pdf", []byte("%PDF-1.7 ......."), FormatUnknown}, + {"png", []byte("\x89PNG\r\n\x1a\n........"), FormatUnknown}, + {"short", []byte("RaS2"), FormatUnknown}, + {"empty", nil, FormatUnknown}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := Detect(tt.data); got != tt.want { + t.Errorf("Detect() = %v, want %v", got, tt.want) + } + }) + } +} diff --git a/cupsraster/rle.go b/cupsraster/rle.go new file mode 100644 index 0000000..6dcc2c1 --- /dev/null +++ b/cupsraster/rle.go @@ -0,0 +1,86 @@ +package cupsraster + +import ( + "bufio" + "fmt" + "io" +) + +// decodeLines decodes the RLE-compressed page data shared by PWG Raster (PWG +// 5102.4 §4.2) and Apple URF into rows of bytesPerLine bytes. Each line +// group starts with a line-repeat byte (the decoded line applies to repeat+1 +// consecutive rows), followed by runs of pixel groups until bytesPerLine +// bytes are produced. A pixel group is groupSize bytes (1 for bpp <= 8, 3 +// for 24-bit RGB). Control byte c: +// +// - 0..127: the next group is repeated c+1 times; +// - 129..255: 257-c literal groups follow; +// - 128: reserved in PWG 5102.4; URF uses it as "fill the remainder of the +// line with blank (white)". Handled defensively for both. +// +// The fill byte for the 0x80 case depends on the colour space of the caller +// (0x00 is white in the black/K space, 0xff is white in sGray/sRGB), so it +// must be supplied explicitly. setRow is called once per decoded row, in +// order, with a buffer that is only valid until the next call. +func decodeLines(r *bufio.Reader, height, bytesPerLine, groupSize int, fill byte, setRow func(y int, row []byte)) error { + row := make([]byte, bytesPerLine) + for y := 0; y < height; { + repeat, err := r.ReadByte() + if err != nil { + return fmt.Errorf("row %d: reading line-repeat byte: %w", y, err) + } + if err := decodeLine(r, row, groupSize, fill); err != nil { + return fmt.Errorf("row %d: %w", y, err) + } + for n := int(repeat) + 1; n > 0 && y < height; n-- { + setRow(y, row) + y++ + } + } + return nil +} + +// decodeLine decodes a single RLE line into row. +func decodeLine(r *bufio.Reader, row []byte, groupSize int, fill byte) error { + for pos := 0; pos < len(row); { + c, err := r.ReadByte() + if err != nil { + return fmt.Errorf("reading control byte at offset %d: %w", pos, err) + } + switch { + case c < 128: + // repeated group + if pos+groupSize > len(row) { + return fmt.Errorf("group overflows line at offset %d", pos) + } + group := row[pos : pos+groupSize] + if _, err := io.ReadFull(r, group); err != nil { + return fmt.Errorf("reading repeated group at offset %d: %w", pos, err) + } + pos += groupSize + for n := int(c); n > 0; n-- { + if pos+groupSize > len(row) { + return fmt.Errorf("repeated group overflows line at offset %d", pos) + } + copy(row[pos:pos+groupSize], group) + pos += groupSize + } + case c > 128: + // literal groups + n := 257 - int(c) + if pos+n*groupSize > len(row) { + return fmt.Errorf("literal run of %d groups overflows line at offset %d", n, pos) + } + if _, err := io.ReadFull(r, row[pos:pos+n*groupSize]); err != nil { + return fmt.Errorf("reading %d literal groups at offset %d: %w", n, pos, err) + } + pos += n * groupSize + default: // c == 128 + // fill the remainder of the line with blank pixels. + for ; pos < len(row); pos++ { + row[pos] = fill + } + } + } + return nil +} diff --git a/cupsraster/rle_test.go b/cupsraster/rle_test.go new file mode 100644 index 0000000..d7472aa --- /dev/null +++ b/cupsraster/rle_test.go @@ -0,0 +1,191 @@ +package cupsraster + +import ( + "bufio" + "bytes" + "math/rand" + "strings" + "testing" +) + +// encodeLine RLE-encodes a single row for tests. It uses repeat runs for +// consecutive equal groups and literal runs otherwise. +func encodeLine(w *bytes.Buffer, row []byte, groupSize int) { + numGroups := len(row) / groupSize + group := func(i int) []byte { return row[i*groupSize : (i+1)*groupSize] } + for i := 0; i < numGroups; { + // count run of equal groups + run := 1 + for i+run < numGroups && run < 128 && bytes.Equal(group(i), group(i+run)) { + run++ + } + if run > 1 { + w.WriteByte(byte(run - 1)) // 0..127: group repeated c+1 times + w.Write(group(i)) + i += run + continue + } + // count literal groups (no two consecutive equal) + lit := 1 + for i+lit < numGroups && lit < 128 && + !(i+lit+1 <= numGroups-1 && bytes.Equal(group(i+lit), group(i+lit+1))) { + lit++ + } + if lit == 1 { + w.WriteByte(0) // single group as a repeat of 1 + w.Write(group(i)) + } else { + w.WriteByte(byte(257 - lit)) // 129..255: 257-c literal groups + w.Write(row[i*groupSize : (i+lit)*groupSize]) + } + i += lit + } +} + +// encodePage RLE-encodes rows, collapsing consecutive identical rows into +// line-repeat counts. +func encodePage(w *bytes.Buffer, rows [][]byte, groupSize int) { + for y := 0; y < len(rows); { + repeat := 0 + for y+repeat+1 < len(rows) && repeat < 255 && bytes.Equal(rows[y], rows[y+repeat+1]) { + repeat++ + } + w.WriteByte(byte(repeat)) + encodeLine(w, rows[y], groupSize) + y += repeat + 1 + } +} + +func decodeToRows(t *testing.T, data []byte, height, bytesPerLine, groupSize int, fill byte) [][]byte { + t.Helper() + rows := make([][]byte, height) + err := decodeLines(bufio.NewReader(bytes.NewReader(data)), height, bytesPerLine, groupSize, fill, + func(y int, row []byte) { + rows[y] = append([]byte(nil), row...) + }) + if err != nil { + t.Fatalf("decodeLines: %v", err) + } + return rows +} + +func TestRLERoundTrip(t *testing.T) { + tests := []struct { + name string + groupSize int + rows [][]byte + }{ + {"1bit runs", 1, [][]byte{ + {0x00, 0x00, 0x00, 0x00}, + {0xff, 0xff, 0xff, 0xff}, + {0xaa, 0x55, 0xaa, 0x55}, + }}, + {"1bit repeated lines", 1, [][]byte{ + {0x0f, 0x0f, 0x0f, 0x0f}, + {0x0f, 0x0f, 0x0f, 0x0f}, + {0x0f, 0x0f, 0x0f, 0x0f}, + {0xf0, 0xf0, 0xf0, 0xf0}, + }}, + {"8bit literals", 1, [][]byte{ + {1, 2, 3, 4, 5, 6, 7, 8}, + {8, 7, 6, 5, 4, 3, 2, 1}, + }}, + {"24bit groups", 3, [][]byte{ + {1, 2, 3, 1, 2, 3, 9, 9, 9}, + {5, 5, 5, 6, 6, 6, 7, 7, 7}, + }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var buf bytes.Buffer + encodePage(&buf, tt.rows, tt.groupSize) + got := decodeToRows(t, buf.Bytes(), len(tt.rows), len(tt.rows[0]), tt.groupSize, 0xff) + for y := range tt.rows { + if !bytes.Equal(got[y], tt.rows[y]) { + t.Errorf("row %d: got % x, want % x", y, got[y], tt.rows[y]) + } + } + }) + } +} + +func TestRLERoundTripRandom(t *testing.T) { + rng := rand.New(rand.NewSource(42)) + for _, groupSize := range []int{1, 3} { + const height, groups = 64, 32 + rows := make([][]byte, height) + for y := range rows { + rows[y] = make([]byte, groups*groupSize) + for i := range rows[y] { + rows[y][i] = byte(rng.Intn(4)) // few values → mixed runs/literals + } + } + var buf bytes.Buffer + encodePage(&buf, rows, groupSize) + got := decodeToRows(t, buf.Bytes(), height, groups*groupSize, groupSize, 0xff) + for y := range rows { + if !bytes.Equal(got[y], rows[y]) { + t.Fatalf("groupSize %d row %d: got % x, want % x", groupSize, y, got[y], rows[y]) + } + } + } +} + +func TestRLEFillRemainder(t *testing.T) { + // control byte 0x80 fills the rest of the line with the blank value, + // which depends on the colour space of the caller. + for _, tt := range []struct { + name string + fill byte + }{ + {"white is 0x00 (K space)", 0x00}, + {"white is 0xff (sGray)", 0xff}, + } { + t.Run(tt.name, func(t *testing.T) { + // one line: single literal group 0x42, then fill remainder + data := []byte{0x00 /* lineRepeat */, 0x00 /* repeat 1 group */, 0x42, 0x80 /* fill */} + rows := decodeToRows(t, data, 1, 8, 1, tt.fill) + want := []byte{0x42, tt.fill, tt.fill, tt.fill, tt.fill, tt.fill, tt.fill, tt.fill} + if !bytes.Equal(rows[0], want) { + t.Errorf("got % x, want % x", rows[0], want) + } + }) + } +} + +func TestRLEErrors(t *testing.T) { + tests := []struct { + name string + data []byte + }{ + {"empty", nil}, + {"truncated after lineRepeat", []byte{0x00}}, + {"truncated repeat group", []byte{0x00, 0x05}}, + {"truncated literal run", []byte{0x00, 0xfe /* 3 literals */, 0x01}}, + {"repeat overflows line", []byte{0x00, 0x7f /* 128 groups > 8 */, 0xaa}}, + {"literal overflows line", []byte{0x00, 0x81 /* 128 literals > 8 */, 0x01, 0x02}}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := decodeLines(bufio.NewReader(bytes.NewReader(tt.data)), 2, 8, 1, 0xff, + func(int, []byte) {}) + if err == nil { + t.Error("expected error, got nil") + } + }) + } +} + +func TestRLEErrorNamesRow(t *testing.T) { + // error on the second line group must mention the row + var buf bytes.Buffer + encodePage(&buf, [][]byte{{0xaa, 0xbb}}, 1) + buf.WriteByte(0x00) // second lineRepeat, then truncated + err := decodeLines(bufio.NewReader(bytes.NewReader(buf.Bytes())), 4, 2, 1, 0xff, func(int, []byte) {}) + if err == nil { + t.Fatal("expected error") + } + if want := "row 1"; !strings.Contains(err.Error(), want) { + t.Errorf("error %q does not mention %q", err, want) + } +} diff --git a/cupsraster/testdata/doc.pwg b/cupsraster/testdata/doc.pwg new file mode 100644 index 0000000..d0ad019 Binary files /dev/null and b/cupsraster/testdata/doc.pwg differ diff --git a/cupsraster/testdata/doc.urf b/cupsraster/testdata/doc.urf new file mode 100644 index 0000000..337970c Binary files /dev/null and b/cupsraster/testdata/doc.urf differ diff --git a/cupsraster/urf.go b/cupsraster/urf.go new file mode 100644 index 0000000..7e4a77d --- /dev/null +++ b/cupsraster/urf.go @@ -0,0 +1,120 @@ +package cupsraster + +import ( + "bufio" + "encoding/binary" + "errors" + "fmt" + "image" + "io" +) + +// Apple Raster (URF) stream layout: the 8-byte magic "UNIRAST\x00" and a +// big-endian uint32 page count, then for each page a 32-byte header followed +// by RLE-compressed page data. Unlike PWG raster there is no bytes-per-line +// field; it is derived from width and bits per pixel. + +const ( + urfMagic = "UNIRAST\x00" + urfPageHeaderSize = 32 +) + +// URF color space values (subset the decoder understands). +const ( + urfCSSGray = 0 // sGray, luminance semantics: 0 = black + urfCSSRGB = 1 // sRGB +) + +type urfHeader struct { + BitsPerPixel int + ColorSpace int + Width, Height int + DPI int +} + +func parseURFHeader(buf []byte) (urfHeader, error) { + h := urfHeader{ + BitsPerPixel: int(buf[0]), + ColorSpace: int(buf[1]), + // bytes 2-11: duplex, quality, media type, reserved — ignored. + Width: int(binary.BigEndian.Uint32(buf[12:16])), + Height: int(binary.BigEndian.Uint32(buf[16:20])), + DPI: int(binary.BigEndian.Uint32(buf[20:24])), + } + if err := checkDimensions(h.Width, h.Height); err != nil { + return h, err + } + switch h.ColorSpace { + case urfCSSGray: + if h.BitsPerPixel != 1 && h.BitsPerPixel != 8 { + return h, fmt.Errorf("unsupported bits per pixel %d for sGray", h.BitsPerPixel) + } + case urfCSSRGB: + if h.BitsPerPixel != 24 { + return h, fmt.Errorf("unsupported bits per pixel %d for sRGB", h.BitsPerPixel) + } + default: + return h, fmt.Errorf("unsupported color space %d", h.ColorSpace) + } + return h, nil +} + +// DecodeURF decodes an Apple Raster (URF) stream into one image per page. +func DecodeURF(r io.Reader) ([]image.Image, error) { + pages, err := decodeURFPages(bufio.NewReader(r)) + if err != nil { + return nil, err + } + return images(pages), nil +} + +func decodeURFPages(br *bufio.Reader) ([]Page, error) { + head := make([]byte, len(urfMagic)+4) + if _, err := io.ReadFull(br, head); err != nil { + return nil, fmt.Errorf("reading URF header: %w", err) + } + if string(head[:len(urfMagic)]) != urfMagic { + return nil, fmt.Errorf("not a URF stream: magic % x", head[:len(urfMagic)]) + } + numPages := int(binary.BigEndian.Uint32(head[len(urfMagic):])) + if numPages <= 0 || numPages > 65535 { + return nil, fmt.Errorf("invalid URF page count %d", numPages) + } + pages := make([]Page, 0, numPages) + hdr := make([]byte, urfPageHeaderSize) + for page := 1; page <= numPages; page++ { + if _, err := io.ReadFull(br, hdr); err != nil { + return nil, fmt.Errorf("page %d: reading page header: %w", page, err) + } + h, err := parseURFHeader(hdr) + if err != nil { + return nil, fmt.Errorf("page %d: %w", page, err) + } + img, err := decodeURFPage(br, h) + if err != nil { + return nil, fmt.Errorf("page %d: %w", page, err) + } + pages = append(pages, Page{Image: img, XDPI: h.DPI, YDPI: h.DPI}) + } + if len(pages) == 0 { + return nil, errors.New("no pages in URF stream") + } + return pages, nil +} + +func decodeURFPage(br *bufio.Reader, h urfHeader) (image.Image, error) { + bytesPerLine := (h.Width*h.BitsPerPixel + 7) / 8 + switch h.BitsPerPixel { + case 1: + // Empirically (macOS cgpdftoraster output), 1-bit URF uses ink + // semantics — a set bit is black — despite the sGray colour space; + // see the fixture cross-check in fixture_test.go. + return decodeGrayPage(br, h.Width, h.Height, h.BitsPerPixel, bytesPerLine, true) + case 8: + // 8-bit sGray is luminance: zero is black. + return decodeGrayPage(br, h.Width, h.Height, h.BitsPerPixel, bytesPerLine, false) + case 24: + return decodeRGBPage(br, h.Width, h.Height, bytesPerLine) + } + panic("unreachable: bpp validated in parseURFHeader") +} diff --git a/cupsraster/urf_test.go b/cupsraster/urf_test.go new file mode 100644 index 0000000..fa82d1a --- /dev/null +++ b/cupsraster/urf_test.go @@ -0,0 +1,139 @@ +package cupsraster + +import ( + "bytes" + "encoding/binary" + "image" + "strings" + "testing" +) + +func buildURFPageHeader(width, height, bitsPerPixel, colorSpace int) []byte { + hdr := make([]byte, urfPageHeaderSize) + hdr[0] = byte(bitsPerPixel) + hdr[1] = byte(colorSpace) + binary.BigEndian.PutUint32(hdr[12:], uint32(width)) + binary.BigEndian.PutUint32(hdr[16:], uint32(height)) + binary.BigEndian.PutUint32(hdr[20:], 203) + return hdr +} + +type urfPage = struct { + hdr []byte + rows [][]byte +} + +func buildURFStream(t *testing.T, pages ...urfPage) []byte { + t.Helper() + var buf bytes.Buffer + buf.WriteString(urfMagic) + binary.Write(&buf, binary.BigEndian, uint32(len(pages))) + for _, p := range pages { + buf.Write(p.hdr) + groupSize := 1 + if int(p.hdr[0]) == 24 { + groupSize = 3 + } + encodePage(&buf, p.rows, groupSize) + } + return buf.Bytes() +} + +func TestDecodeURF_Mono1Polarity(t *testing.T) { + // 1-bit URF uses ink semantics (bit 1 = BLACK), matching PWG's K space + // — verified against real macOS output in fixture_test.go. + rows := [][]byte{{0xff, 0x00}} // 8 black, 8 white + stream := buildURFStream(t, urfPage{buildURFPageHeader(16, 1, 1, urfCSSGray), rows}) + pages, err := DecodeURF(bytes.NewReader(stream)) + if err != nil { + t.Fatal(err) + } + img := pages[0].(*image.Gray) + if img.GrayAt(0, 0).Y != 0x00 { + t.Error("URF bit 1 must decode black") + } + if img.GrayAt(15, 0).Y != 0xff { + t.Error("URF bit 0 must decode white") + } +} + +func TestDecodeURF_Gray8(t *testing.T) { + rows := [][]byte{{0, 128, 255, 32}, {1, 2, 3, 4}} + stream := buildURFStream(t, urfPage{buildURFPageHeader(4, 2, 8, urfCSSGray), rows}) + pages, err := DecodeURF(bytes.NewReader(stream)) + if err != nil { + t.Fatal(err) + } + img := pages[0].(*image.Gray) + if img.GrayAt(0, 0).Y != 0 || img.GrayAt(2, 0).Y != 255 || img.GrayAt(3, 1).Y != 4 { + t.Error("gray values must pass through unchanged") + } +} + +func TestDecodeURF_RGB24(t *testing.T) { + rows := [][]byte{{10, 20, 30, 40, 50, 60}} + stream := buildURFStream(t, urfPage{buildURFPageHeader(2, 1, 24, urfCSSRGB), rows}) + pages, err := DecodeURF(bytes.NewReader(stream)) + if err != nil { + t.Fatal(err) + } + img := pages[0].(*image.NRGBA) + if c := img.NRGBAAt(1, 0); c.R != 40 || c.G != 50 || c.B != 60 { + t.Errorf("pixel 1: %v", c) + } +} + +func TestDecodeURF_MultiPage(t *testing.T) { + p1 := urfPage{buildURFPageHeader(8, 1, 1, urfCSSGray), [][]byte{{0xff}}} + p2 := urfPage{buildURFPageHeader(8, 2, 8, urfCSSGray), [][]byte{{1, 2, 3, 4, 5, 6, 7, 8}, {9, 10, 11, 12, 13, 14, 15, 16}}} + pages, err := DecodeURF(bytes.NewReader(buildURFStream(t, p1, p2))) + if err != nil { + t.Fatal(err) + } + if len(pages) != 2 { + t.Fatalf("got %d pages, want 2", len(pages)) + } +} + +func TestDecodeURF_Errors(t *testing.T) { + valid := buildURFStream(t, urfPage{buildURFPageHeader(8, 1, 1, urfCSSGray), [][]byte{{0x00}}}) + badCount := append([]byte(urfMagic), 0, 0, 0, 0) + tests := []struct { + name string + stream []byte + wantSub string + }{ + {"bad magic", []byte("NOTURAST\x00\x00\x00\x01"), "not a URF stream"}, + {"zero pages", badCount, "page count"}, + {"truncated page header", valid[:len(urfMagic)+4+10], "page 1"}, + {"truncated page data", valid[:len(valid)-1], "page 1"}, + {"bad colorspace", buildURFStream(t, urfPage{buildURFPageHeader(8, 1, 1, 7), [][]byte{{0x00}}}), "color space"}, + {"bad bpp", buildURFStream(t, urfPage{buildURFPageHeader(8, 1, 4, urfCSSGray), [][]byte{{0x00}}}), "bits per pixel"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := DecodeURF(bytes.NewReader(tt.stream)) + if err == nil { + t.Fatal("expected error") + } + if !strings.Contains(err.Error(), tt.wantSub) { + t.Errorf("error %q does not contain %q", err, tt.wantSub) + } + }) + } +} + +func TestDecode_Dispatch(t *testing.T) { + pwg := buildPWGStream(t, pwgPage{buildPWGHeader(8, 1, 1, pwgCSBlack), [][]byte{{0x00}}}) + urf := buildURFStream(t, urfPage{buildURFPageHeader(8, 1, 1, urfCSSGray), [][]byte{{0xff}}}) + + if pages, err := Decode(bytes.NewReader(pwg)); err != nil || len(pages) != 1 { + t.Errorf("Decode(pwg): pages=%d err=%v", len(pages), err) + } + if pages, err := Decode(bytes.NewReader(urf)); err != nil || len(pages) != 1 { + t.Errorf("Decode(urf): pages=%d err=%v", len(pages), err) + } + if _, err := Decode(bytes.NewReader([]byte("%PDF-1.7 not raster data"))); err == nil { + t.Error("Decode(pdf) must fail") + } +} diff --git a/doc/airprint.md b/doc/airprint.md new file mode 100644 index 0000000..97a6bb9 --- /dev/null +++ b/doc/airprint.md @@ -0,0 +1,98 @@ +# AirPrint / Bonjour / raster notes for agents + +Hard-won empirical knowledge about the IPP server's discovery and raster +paths. Read this before touching `ippsrv/bonjour.go`, `ippsrv/airprint.go`, +`ippsrv/ipp.go` attributes, or the `cupsraster` package. + +## Testing without printer hardware + +- `tp server -dry` runs the server without Bluetooth (`-dry` is a global + flag, or `DRY_RUN=1`). The driver writes `preview_*.png` instead of + printing. +- `dns-sd -B _ipp._tcp local.` browses the printer; `dns-sd -L "" + _ipp._tcp local.` shows SRV/TXT. +- `ipptool -tv [-f file] ipp://:6310/printers/default + get-printer-attributes.test` (or `print-job.test`) probes like macOS does. + Strict-test FAILs on optional attributes we don't emit are expected. +- `lpadmin -p tq -E -v ipp://localhost:6310/printers/default -m everywhere` + creates a driverless CUPS queue; `lp -d tq file.pdf` makes the client + rasterise. Clean up with `lpadmin -x tq`. +- Goodbye packets only go out on graceful SIGINT; a hard kill leaves a stale + mDNSResponder cache entry (clear with `sudo killall -HUP mDNSResponder`). +- Run the server with `-v -dumpdir ` to capture the exact IPP requests + clients send. + +## Discovery / AirPrint classification + +- macOS "Printers & Scanners" lists any `_ipp._tcp` service, but classifies + a printer as **driverless (AirPrint)** only if it answers the + **`_universal._sub._ipp._tcp` subtype browse**. Without it, macOS demands + a driver and iOS does not show the printer at all. +- The subtype answer must be a PTR record *owned by* the subtype name whose + rdata is the **parent-type** instance name (`._ipp._tcp.local.`, + RFC 6762 §7.1). Registering a second dnssd service *typed* as the subtype + produces rdata under the subtype name, which mDNSResponder silently + discards — do not retry that approach. +- `github.com/brutella/dnssd` (v1.2.14, latest) has **no subtype support**; + the supplementary responder in `ippsrv/airprint.go` (miekg/dns, port 5353 + coexistence by binding the multicast group address) serves exactly that + one PTR record. SRV/TXT/A resolution flows through the main responder. +- Verification: `dns-sd -B _ipp._tcp,_universal local.` must list the + printer with type `_ipp._tcp`; `dns-sd -q + _universal._sub._ipp._tcp.local. PTR IN` shows the raw rdata (compare with + the Epson/HP entries on the LAN). +- `grandcat/zeroconf` (used by the abandoned `mdns` branch) fails same-host + discovery on macOS; keep using brutella/dnssd for the main advertisement. +- TXT keys real AirPrint printers carry (all present in `txtRecord`): + `product`, `usb_MFG`, `usb_MDL`, `kind`, `Scan`, `Fax`, `PaperMax`, + `URF`, `pdl`, `rp`, `ty`, `note`, `adminurl`, `UUID`, `Color`, `Duplex`, + `qtotal`, `txtvers`, `priority`. (`TLS` only with TLS support.) +- **SRGB24 in URF is NOT required** for AirPrint: the mono HP LaserJet + M148dw advertises `URF=V1.4,CP99,W8,OB10,PQ3-4-5,DM1,IS1,MT1-3-5,RS600` + without it. We stay grayscale-only (`W8`) on purpose. +- The `URF` TXT key and the `urf-supported` IPP attribute must agree — both + come from `urfSupported()` in `ippsrv/bonjour.go`. +- **Apple's `ipp2ppd` emits `*DefaultResolution` / `*cupsPrintQuality` + (HWResolution) only if `print-quality-supported` is advertised.** Without + it the generated AirPrint PPD has no resolution at all, `cgpdftoraster` + falls back to its built-in 100dpi, and printouts come out at half size on + the 203dpi head. Iterate on the generated PPD offline with: + `/System/Library/Printers/Libraries/ipp2ppd ipp://localhost:6310/printers/default + /System/Library/Frameworks/ApplicationServices.framework/Versions/A/Frameworks/PrintCore.framework/Versions/A/Resources/AirPrint.ppd` + (no GUI re-adding needed). Existing queues keep their old generated PPD — + re-add the printer after changing advertised attributes. +- As defence in depth the server honours the resolution declared in raster + page headers: `cupsraster.DecodePages` surfaces per-page DPI and + `scaleToDPI` (`ippsrv/filter.go`) rescales pages to the printer's DPI, so + even a client rasterising at the wrong resolution prints at the correct + physical size. + +## Raster formats (cupsraster package) + +- Only raster formats are advertised (`document-format-supported`, TXT + `pdl`): listing `application/pdf` makes CUPS driverless queues pass PDFs + through unchanged, defeating client-side rasterisation. PDF is still + *accepted* silently — the sniffing filter (`ippsrv/filter.go`) falls back + to ImageMagick for non-raster data. +- **1-bit polarity is per color space** and macOS deviates from a naive + spec reading: PWG ColorSpace 3 (K): bit 1 = black; **URF 1-bit: bit 1 = + black too** (ink semantics despite the sGray colorspace — verified against + real macOS output; a wrong guess here shows up as a 100% inverted page in + `cupsraster/fixture_test.go`). +- Media is advertised at the **printable** width (48mm, `om_label-48x...`), + not the 58mm stock width, so clients rasterise at exactly ~384px. With + this, a driverless client sends e.g. 383×799px @ 203dpi 8-bit gray. +- Fixture generation (macOS): `cupsfilter -i image/png -m image/pwg-raster + -p ippsrv/ppd/LX-D02.ppd file.png` (same for `image/urf`). Caveats: needs + `-p`; **always emits 100dpi** no matter what `-o Resolution=` says; a + letter-sized PDF input yields a blank label crop — feed a PNG instead. +- Which format CUPS picks per job (PWG vs URF) is its internal cost choice; + both share the RLE decoder, either is fine. + +## Known remaining gaps + +- `printer-location` / `printer-more-info` attributes not emitted; + `requested-attributes` is ignored (full set always returned). +- Job-ID collision: `JobID = time.Now().Unix()` — two jobs within the same + second collide ("job already exists"). +- No TLS, so no `_ipps._tcp` / "Secure AirPrint". diff --git a/doc/archive/raster-plan.md b/doc/archive/raster-plan.md new file mode 100644 index 0000000..e611639 --- /dev/null +++ b/doc/archive/raster-plan.md @@ -0,0 +1,73 @@ +# Client-side rasterisation: PWG Raster + Apple URF support (branch `cups-raster`) + +## Context + +Today CUPS sends `application/pdf` to the IPP server, which shells out to the external `magick` binary (ImageMagick → Ghostscript) to rasterise it (`ippsrv/filter.go`, `imageMagickFilter`). By advertising `image/pwg-raster` and `image/urf` (Apple Raster), CUPS/macOS clients rasterise on their side and send pre-rendered raster streams, removing the external-binary dependency for the common path and enabling fully driverless (no-PPD) queue setup on macOS. PDF stays as a fallback format. + +Key facts verified during planning: +- The existing `Filter` interface (`ToRaster(ctx, dpi, data) ([]image.Image, error)`, `ippsrv/filter.go`) is exactly the shape a raster decoder needs → integrate as a magic-byte-sniffing filter; **zero changes** to `basePrinter.Print`, `Driver`, or the composer. +- The existing compose loop (`printer.go:224-231`: `IsDocument` → threshold, else Floyd-Steinberg) is content-lossless for already-1-bit pages and correctly dithers gray/color pages; `ResizeToFit` is a no-op at width ≤ 384. +- Printer: 384 px wide, 203 dpi. `document-format` is not captured from requests; body is sniffed — so route by magic bytes. +- Header layouts below were **empirically verified on this Mac** by generating streams with `/usr/sbin/cupsfilter -m image/pwg-raster|image/urf -p ippsrv/ppd/LX-D02.ppd` (works; needs `-p` and explicit `-o Resolution=203dpi`, otherwise emits 100dpi). +- goipp v1.2.0 has `goipp.Resolution{Xres, Yres, Units: goipp.UnitsDpi}` + `TagResolution`, usable with the existing `adder` helper (`ipp_utils.go`). +- brutella/dnssd v1.2.14 does **not** support DNS-SD subtypes → skip `_universal._sub._ipp._tcp`; macOS Printers & Scanners browses `_ipp._tcp` directly (only iOS strictly needs the subtype — accepted limitation, document in a comment). + +Decisions: media advertised at **48mm printable width** (client rasters arrive at exactly 384px, no rescaling) — flagged: switch to 58mm names if label-stock-matching names matter more than sharpness. mDNS/attribute work builds on the `bonjour` branch work. + +## Milestone 1 — decoder + sniffing filter (server accepts pushed raster) + +### New package `cupsraster/` (repo root) + +Files: `cupsraster.go` (doc, `Format` enum, `Detect(data []byte) Format`, `Decode(r io.Reader) ([]image.Image, error)` dispatch), `pwg.go`, `urf.go`, `rle.go`, tests + `testdata/`. + +API: `Detect`, `Decode`, `DecodePWG`, `DecodeURF`. Output per page: bpp 1 → `image.Gray` (bit-expand), bpp 8 sGray → `image.Gray`, bpp 24 sRGB → `image.NRGBA` (decoded defensively even though color is not advertised). + +**1-bit polarity is per color space — do not generalise one rule:** +- PWG ColorSpace 3 ("black"/K, ink semantics): bit 1 = black, bit 0 = white. +- PWG ColorSpace 18 (sgray) and URF colorSpace 0 (sGray/luminance) at bpp 1: luminance semantics — bit 0 = black, bit 1 = white (inverse of K). +- Fixture tests MUST assert black text renders black for **both** PWG black_1 and URF 1-bit mono (generate both fixtures from the same input document and compare decoded pixels). + +**PWG raster** (PWG 5102.4): 4-byte sync `"RaS2"` once, then per page 1796-byte big-endian header + RLE. Detect: `"RaS2"` at 0 **and** `"PwgRaster\0"` at 4 (guards against little-endian CUPS-raster confusion; same rule as `/usr/share/cups/mime/apple.types`). Verified header offsets (u32 BE): HWResolution X/Y @ 276/280, **Width/Height @ 372/376**, BitsPerColor @ 384, **BitsPerPixel @ 388**, **BytesPerLine @ 392**, ColorOrder @ 396 (must be 0), **ColorSpace @ 400** (3=black, 18=sgray, 19=srgb). + +**Apple URF**: `"UNIRAST\x00"` + u32 BE page count; per page 32-byte header: bitsPerPixel u8 @ 0 (support 1/8/24 — macOS emitted 1 with our mono PPD), colorSpace u8 @ 1 (0=sGray, 1=sRGB), width u32 @ 12, height @ 16, dpi @ 20. No BytesPerLine — compute `(width*bpp+7)/8`. + +**Shared RLE** (`rle.go`): per line group: 1 byte lineRepeat (line spans repeat+1 rows); then runs of pixel groups (group = `max(1, bpp/8)` bytes) until bytesPerLine produced: control `0..127` → next group repeated c+1 times; `129..255` → `257-c` literal groups; `0x80` → treat defensively as "fill rest of line with white, end line" (URF uses it; reserved in PWG — verify against fixtures). Because "white" depends on color space (0x00 in PWG K 1-bit, 0xFF in sGray/sRGB/URF mono), the RLE routine must take an explicit `fill byte` (or polarity) parameter from the per-format caller — it cannot derive it from bpp/bytesPerLine alone. Test the 0x80 path with both fill values. Include an unexported `encodePage` for round-trip tests. + +Guards: reject zero/absurd dimensions (cap ~32768 and total-pixel cap), BytesPerLine consistency, truncated-stream errors naming the page index. + +### Integration (`ippsrv`) + +- `ippsrv/filter.go`: add `rasterSniffFilter{fallback Filter}` — `Detect != FormatUnknown` → `cupsraster.Decode`, else delegate; `Type() = "raster+" + fallback.Type()`. +- `ippsrv/printer.go:123` (`WrapDriver`): default filter becomes `&rasterSniffFilter{fallback: &imageMagickFilter{}}`. +- `ippsrv/job.go` `createJobFromRequest`: also capture `document-format` (via existing `extractValue[goipp.String]`) onto a new `Job` field for logging/dumps only. + +## Milestone 2 — advertisement (driverless setup) + +- `ippsrv/ipp_utils.go`: constants `ippImagePWGRaster = "image/pwg-raster"`, `ippImageURF = "image/urf"`. +- `ippsrv/ipp.go` `printerAttributes`: `document-format-supported` → **rasters only** (`image/pwg-raster`, `image/urf`), `document-format-default` → `image/pwg-raster`. **Do not advertise `application/pdf`**: CUPS driverless queues cost-optimise toward PDF passthrough when the destination lists PDF, which would defeat client-side rasterisation. The server still *accepts* PDF silently — the sniffing filter's magick fallback is unchanged and nothing validates `document-format` — so scripts pushing PDF keep working. (If a real client ever needs PDF advertised, add an `ippsrv` option then; don't pre-build it.) Add `pwg-raster-document-resolution-supported` (TagResolution, 203×203dpi), `pwg-raster-document-type-supported` (TagKeyword: `black_1`, `sgray_8` — mono/grayscale only; note PWG type keywords are bits-per-**color**, so 24-bit RGB would be `srgb_8`, never "srgb_24"), `pwg-raster-document-sheet-back` (`normal`), `urf-supported` (TagKeyword: `V1.4`, `W8`, `RS203` — no `SRGB24`: don't invite color rasters for a 1-bit printer; decoder still accepts them defensively). +- Minimal media collections (required here, not deferred — `lpadmin -m everywhere` and macOS driverless setup commonly query collections, not just keyword names): `media-size-supported` and `media-col-database`/`media-col-default` built with `goipp.Collection`, one entry per label size. Structure: `media-size` contains **only** `x-dimension`/`y-dimension` (hundredths of mm, 48mm → 4800); the zero margins are separate sibling members of `media-col` — `media-top-margin`, `media-bottom-margin`, `media-left-margin`, `media-right-margin` (integers, 0) — never inside `media-size`. Implementation note: every `goipp.Collection` member attribute must be **named** — goipp rejects collection members with empty names at encode time, so malformed collections surface only when the response is encoded, not when built. +- `ippsrv/printer.go`: `MediaSupported`/`MediaDefault` → PWG 5101.1 self-describing names at printable width: `om_label-48x100mm_48x100mm` (default), `om_label-48x40mm_48x40mm`, `om_label-48x32mm_48x32mm`, `om_label-48x60mm_48x60mm` (current `roll_57mm` is not parseable by `lpadmin -m everywhere`). +- `ippsrv/bonjour.go` `txtRecord`: `pdl = "image/urf,image/pwg-raster"` (no PDF — see above), add `URF = "V1.4,W8,RS203"`; comment noting the skipped `_universal` subtype (dnssd library limitation, iOS-only impact). +- Deferred follow-up (iterate with protocol dumps if queue setup stalls): richer media-col fields (margins, media-type keywords), color advertising (`srgb_8`/`SRGB24`) if ever wanted, subtype workaround for iOS. + +## Tests + +- `cupsraster`: RLE round-trips via internal encoder (bpp 1/8/24, lineRepeat > 0, literal/run mixes, multi-page, truncation errors); decode checked-in fixtures generated with `cupsfilter` (command above) asserting dimensions/dpi from headers **and 1-bit polarity per color space** (black text must decode black in both PWG black_1 and URF mono fixtures of the same document). Plain `testing`, style of `bitmap/*_test.go` and root `raster_test.go`. +- `ippsrv`: sniffing filter routes raster → decoder, everything else → fake fallback (table test); `printerAttributes` contains new formats/attributes **and the full response round-trips through `Message.Encode`** (encode to bytes, not just inspect the attribute list — this is what catches malformed/unnamed collection members early); **invert** `bonjour_test.go:35-36` (TXT must now contain `URF` and raster `pdl` entries); update media assertions in tests to the new PWG names. + +## Verification + +1. `go test ./...`, `go build ./...`. +2. **M1 gate**: run `tp server -dry`; `ipptool -tv -f cupsraster/testdata/doc.pwg ipp://.local.:6310/printers/default print-job.test` (repeat with `.urf`); prove `magick` isn't invoked (temporarily rename it out of PATH). Inspect `-dumpdir` output. +3. **M2 gate**: `lpadmin -p tq -E -v ipp://.local.:6310/printers/default -m everywhere && lp -d tq file.pdf` — verify the server receives `image/pwg-raster` at width 384 (protocol dumps). +4. macOS Settings → Printers & Scanners → Add Printer → should set up **without asking for a PPD** (driverless via URF TXT); print a test page; verify received format/width in dumps. +5. Regression: push a PDF directly (`ipptool -f file.pdf ... print-job.test`) — still prints via the magick fallback even though PDF is no longer advertised. +6. Confirm in the dumps that the `-m everywhere` queue actually sends `image/pwg-raster` (the whole point — if it somehow still sends PDF, investigate the generated PPD's cupsFilter2 entries). + +## Risks + +- `0x80` RLE control-byte semantics differ between URF and PWG (defensive fill-white handling; confirm with fixtures). +- Media width: if clients still raster at unexpected widths, iterate using dumps; composer downscaling is the safety net. +- macOS `cupsfilter` fixture quirk: emits 100dpi unless `-o Resolution=203dpi` passed. +- iOS AirPrint won't discover the printer without the `_universal` subtype (library limitation, out of scope). +- The exact URF TXT value `V1.4,W8,RS203` (grayscale-only, no SRGB24) is unvalidated against real macOS behaviour — the dump-based verification gate (step 4/6) is where it gets confirmed; if macOS balks at a colorless URF printer, adding `SRGB24` back (with the decoder's existing sRGB support) is the one-line fallback. diff --git a/go.mod b/go.mod index 7c093ba..3a0a71b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/rusq/thermoprint -go 1.24.2 +go 1.25.0 require ( github.com/OpenPrinting/goipp v1.2.0 @@ -9,44 +9,46 @@ require ( github.com/google/uuid v1.6.0 github.com/looplab/fsm v1.0.3 github.com/makeworld-the-better-one/dither/v2 v2.4.0 - github.com/pterm/pterm v0.12.81 - github.com/rusq/fontpic v0.0.7 + github.com/miekg/dns v1.1.72 + github.com/pterm/pterm v0.12.83 + github.com/rusq/fontpic v0.0.8 github.com/rusq/httpex v0.0.1 - github.com/stretchr/testify v1.8.4 - golang.org/x/image v0.29.0 - tinygo.org/x/bluetooth v0.12.0 + github.com/stretchr/testify v1.10.0 + golang.org/x/image v0.43.0 + golang.org/x/net v0.56.0 + tinygo.org/x/bluetooth v0.15.0 ) require ( atomicgo.dev/cursor v0.2.0 // indirect - atomicgo.dev/keyboard v0.2.9 // indirect + atomicgo.dev/keyboard v0.2.10 // indirect atomicgo.dev/schedule v0.1.0 // indirect + github.com/clipperhouse/uax29/v2 v2.7.0 // indirect github.com/containerd/console v1.0.5 // indirect github.com/davecgh/go-spew v1.1.1 // indirect github.com/go-ole/go-ole v1.3.0 // indirect - github.com/godbus/dbus/v5 v5.1.0 // indirect - github.com/gookit/color v1.5.4 // indirect + github.com/godbus/dbus/v5 v5.2.2 // indirect + github.com/gookit/color v1.6.1 // indirect github.com/lithammer/fuzzysearch v1.1.8 // indirect - github.com/mattn/go-runewidth v0.0.16 // indirect - github.com/miekg/dns v1.1.61 // indirect + github.com/mattn/go-runewidth v0.0.24 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rivo/uniseg v0.4.7 // indirect - github.com/saltosystems/winrt-go v0.0.0-20241223121953-98e32661f6ff // indirect - github.com/sirupsen/logrus v1.9.3 // indirect - github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af // indirect - github.com/soypat/seqs v0.0.0-20250630134107-01c3f05666ba // indirect + github.com/saltosystems/winrt-go v0.0.0-20260513072510-45f10383b2b8 // indirect + github.com/sirupsen/logrus v1.9.4 // indirect + github.com/soypat/cyw43439 v0.1.1 // indirect + github.com/soypat/lneto v0.2.0 // indirect + github.com/soypat/seqs v0.0.0-20260125140838-2c1c6b1bd69e // indirect github.com/tinygo-org/cbgo v0.0.4 // indirect - github.com/tinygo-org/pio v0.2.0 // indirect - github.com/vishvananda/netlink v1.2.1-beta.2 // indirect - github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae // indirect + github.com/tinygo-org/pio v0.3.0 // indirect + github.com/vishvananda/netlink v1.3.1 // indirect + github.com/vishvananda/netns v0.0.5 // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect - golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 // indirect - golang.org/x/mod v0.26.0 // indirect - golang.org/x/net v0.42.0 // indirect - golang.org/x/sync v0.16.0 // indirect - golang.org/x/sys v0.34.0 // indirect - golang.org/x/term v0.33.0 // indirect - golang.org/x/text v0.27.0 // indirect - golang.org/x/tools v0.35.0 // indirect + golang.org/x/exp v0.0.0-20260611194520-c48552f49976 // indirect + golang.org/x/mod v0.37.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/term v0.44.0 // indirect + golang.org/x/text v0.38.0 // indirect + golang.org/x/tools v0.47.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index f81fc90..f476b9f 100644 --- a/go.sum +++ b/go.sum @@ -4,6 +4,8 @@ atomicgo.dev/cursor v0.2.0 h1:H6XN5alUJ52FZZUkI7AlJbUc1aW38GWZalpYRPpoPOw= atomicgo.dev/cursor v0.2.0/go.mod h1:Lr4ZJB3U7DfPPOkbH7/6TOtJ4vFGHlgj1nc+n900IpU= atomicgo.dev/keyboard v0.2.9 h1:tOsIid3nlPLZ3lwgG8KZMp/SFmr7P0ssEN5JUsm78K8= atomicgo.dev/keyboard v0.2.9/go.mod h1:BC4w9g00XkxH/f1HXhW2sXmJFOCWbKn9xrOunSFtExQ= +atomicgo.dev/keyboard v0.2.10 h1:v7mvUKUZLHIggxULEIuWbT+WkkyQSgdbA201EziAhHU= +atomicgo.dev/keyboard v0.2.10/go.mod h1:ap/z5ilnhLqYq852m6kPeTq5Z6aESGWu5mzRpJlC6aI= atomicgo.dev/schedule v0.1.0 h1:nTthAbhZS5YZmgYbb2+DH8uQIZcTlIrd4eYr3UQxEjs= atomicgo.dev/schedule v0.1.0/go.mod h1:xeUa3oAkiuHYh8bKiQBRojqAMq3PXXbJujjb0hw8pEU= github.com/MarvinJWendt/testza v0.1.0/go.mod h1:7AxNvlfeHP7Z/hDQ5JtE3OKYT3XFUeLCDE2DQninSqs= @@ -20,6 +22,8 @@ github.com/OpenPrinting/goipp v1.2.0/go.mod h1:ot2iw+QF7fVLaX+55JUNlF5YSDNiXVo2L github.com/atomicgo/cursor v0.0.1/go.mod h1:cBON2QmmrysudxNBFthvMtN32r3jxVRIvzkUiF/RuIk= github.com/brutella/dnssd v1.2.14 h1:qLpTnRTm5peo2jA30hqMIbCuWn8x3sFg3e9o9ODOobw= github.com/brutella/dnssd v1.2.14/go.mod h1:tG4GE8orv6+irE5rdsNgb6MJSxm6cyMUKdC5jmD22gk= +github.com/clipperhouse/uax29/v2 v2.7.0 h1:+gs4oBZ2gPfVrKPthwbMzWZDaAFPGYK72F0NJv2v7Vk= +github.com/clipperhouse/uax29/v2 v2.7.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/containerd/console v1.0.3/go.mod h1:7LqA/THxQ86k76b8c/EMSiaJ3h1eZkMkXar0TQ1gf3U= github.com/containerd/console v1.0.5 h1:R0ymNeydRqH2DmakFNdmjR2k0t7UPuiOV/N/27/qqsc= github.com/containerd/console v1.0.5/go.mod h1:YynlIjWYF8myEu6sdkwKIvGQq+cOckRm6So2avqoYAk= @@ -32,6 +36,8 @@ github.com/go-ole/go-ole v1.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE= github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78= github.com/godbus/dbus/v5 v5.1.0 h1:4KLkAxT3aOY8Li4FRJe/KvhoNFFxo0m6fNuFUO8QJUk= github.com/godbus/dbus/v5 v5.1.0/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= +github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= +github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= @@ -40,11 +46,14 @@ github.com/gookit/color v1.4.2/go.mod h1:fqRyamkC1W8uxl+lxCQxOT09l/vYfZ+QeiX3rKQ github.com/gookit/color v1.5.0/go.mod h1:43aQb+Zerm/BWh2GnrgOQm7ffz7tvQXEKV6BFMl7wAo= github.com/gookit/color v1.5.4 h1:FZmqs7XOyGgCAxmWyPslpiok1k05wmY3SJTytgvYFs0= github.com/gookit/color v1.5.4/go.mod h1:pZJOeOS8DM43rXbp4AZo1n9zCU2qjpcRko0b6/QJi9w= +github.com/gookit/color v1.6.1 h1:KoTnDxJPRgrL0SoX0f8rCFg2zI0t4E3GZZBMo2nN8LU= +github.com/gookit/color v1.6.1/go.mod h1:9ACFc7/1IpHGBW8RwuDm/0YEnhg3dwwXpoMsmtyHfjs= github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg= github.com/klauspost/cpuid/v2 v2.0.10/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.0.12/go.mod h1:g2LTdtYhdyuGPqyWyv7qRAmj1WBqxuObKfj5c0PQa7c= github.com/klauspost/cpuid/v2 v2.2.3 h1:sxCkb+qR91z4vsqw4vGGZlDgPz3G7gjaLyK3V8y70BU= github.com/klauspost/cpuid/v2 v2.2.3/go.mod h1:RVVoqg1df56z8g3pUjL/3lE5UfnlrJX8tyFgg4nqhuY= +github.com/klauspost/cpuid/v2 v2.3.0 h1:S4CRMLnYUhGeDFDqkGriYKdfoFlDnMtqTiI/sFzhA9Y= github.com/konsorten/go-windows-terminal-sequences v1.0.1/go.mod h1:T0+1ngSBFLxvqU3pZ+m/2kptfBszLMUkC4ZK/EgS/cQ= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= @@ -60,8 +69,12 @@ github.com/makeworld-the-better-one/dither/v2 v2.4.0/go.mod h1:VBtN8DXO7SNtyGmLi github.com/mattn/go-runewidth v0.0.13/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= +github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/miekg/dns v1.1.61 h1:nLxbwF3XxhwVSm8g9Dghm9MHPaUZuqhPiGL+675ZmEs= github.com/miekg/dns v1.1.61/go.mod h1:mnAarhS3nWaW+NVP2wTkYVIZyHNJ098SJZUki3eykwQ= +github.com/miekg/dns v1.1.72 h1:vhmr+TF2A3tuoGNkLDFK9zi36F2LS+hKTRW0Uf8kbzI= +github.com/miekg/dns v1.1.72/go.mod h1:+EuEPhdHOsfk6Wk5TT2CzssZdqkmFhf8r+aVyDEToIs= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pterm/pterm v0.12.27/go.mod h1:PhQ89w4i95rhgE+xedAoqous6K9X+r6aSOI2eFF7DZI= @@ -73,24 +86,39 @@ github.com/pterm/pterm v0.12.36/go.mod h1:NjiL09hFhT/vWjQHSj1athJpx6H8cjpHXNAK5b github.com/pterm/pterm v0.12.40/go.mod h1:ffwPLwlbXxP+rxT0GsgDTzS3y3rmpAO1NMjUkGTYf8s= github.com/pterm/pterm v0.12.81 h1:ju+j5I2++FO1jBKMmscgh5h5DPFDFMB7epEjSoKehKA= github.com/pterm/pterm v0.12.81/go.mod h1:TyuyrPjnxfwP+ccJdBTeWHtd/e0ybQHkOS/TakajZCw= +github.com/pterm/pterm v0.12.83 h1:ie+YmGmA727VuhxBlyGr74Ks+7McV6kT99IB8EU80aA= +github.com/pterm/pterm v0.12.83/go.mod h1:xlgc6bFWyJIMtmLJvGim+L7jhSReilOlOnodeIYe4Tk= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= github.com/rusq/fontpic v0.0.7 h1:mSOkdMUrDdPfMt7Q/qGcHfT4njMbIV+dEz0jDoxN6k0= github.com/rusq/fontpic v0.0.7/go.mod h1:ahRvNj4bzwRn0FmVS+koDxbNP72+znPAjMXpGLGfzsk= +github.com/rusq/fontpic v0.0.8 h1:ZSrQMppwkl1Wm6bNM6Y7XXXkxCNpFxTz5uNk5Brb5Cs= +github.com/rusq/fontpic v0.0.8/go.mod h1:ahRvNj4bzwRn0FmVS+koDxbNP72+znPAjMXpGLGfzsk= github.com/rusq/httpex v0.0.1 h1:LzA73CzBZo64nWNHBkzM/QEIEr9UuP8f5mznR3YIuWE= github.com/rusq/httpex v0.0.1/go.mod h1:rexNReAdRTSgOQtnX1OqsHFnYlxHt3gNES1KvblN6K0= github.com/saltosystems/winrt-go v0.0.0-20241223121953-98e32661f6ff h1:cCYo/NzsEvK9MedoaqkVY8kCp4g1QMyKOYlA/uJwO7g= github.com/saltosystems/winrt-go v0.0.0-20241223121953-98e32661f6ff/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= +github.com/saltosystems/winrt-go v0.0.0-20260513072510-45f10383b2b8 h1:CpUxfPAWwKKHDCH8tKBzAe6lC3c2mDVspXPP10Z+4IQ= +github.com/saltosystems/winrt-go v0.0.0-20260513072510-45f10383b2b8/go.mod h1:CIltaIm7qaANUIvzr0Vmz71lmQMAIbGJ7cvgzX7FMfA= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= github.com/sergi/go-diff v1.2.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= +github.com/sergi/go-diff v1.4.0 h1:n/SP9D5ad1fORl+llWyN+D6qoUETXNZARKjyY2/KVCw= github.com/sirupsen/logrus v1.5.0/go.mod h1:+F7Ogzej0PZc/94MaYx/nvG9jOFMD2osvC3s+Squfpo= github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ= github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= +github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= +github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af h1:ZfFq94aH/BCSWWKd9RPUgdHOdgGKCnfl2VdvU9UksTA= github.com/soypat/cyw43439 v0.0.0-20250505012923-830110c8f4af/go.mod h1:MUaGO5m6X7xrkHrPDmnaxCEcuCCFN/0ZFh9oie+exbU= +github.com/soypat/cyw43439 v0.1.1 h1:vcaTiVzfuz3keK7lJpVxStZ6tV8HCw7Ugzsh1k4mneE= +github.com/soypat/cyw43439 v0.1.1/go.mod h1:R2uSILRwSPmcmmKy5Z0FtK4ypgiPf5YqK+F+IKmXqxc= +github.com/soypat/lneto v0.2.0 h1:h+59QBUgWbpq/4LWOp31+6usVybN01mTDMcVW5U8OxM= +github.com/soypat/lneto v0.2.0/go.mod h1:Be5PjwoYukvHFiUXxpYi8+ppH2F/gw/vjGBvFdv+Ti8= github.com/soypat/seqs v0.0.0-20250630134107-01c3f05666ba h1:NaIxs8iRVTAGBY4xiCy1Jqex3mIPodyLHppYvxUjJEk= github.com/soypat/seqs v0.0.0-20250630134107-01c3f05666ba/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= +github.com/soypat/seqs v0.0.0-20260125140838-2c1c6b1bd69e h1:xF3R+8683ngGNUeIy8PHJZiJZ/XIw+hlGgxg572P0Mw= +github.com/soypat/seqs v0.0.0-20260125140838-2c1c6b1bd69e/go.mod h1:oCVCNGCHMKoBj97Zp9znLbQ1nHxpkmOY9X+UAGzOxc8= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.2.2/go.mod h1:a8OnRcib4nhh0OaRAV+Yts87kKdq0PP7pXfy6kDkUVs= github.com/stretchr/testify v1.4.0/go.mod h1:j7eGeouHqKxXV5pUuKE4zz7dFj8WfuZ+81PSLYec5m4= @@ -98,14 +126,22 @@ github.com/stretchr/testify v1.6.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/ github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/stretchr/testify v1.8.4 h1:CcVxjf3Q8PM0mHUKJCdn+eZZtm5yQwehR5yeSVQQcUk= github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo= +github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA= +github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY= github.com/tinygo-org/cbgo v0.0.4 h1:3D76CRYbH03Rudi8sEgs/YO0x3JIMdyq8jlQtk/44fU= github.com/tinygo-org/cbgo v0.0.4/go.mod h1:7+HgWIHd4nbAz0ESjGlJ1/v9LDU1Ox8MGzP9mah/fLk= github.com/tinygo-org/pio v0.2.0 h1:vo3xa6xDZ2rVtxrks/KcTZHF3qq4lyWOntvEvl2pOhU= github.com/tinygo-org/pio v0.2.0/go.mod h1:LU7Dw00NJ+N86QkeTGjMLNkYcEYMor6wTDpTCu0EaH8= +github.com/tinygo-org/pio v0.3.0 h1:opEnOtw58KGB4RJD3/n/Rd0/djYGX3DeJiXLI6y/yDI= +github.com/tinygo-org/pio v0.3.0/go.mod h1:wf6c6lKZp+pQOzKKcpzchmRuhiMc27ABRuo7KVnaMFU= github.com/vishvananda/netlink v1.2.1-beta.2 h1:Llsql0lnQEbHj0I1OuKyp8otXp0r3q0mPkuhwHfStVs= github.com/vishvananda/netlink v1.2.1-beta.2/go.mod h1:twkDnbuQxJYemMlGd4JFIcuhgX83tXhKS2B/PRMpOho= +github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0= +github.com/vishvananda/netlink v1.3.1/go.mod h1:ARtKouGSTGchR8aMwmkzC0qiNPrrWO5JS/XMVl45+b4= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae h1:4hwBBUfQCFe3Cym0ZtKyq7L16eZUtYKs+BaHDN6mAns= github.com/vishvananda/netns v0.0.0-20200728191858-db3c7e526aae/go.mod h1:DD4vA1DwXk04H54A1oHXtwZmA0grkVMdPxx/VGLCah0= +github.com/vishvananda/netns v0.0.5 h1:DfiHV+j8bA32MFM7bfEunvT8IAqQ/NzSJHtcmW5zdEY= +github.com/vishvananda/netns v0.0.5/go.mod h1:SpkAiCQRtJ6TvvxPnOSyH3BMl6unz3xZlaprSwhNNJM= github.com/xo/terminfo v0.0.0-20210125001918-ca9a967f8778/go.mod h1:2MuV+tbUrU1zIOPMxZ5EncGwgmMJsa+9ucAQZXxsObs= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= @@ -114,24 +150,34 @@ golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACk golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc= golang.org/x/exp v0.0.0-20250718183923-645b1fa84792 h1:R9PFI6EUdfVKgwKjZef7QIwGcBKu86OEFpJ9nUEP2l4= golang.org/x/exp v0.0.0-20250718183923-645b1fa84792/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976 h1:X8Hz2ImujgbmetVuW+w2YkyZChE3cBpZi2P158rTG9M= +golang.org/x/exp v0.0.0-20260611194520-c48552f49976/go.mod h1:vnf4pv9iKZXY58sQE1L86zmNWJ4159e1RkcWiLCkeEY= golang.org/x/image v0.0.0-20191009234506-e7c1f5e7dbb8/go.mod h1:FeLwcggjj3mMvU+oOTbSwawSJRM1uh48EjtB4UJZlP0= golang.org/x/image v0.29.0 h1:HcdsyR4Gsuys/Axh0rDEmlBmB68rW1U9BUdB3UVHsas= golang.org/x/image v0.29.0/go.mod h1:RVJROnf3SLK8d26OW91j4FrIHGbsJ8QnbEocVTOWQDA= +golang.org/x/image v0.43.0 h1:FLxcP4ec2350nTfOC8ysKtqYSIFbk/QGjw1ZHNP4tsY= +golang.org/x/image v0.43.0/go.mod h1:rrpelvGFt+kLPAjPM4HeWPgrl0FtafueU//e5N0qk/Q= golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4= golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs= golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg= golang.org/x/mod v0.26.0/go.mod h1:/j6NAhSk8iQ723BGAUyoAcn7SlD7s15Dp9Nd/SfeaFQ= +golang.org/x/mod v0.37.0 h1:vF1DjpVEshcIqoEaauuHebaLk1O1forxjxBaVn884JQ= +golang.org/x/mod v0.37.0/go.mod h1:m8S8VeM9r4dzDwjrKO0a1sZP3YjeMamRRlD+fmR2Q/0= golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s= golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg= golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c= golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs= golang.org/x/net v0.42.0 h1:jzkYrhi3YQWD6MLBJcsklgQsoAcw89EcZbJw8Z614hs= golang.org/x/net v0.42.0/go.mod h1:FF1RA5d3u7nAYA4z2TkclSCKh68eSXtiFwcWQpPXdt8= +golang.org/x/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= +golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM= golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw= golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= golang.org/x/sys v0.0.0-20190422165155-953cdadca894/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20200217220822-9197077df867/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -146,9 +192,13 @@ golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBc golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.2.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.10.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA= golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210220032956-6a3ed077a48d/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= golang.org/x/term v0.0.0-20210615171337-6886f2dfbf5b/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8= @@ -156,6 +206,8 @@ golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuX golang.org/x/term v0.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k= golang.org/x/term v0.33.0 h1:NuFncQrRcaRvVmgRkvM3j/F00gWIAlcmlB8ACEKmGIg= golang.org/x/term v0.33.0/go.mod h1:s18+ql9tYWp1IfpV9DmCtQDDSRBUjKaw9M1eAv5UeF0= +golang.org/x/term v0.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= +golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ= golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ= @@ -163,13 +215,18 @@ golang.org/x/text v0.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8= golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8= golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4= golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc= golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU= golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0= golang.org/x/tools v0.35.0/go.mod h1:NKdj5HkL/73byiZSJjqJgKn3ep7KjFkBOkR/Hps3VPw= +golang.org/x/tools v0.47.0 h1:7Kn5x/d1svx/PzryTsqeoZN4TZwqeH5pGWjefhLi/1Q= +golang.org/x/tools v0.47.0/go.mod h1:dFHnyTvFWY212G+h7ZY4Vsp/K3U4/7W9TyVaAul8uCA= golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo= gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= @@ -181,3 +238,5 @@ gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= tinygo.org/x/bluetooth v0.12.0 h1:ztrLZfhcZsmzdpir7lBKNz+Q5Wbd6ZdUB98sYLhXWhw= tinygo.org/x/bluetooth v0.12.0/go.mod h1:6+y5kVUN6tU7wtJj+qrcFJEVhas4/bIDhGNqvENmT74= +tinygo.org/x/bluetooth v0.15.0 h1:hLn8+iZFXvVxBzPIdZfvc6TD8JP32ixF22lCEWHAbIo= +tinygo.org/x/bluetooth v0.15.0/go.mod h1:meayNB+9rC1igTUNmNU7KftlSEzrFHe37rBSQZjHN8Y= diff --git a/ippsrv/airprint.go b/ippsrv/airprint.go new file mode 100644 index 0000000..be5abdf --- /dev/null +++ b/ippsrv/airprint.go @@ -0,0 +1,247 @@ +package ippsrv + +// DNS-SD service subtype announcement. +// +// Apple clients decide whether a printer is driverless-capable by browsing +// the _universal._sub._ipp._tcp subtype; a printer that does not answer it +// is offered with a driver prompt on macOS and is invisible to iOS. Per RFC +// 6762 §7.1 the answer must be a PTR record OWNED by the subtype name whose +// rdata is the parent-type service instance name (…._ipp._tcp.local.). The +// brutella/dnssd responder can only answer for the names of services it +// registered, so this file implements a minimal supplementary mDNS responder +// that serves exactly that one PTR record; SRV/TXT/A resolution of the +// instance is handled by the main responder. + +import ( + "context" + "fmt" + "log/slog" + "net" + "strings" + "time" + + "github.com/miekg/dns" + "golang.org/x/net/ipv4" + "golang.org/x/net/ipv6" +) + +const ( + mdnsPort = 5353 + subtypeTTL = 4500 // seconds, standard TTL for shared record sets +) + +var ( + mdnsGroup4 = &net.UDPAddr{IP: net.IPv4(224, 0, 0, 251), Port: mdnsPort} + mdnsGroup6 = &net.UDPAddr{IP: net.ParseIP("ff02::fb"), Port: mdnsPort} +) + +// escapeLabel escapes a DNS label for presentation format (RFC 1035), so +// that instance names containing dots or backslashes survive packing. +func escapeLabel(label string) string { + return strings.NewReplacer(`\`, `\\`, `.`, `\.`).Replace(label) +} + +// subtypeResponder answers PTR queries for one DNS-SD service subtype with +// the parent-type instance names. +type subtypeResponder struct { + subtype string // fully qualified, e.g. "_universal._sub._ipp._tcp.local." + instances []string // fully qualified parent instances, e.g. "Printer\ Name._ipp._tcp.local." + + pc4 *ipv4.PacketConn + pc6 *ipv6.PacketConn +} + +// newSubtypeResponder prepares a responder that maps the subtype to the +// given instance names of parentType (e.g. "_ipp._tcp"). IPv6 is best +// effort; IPv4 is required. +func newSubtypeResponder(subtype, parentType string, names []string) (*subtypeResponder, error) { + r := &subtypeResponder{ + subtype: subtype + ".local.", + } + for _, name := range names { + r.instances = append(r.instances, escapeLabel(name)+"."+parentType+".local.") + } + + // Binding to the multicast group address (not the wildcard) is what + // allows coexistence with mDNSResponder and the dnssd library on the + // same port. + conn4, err := net.ListenUDP("udp4", mdnsGroup4) + if err != nil { + return nil, fmt.Errorf("failed to listen on mDNS IPv4 group: %w", err) + } + r.pc4 = ipv4.NewPacketConn(conn4) + _ = r.pc4.SetControlMessage(ipv4.FlagInterface, true) + _ = r.pc4.SetMulticastTTL(255) + if conn6, err := net.ListenUDP("udp6", mdnsGroup6); err == nil { + r.pc6 = ipv6.NewPacketConn(conn6) + _ = r.pc6.SetControlMessage(ipv6.FlagInterface, true) + _ = r.pc6.SetMulticastHopLimit(255) + } + for _, iface := range multicastInterfaces() { + iface := iface + if err := r.pc4.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroup4.IP}); err != nil { + slog.Debug("mDNS IPv4 join failed", "interface", iface.Name, "error", err) + } + if r.pc6 != nil { + if err := r.pc6.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroup6.IP}); err != nil { + slog.Debug("mDNS IPv6 join failed", "interface", iface.Name, "error", err) + } + } + } + return r, nil +} + +func multicastInterfaces() []net.Interface { + ifaces, err := net.Interfaces() + if err != nil { + return nil + } + var out []net.Interface + for _, iface := range ifaces { + if iface.Flags&net.FlagUp != 0 && iface.Flags&net.FlagMulticast != 0 { + out = append(out, iface) + } + } + return out +} + +// records returns the PTR record set, with the given TTL. +func (r *subtypeResponder) records(ttl uint32) []dns.RR { + rrs := make([]dns.RR, 0, len(r.instances)) + for _, inst := range r.instances { + rrs = append(rrs, &dns.PTR{ + Hdr: dns.RR_Header{ + Name: r.subtype, + Rrtype: dns.TypePTR, + Class: dns.ClassINET, + Ttl: ttl, + }, + Ptr: inst, + }) + } + return rrs +} + +// respond serves subtype PTR queries until ctx is cancelled, then multicasts +// a goodbye (TTL 0) for the record set. It announces the records on start. +func (r *subtypeResponder) respond(ctx context.Context) { + go r.read4(ctx) + if r.pc6 != nil { + go r.read6(ctx) + } + + // unsolicited announcements (RFC 6762 §8.3) + for i := 0; i < 2; i++ { + r.multicast(r.records(subtypeTTL)) + select { + case <-time.After(time.Second): + case <-ctx.Done(): + } + } + + <-ctx.Done() + r.multicast(r.records(0)) // goodbye + r.pc4.Close() + if r.pc6 != nil { + r.pc6.Close() + } +} + +func (r *subtypeResponder) read4(ctx context.Context) { + buf := make([]byte, 65536) + for ctx.Err() == nil { + n, cm, src, err := r.pc4.ReadFrom(buf) + if err != nil { + return + } + var ifIndex int + if cm != nil { + ifIndex = cm.IfIndex + } + r.handle(buf[:n], src, func(msg []byte, dst net.Addr) { + wcm := &ipv4.ControlMessage{IfIndex: ifIndex} + _, _ = r.pc4.WriteTo(msg, wcm, dst) + }) + } +} + +func (r *subtypeResponder) read6(ctx context.Context) { + buf := make([]byte, 65536) + for ctx.Err() == nil { + n, cm, src, err := r.pc6.ReadFrom(buf) + if err != nil { + return + } + var ifIndex int + if cm != nil { + ifIndex = cm.IfIndex + } + r.handle(buf[:n], src, func(msg []byte, dst net.Addr) { + wcm := &ipv6.ControlMessage{IfIndex: ifIndex} + _, _ = r.pc6.WriteTo(msg, wcm, dst) + }) + } +} + +// handle answers a single received packet. write sends a packed message to +// the destination on the interface the query arrived on. +func (r *subtypeResponder) handle(pkt []byte, src net.Addr, write func(msg []byte, dst net.Addr)) { + var q dns.Msg + if err := q.Unpack(pkt); err != nil || q.Response { + return + } + asked := false + for _, question := range q.Question { + if (question.Qtype == dns.TypePTR || question.Qtype == dns.TypeANY) && + strings.EqualFold(question.Name, r.subtype) { + asked = true + break + } + } + if !asked { + return + } + + var resp dns.Msg + resp.Response = true + resp.Authoritative = true + resp.Answer = r.records(subtypeTTL) + + udp, _ := src.(*net.UDPAddr) + if udp != nil && udp.Port != mdnsPort { + // legacy unicast query (RFC 6762 §6.7): echo ID and question, + // respond directly to the source. + resp.Id = q.Id + resp.Question = q.Question + if msg, err := resp.Pack(); err == nil { + write(msg, src) + } + return + } + dst := net.Addr(mdnsGroup4) + if udp != nil && udp.IP.To4() == nil { + dst = mdnsGroup6 + } + if msg, err := resp.Pack(); err == nil { + write(msg, dst) + } +} + +// multicast sends the record set as an unsolicited response on all +// interfaces, over both address families. +func (r *subtypeResponder) multicast(rrs []dns.RR) { + var resp dns.Msg + resp.Response = true + resp.Authoritative = true + resp.Answer = rrs + msg, err := resp.Pack() + if err != nil { + return + } + for _, iface := range multicastInterfaces() { + _, _ = r.pc4.WriteTo(msg, &ipv4.ControlMessage{IfIndex: iface.Index}, mdnsGroup4) + if r.pc6 != nil { + _, _ = r.pc6.WriteTo(msg, &ipv6.ControlMessage{IfIndex: iface.Index}, mdnsGroup6) + } + } +} diff --git a/ippsrv/bonjour.go b/ippsrv/bonjour.go index 2431d77..fcbeaaf 100644 --- a/ippsrv/bonjour.go +++ b/ippsrv/bonjour.go @@ -27,23 +27,53 @@ func WithBonjour() Option { } } -const svcTypeIPP = "_ipp._tcp" +const ( + svcTypeIPP = "_ipp._tcp" + // svcTypeUniversal is the AirPrint service subtype. Apple clients + // browse it to decide whether a printer is driverless-capable: without + // it, macOS "Add Printer" falls back to asking for a driver and iOS does + // not see the printer at all. + svcTypeUniversal = "_universal._sub._ipp._tcp" +) + +// urfSupported lists the Apple Raster capabilities: URF version, 8-bit +// grayscale, resolution, and the capability hints (copies, input slot, media +// type, output bin, print quality) that AirPrint clients expect. It is the +// single source for both the urf-supported IPP attribute and the URF TXT +// key, which must agree. +func urfSupported(dpi int) []string { + return []string{"V1.4", "W8", fmt.Sprintf("RS%d", dpi), "CP1", "IS1", "MT1", "OB9", "PQ4"} +} // txtRecord returns the DNS-SD TXT record for the printer, according to the // Bonjour Printing Specification. hostname is the mDNS host label without // the ".local" suffix. -func txtRecord(p PrinterInformer, baseURL, hostname string, port int) map[string]string { +// +// Only raster PDLs are advertised (matching document-format-supported): +// listing application/pdf would make clients send PDFs through unchanged +// instead of rasterising them client-side. The URF key, together with the +// _universal service subtype registered in startBonjour, enables driverless +// (AirPrint-style) setup. +func txtRecord(p PrinterInformer, baseURL, hostname string, port, dpi int) map[string]string { return map[string]string{ "txtvers": "1", "qtotal": "1", "rp": strings.TrimPrefix(path.Join(baseURL, p.Name()), "/"), "ty": p.MakeAndModel(), "note": p.Info(), - "pdl": ippApplicationPDF.String(), + "product": "(" + p.MakeAndModel() + ")", + "usb_MFG": "Thermoprint", + "usb_MDL": p.MakeAndModel(), + "pdl": ippImageURF.String() + "," + ippImagePWGRaster.String(), + "URF": strings.Join(urfSupported(dpi), ","), "adminurl": fmt.Sprintf("http://%s.local.:%d/admin/", hostname, port), "UUID": p.UUID(), + "kind": "label", + "PaperMax": " 0 { + a("media-size-supported", goipp.TagBeginCollection, sizes...) + a("media-col-database", goipp.TagBeginCollection, cols...) + } + if x, y, err := mediaSizeDimensions(p.MediaDefault()); err == nil { + a("media-col-default", goipp.TagBeginCollection, mediaCol(x, y)) + } a("printer-uuid", goipp.TagURI, goipp.String("urn:uuid:"+p.UUID())) return m diff --git a/ippsrv/ipp_attrs_test.go b/ippsrv/ipp_attrs_test.go new file mode 100644 index 0000000..bfc149b --- /dev/null +++ b/ippsrv/ipp_attrs_test.go @@ -0,0 +1,79 @@ +package ippsrv + +import ( + "bytes" + "context" + "testing" + + "github.com/OpenPrinting/goipp" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// attrStrings returns all values of the named operation attribute as strings. +func attrStrings(t *testing.T, attrs goipp.Attributes, name string) []string { + t.Helper() + vv, ok := findAttr(attrs, name) + if !ok { + return nil + } + var out []string + for _, v := range vv { + out = append(out, v.V.String()) + } + return out +} + +func TestPrinterAttributes_RasterAdvertisement(t *testing.T) { + s := newTestIPPServer(t) + req := newIPPRequest(goipp.OpGetPrinterAttributes, 7) + + resp, err := s.handleGetPrinterAttributes(context.Background(), req, nil) + require.NoError(t, err) + + formats := attrStrings(t, resp.Operation, "document-format-supported") + assert.ElementsMatch(t, []string{"image/pwg-raster", "image/urf"}, formats, + "only raster formats may be advertised; PDF would make clients skip client-side rasterisation") + assert.Equal(t, []string{"image/pwg-raster"}, attrStrings(t, resp.Operation, "document-format-default")) + + assert.Equal(t, []string{"black_1", "sgray_8"}, attrStrings(t, resp.Operation, "pwg-raster-document-type-supported")) + assert.Equal(t, []string{"normal"}, attrStrings(t, resp.Operation, "pwg-raster-document-sheet-back")) + assert.Equal(t, urfSupported(203), attrStrings(t, resp.Operation, "urf-supported"), + "urf-supported must match the URF TXT record key") + + res, ok := findAttr(resp.Operation, "pwg-raster-document-resolution-supported") + require.True(t, ok, "pwg-raster-document-resolution-supported missing") + require.IsType(t, goipp.Resolution{}, res[0].V) + assert.Equal(t, goipp.Resolution{Xres: 203, Yres: 203, Units: goipp.UnitsDpi}, res[0].V) + + media := attrStrings(t, resp.Operation, "media-supported") + assert.Contains(t, media, "om_label-48x100mm_48x100mm") + + cols, ok := findAttr(resp.Operation, "media-col-database") + require.True(t, ok, "media-col-database missing") + assert.Len(t, cols, 4, "one media-col per label size") + _, ok = findAttr(resp.Operation, "media-size-supported") + assert.True(t, ok, "media-size-supported missing") + _, ok = findAttr(resp.Operation, "media-col-default") + assert.True(t, ok, "media-col-default missing") +} + +// TestPrinterAttributes_Encodes encodes the complete Get-Printer-Attributes +// response to wire format. goipp validates collections only at encode time +// (e.g. members with empty names are rejected), so inspecting the attribute +// list alone would miss malformed collections. +func TestPrinterAttributes_Encodes(t *testing.T) { + s := newTestIPPServer(t) + req := newIPPRequest(goipp.OpGetPrinterAttributes, 8) + + resp, err := s.handleGetPrinterAttributes(context.Background(), req, nil) + require.NoError(t, err) + + var buf bytes.Buffer + require.NoError(t, resp.Encode(&buf), "response must encode to wire format") + assert.NotZero(t, buf.Len()) + + // and it must decode back + var m goipp.Message + require.NoError(t, m.Decode(bytes.NewReader(buf.Bytes()))) +} diff --git a/ippsrv/ipp_test.go b/ippsrv/ipp_test.go index 7a43fae..57ac2a4 100644 --- a/ippsrv/ipp_test.go +++ b/ippsrv/ipp_test.go @@ -66,7 +66,7 @@ func addTestJob(t *testing.T, s *basicIPPServer, id JobID, name, username string p := s.Printer["test-printer"] jobURL := fmt.Sprintf("/printers/test-printer/%d", id) - job, err := createJob(p, id, "ipp://localhost/printers/test-printer", jobURL, name, username) + job, err := createJob(p, id, "ipp://localhost/printers/test-printer", jobURL, name, username, "") if err != nil { t.Fatalf("createJob: %v", err) } @@ -426,7 +426,7 @@ func TestJobAttributesTimeSyntax(t *testing.T) { func TestJobAttributesZeroTimeIsNoValue(t *testing.T) { s := newTestIPPServer(t) p := s.Printer["test-printer"] - job, err := createJob(p, 42, "ipp://localhost/printers/test-printer", "/printers/test-printer/42", "test-job", "tester") + job, err := createJob(p, 42, "ipp://localhost/printers/test-printer", "/printers/test-printer/42", "test-job", "tester", "") if err != nil { t.Fatalf("createJob: %v", err) } diff --git a/ippsrv/ipp_utils.go b/ippsrv/ipp_utils.go index d83835a..47c805a 100644 --- a/ippsrv/ipp_utils.go +++ b/ippsrv/ipp_utils.go @@ -13,6 +13,8 @@ const ( ippUTF8 goipp.String = "utf-8" ippENUS goipp.String = "en-us" ippApplicationPDF goipp.String = "application/pdf" + ippImagePWGRaster goipp.String = "image/pwg-raster" + ippImageURF goipp.String = "image/urf" ) // adder is a helper function to add attributes to an operation. diff --git a/ippsrv/job.go b/ippsrv/job.go index cd92f9e..6f81222 100644 --- a/ippsrv/job.go +++ b/ippsrv/job.go @@ -26,6 +26,7 @@ type Job struct { Username string // Username of the user who created the job JobURI string // URL to access the job, e.g., "/printers/default/123" PrinterURI string // URI of the printer, e.g., "/printers/default" + Format string // document-format of the job data, if provided by the client sm *fsm.FSM buffer []byte // Buffer for job data, if needed @@ -169,13 +170,21 @@ func createJobFromRequest(p Printer, baseURL string, id JobID, req *goipp.Messag if err != nil { return nil, fmt.Errorf("failed to extract printer-uri: %w", err) } + // document-format is optional; used for logging only, the data format is + // sniffed at print time. + format, err := extractValue[goipp.String](req.Operation, "document-format") + if err != nil { + format = "" + } else { + slog.Debug("job document format", "job_id", id, "document_format", format) + } jobURL := path.Join(baseURL, p.Name(), fmt.Sprintf("%d", id)) - return createJob(p, id, printerURI.String(), jobURL, jobName.String(), username.String()) + return createJob(p, id, printerURI.String(), jobURL, jobName.String(), username.String(), format.String()) } -func createJob(p Printer, id JobID, printerURI, jobURL, name, username string) (*Job, error) { +func createJob(p Printer, id JobID, printerURI, jobURL, name, username, format string) (*Job, error) { // Create a new job based on the message job := &Job{ ID: id, @@ -189,6 +198,7 @@ func createJob(p Printer, id JobID, printerURI, jobURL, name, username string) ( Username: username, JobURI: jobURL, PrinterURI: printerURI, + Format: format, } job.sm = makeJobFSM(job) diff --git a/ippsrv/media.go b/ippsrv/media.go new file mode 100644 index 0000000..f1fb6e7 --- /dev/null +++ b/ippsrv/media.go @@ -0,0 +1,77 @@ +package ippsrv + +// Media size attributes for driverless clients. CUPS "everywhere" queue +// generation and macOS AirPrint setup query media collections, not just the +// keyword names, so both are derived from the printer's self-describing +// media names (PWG 5101.1, e.g. "om_label-48x100mm_48x100mm"). + +import ( + "fmt" + "strconv" + "strings" + + "github.com/OpenPrinting/goipp" +) + +// mediaSizeDimensions parses the trailing dimension segment of a PWG 5101.1 +// self-describing metric media size name and returns width and height in +// hundredths of a millimetre. +func mediaSizeDimensions(name string) (x, y int, err error) { + i := strings.LastIndex(name, "_") + if i < 0 { + return 0, 0, fmt.Errorf("media name %q is not self-describing", name) + } + dims, ok := strings.CutSuffix(name[i+1:], "mm") + if !ok { + return 0, 0, fmt.Errorf("media name %q does not have metric dimensions", name) + } + w, h, ok := strings.Cut(dims, "x") + if !ok { + return 0, 0, fmt.Errorf("media name %q dimensions are not WxH", name) + } + fw, err := strconv.ParseFloat(w, 64) + if err != nil { + return 0, 0, fmt.Errorf("media name %q width: %w", name, err) + } + fh, err := strconv.ParseFloat(h, 64) + if err != nil { + return 0, 0, fmt.Errorf("media name %q height: %w", name, err) + } + return int(fw * 100), int(fh * 100), nil +} + +// mediaSizeCol returns the media-size collection member. +func mediaSizeCol(x, y int) goipp.Collection { + return goipp.Collection{ + goipp.MakeAttribute("x-dimension", goipp.TagInteger, goipp.Integer(x)), + goipp.MakeAttribute("y-dimension", goipp.TagInteger, goipp.Integer(y)), + } +} + +// mediaCol returns a media-col collection for a borderless size. The +// margins are siblings of media-size within media-col, per RFC 8011 and PWG +// 5100.3; media-size itself holds only the dimensions. +func mediaCol(x, y int) goipp.Collection { + return goipp.Collection{ + goipp.MakeAttribute("media-size", goipp.TagBeginCollection, mediaSizeCol(x, y)), + goipp.MakeAttribute("media-top-margin", goipp.TagInteger, goipp.Integer(0)), + goipp.MakeAttribute("media-bottom-margin", goipp.TagInteger, goipp.Integer(0)), + goipp.MakeAttribute("media-left-margin", goipp.TagInteger, goipp.Integer(0)), + goipp.MakeAttribute("media-right-margin", goipp.TagInteger, goipp.Integer(0)), + } +} + +// mediaCollections builds the media-size-supported and media-col-database +// values from the printer's self-describing media names. Names that cannot +// be parsed are skipped. +func mediaCollections(names []string) (sizes, cols []goipp.Value) { + for _, name := range names { + x, y, err := mediaSizeDimensions(name) + if err != nil { + continue + } + sizes = append(sizes, mediaSizeCol(x, y)) + cols = append(cols, mediaCol(x, y)) + } + return sizes, cols +} diff --git a/ippsrv/media_test.go b/ippsrv/media_test.go new file mode 100644 index 0000000..3214ebe --- /dev/null +++ b/ippsrv/media_test.go @@ -0,0 +1,48 @@ +package ippsrv + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestMediaSizeDimensions(t *testing.T) { + tests := []struct { + name string + media string + wantX int + wantY int + wantErr bool + }{ + {"label", "om_label-48x100mm_48x100mm", 4800, 10000, false}, + {"short label", "om_label-48x32mm_48x32mm", 4800, 3200, false}, + {"fractional", "om_thing_21.5x30mm", 2150, 3000, false}, + {"iso a4", "iso_a4_210x297mm", 21000, 29700, false}, + {"not self-describing", "roll_57mm", 0, 0, true}, + {"imperial", "na_letter_8.5x11in", 0, 0, true}, + {"garbage", "whatever", 0, 0, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + x, y, err := mediaSizeDimensions(tt.media) + if tt.wantErr { + assert.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.wantX, x) + assert.Equal(t, tt.wantY, y) + }) + } +} + +func TestMediaCollections(t *testing.T) { + sizes, cols := mediaCollections([]string{ + "om_label-48x100mm_48x100mm", + "bogus", // skipped + "om_label-48x40mm_48x40mm", + }) + assert.Len(t, sizes, 2) + assert.Len(t, cols, 2) +} diff --git a/ippsrv/printer.go b/ippsrv/printer.go index 365d1f6..ca98739 100644 --- a/ippsrv/printer.go +++ b/ippsrv/printer.go @@ -120,7 +120,9 @@ func WrapDriver(drv Driver, id, fullname string, opt ...PrinterOption) (Printer, ID: id, state: PSIdle, // Set initial state to idle Drv: drv, - Filter: &imageMagickFilter{}, // Default filter, can be overridden + // Default filter: PWG/URF raster streams are decoded natively, + // anything else falls back to ImageMagick. Can be overridden. + Filter: &rasterSniffFilter{fallback: &imageMagickFilter{}}, } for _, o := range opt { if err := o(p); err != nil { @@ -165,12 +167,21 @@ func (p *basePrinter) UpTime() int { return int(time.Since(startTime).Seconds()) // returns seconds since start } +// Media names are PWG 5101.1 self-describing sizes at the PRINTABLE width: +// the label stock is 58mm, but the head prints 384px @ 203dpi ≈ 48mm. +// Advertising 48mm makes driverless clients rasterise at exactly 384px, so +// pages arrive pixel-perfect without rescaling. func (p *basePrinter) MediaSupported() []string { - return []string{"roll_57mm"} + return []string{ + "om_label-48x100mm_48x100mm", + "om_label-48x40mm_48x40mm", + "om_label-48x32mm_48x32mm", + "om_label-48x60mm_48x60mm", + } } func (p *basePrinter) MediaDefault() string { - return "roll_57mm" + return "om_label-48x100mm_48x100mm" } func (p *basePrinter) UUID() string { diff --git a/ippsrv/spool_test.go b/ippsrv/spool_test.go index 7b13779..dfc198f 100644 --- a/ippsrv/spool_test.go +++ b/ippsrv/spool_test.go @@ -45,7 +45,7 @@ func mustCreateJob(t *testing.T, p Printer, id JobID, name string) *Job { printerURI := "ipp://localhost/printers/" + p.Name() jobURI := fmt.Sprintf("/printers/%s/%d", p.Name(), id) - job, err := createJob(p, id, printerURI, jobURI, name, "tester") + job, err := createJob(p, id, printerURI, jobURI, name, "tester", "") if err != nil { t.Fatalf("createJob: %v", err) }