diff --git a/CREDITS.txt b/CREDITS.txt new file mode 100644 index 00000000..db8d0bcb --- /dev/null +++ b/CREDITS.txt @@ -0,0 +1,29 @@ +cmd/mfp-test/cupsraster + Source: github.com/rusq/thermoprint/cupsraster + Author: github.com/rusq + License: MIT + + MIT License + + Copyright (c) 2025 github.com/rusq + + Protocol reverse engineering based on https://github.com/big-vl/catcombo, + which is (c) 2025 big-vl + + Permission is hereby granted, free of charge, to any person obtaining a copy + of this software and associated documentation files (the "Software"), to deal + in the Software without restriction, including without limitation the rights + to use, copy, modify, merge, publish, distribute, sublicense, and/or sell + copies of the Software, and to permit persons to whom the Software is + furnished to do so, subject to the following conditions: + + The above copyright notice and this permission notice shall be included in + all copies or substantial portions of the Software. + + THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR + IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, + FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE + AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER + LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, + OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE + SOFTWARE. diff --git a/cmd/mfp-test/cupsraster/cupsraster.go b/cmd/mfp-test/cupsraster/cupsraster.go new file mode 100644 index 00000000..a9481ebe --- /dev/null +++ b/cmd/mfp-test/cupsraster/cupsraster.go @@ -0,0 +1,164 @@ +// 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 +// +// This package is derived from github.com/rusq/thermoprint/cupsraster, +// which is copyright (c) 2025 github.com/rusq, MIT License. +// See CREDITS.txt at the root of this repository. +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 := 0; x < len(dst); x++ { + 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/cmd/mfp-test/cupsraster/pwg.go b/cmd/mfp-test/cupsraster/pwg.go new file mode 100644 index 00000000..5da503b1 --- /dev/null +++ b/cmd/mfp-test/cupsraster/pwg.go @@ -0,0 +1,146 @@ +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 := 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/cmd/mfp-test/cupsraster/rle.go b/cmd/mfp-test/cupsraster/rle.go new file mode 100644 index 00000000..6dcc2c1f --- /dev/null +++ b/cmd/mfp-test/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/cmd/mfp-test/cupsraster/urf.go b/cmd/mfp-test/cupsraster/urf.go new file mode 100644 index 00000000..dead1175 --- /dev/null +++ b/cmd/mfp-test/cupsraster/urf.go @@ -0,0 +1,115 @@ +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]), + 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: + return decodeGrayPage(br, h.Width, h.Height, h.BitsPerPixel, bytesPerLine, true) + case 8: + 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/cmd/mfp-test/test/command.go b/cmd/mfp-test/test/command.go index a4d40226..8d40587a 100644 --- a/cmd/mfp-test/test/command.go +++ b/cmd/mfp-test/test/command.go @@ -19,6 +19,7 @@ import ( "time" "github.com/OpenPrinting/go-mfp/argv" + "github.com/OpenPrinting/go-mfp/internal/evaluate" "github.com/OpenPrinting/go-mfp/log" "github.com/OpenPrinting/go-mfp/modeling" "github.com/OpenPrinting/go-mfp/transport" @@ -70,6 +71,14 @@ var Command = argv.Command{ Validate: argv.ValidateAny, Complete: argv.CompleteOSPath, }, + { + Name: "--comparator", + Help: "path to enhanced_comparison.py (default: use embedded script)", + HelpArg: "file", + Singleton: true, + Validate: argv.ValidateAny, + Complete: argv.CompleteOSPath, + }, { Name: "--threshold", Help: fmt.Sprintf("minimum similarity score to pass (0.0-1.0, default %.2f)", defaultThreshold), @@ -262,13 +271,33 @@ func cmdTestHandler(ctx context.Context, inv *argv.Invocation) error { timeout = d } + // Set up image evaluator. Use the embedded enhanced_comparison.py by + // default; --comparator overrides with an external file. + var eval *evaluate.Evaluator + if comparatorPath, ok := inv.Get("--comparator"); ok { + e, err := evaluate.NewEvaluator(comparatorPath) + if err != nil { + return fmt.Errorf("evaluator: %w", err) + } + defer e.Close() + eval = e + } else { + e, err := evaluate.NewDefaultEvaluator(defaultComparatorScript) + if err != nil { + log.Info(ctx, "image evaluation disabled (embedded comparator unavailable): %v", err) + } else { + defer e.Close() + eval = e + } + } + keep := inv.Flag("--keep") verbose := inv.Flag("-v") // Run each test configuration. for _, cfg := range configs { log.Info(ctx, "running test: %s", cfg.Name) - result, err := runTest(ctx, cfg, queueName, capture, threshold, timeout, keep, verbose) + result, err := runTest(ctx, cfg, queueName, capture, eval, threshold, timeout, keep, verbose) if err != nil { log.Info(ctx, "FAIL %s: %v", cfg.Name, err) continue diff --git a/cmd/mfp-test/test/comparator.go b/cmd/mfp-test/test/comparator.go new file mode 100644 index 00000000..b570075a --- /dev/null +++ b/cmd/mfp-test/test/comparator.go @@ -0,0 +1,14 @@ +// MFP - Multi-Function Printers and scanners toolkit +// +// Copyright (C) 2026 Mohammad Arman (officialmdarman@gmail.com) +// See LICENSE for license terms and conditions + +package test + +import _ "embed" + +// defaultComparatorScript is the embedded enhanced_comparison.py bundled +// into the mfp-test binary so users do not need to provide a separate file. +// +//go:embed imgeval/enhanced_comparison.py +var defaultComparatorScript []byte diff --git a/cmd/mfp-test/test/raster.go b/cmd/mfp-test/test/raster.go new file mode 100644 index 00000000..a2a81242 --- /dev/null +++ b/cmd/mfp-test/test/raster.go @@ -0,0 +1,116 @@ +// MFP - Multi-Function Printers and scanners toolkit +// +// Copyright (C) 2026 Mohammad Arman (officialmdarman@gmail.com) +// See LICENSE for license terms and conditions +// +// Raster conversion for mfp-test + +package test + +import ( + "bytes" + "fmt" + "image/png" + "os" + "os/exec" + + "github.com/OpenPrinting/go-mfp/cmd/mfp-test/cupsraster" + "github.com/h2non/bimg" +) + +// convertToPNG converts captured document bytes to a PNG image. +// The format argument is the MIME type of the document (e.g. "image/pwg-raster"). +// For multi-page documents, the first page is returned. +func convertToPNG(data []byte, format string) ([]byte, error) { + switch format { + case "image/pwg-raster", "image/urf": + return convertRasterToPNG(data) + case "application/pdf", + "application/vnd.cups-pdf", + "image/jpeg", + "image/tiff", + "image/webp", + "image/gif", + "image/png": + return convertVipsToPNG(data) + case "application/postscript", + "application/vnd.cups-postscript": + return convertPSToPNG(data) + default: + // image/vnd.cups-raster and image/jpeg+gzip are not yet supported + // for image evaluation; captured bytes are still saved with --keep. + return nil, fmt.Errorf("raster: unsupported format %q", format) + } +} + +// convertVipsToPNG uses bimg (libvips) to convert PDF, JPEG, TIFF and other +// common formats to PNG. For multi-page documents, the first page is used. +func convertVipsToPNG(data []byte) ([]byte, error) { + out, err := bimg.NewImage(data).Convert(bimg.PNG) + if err != nil { + return nil, fmt.Errorf("raster: bimg convert: %w", err) + } + return out, nil +} + +// convertPSToPNG calls Ghostscript directly to convert the first page of a +// PostScript document to PNG. Using gs avoids the ImageMagick dependency and +// the Ubuntu policy.xml reconfiguration it requires. +func convertPSToPNG(data []byte) ([]byte, error) { + // Write PostScript data to a temp input file. + inFile, err := os.CreateTemp("", "mfp-ps-*.ps") + if err != nil { + return nil, fmt.Errorf("raster: gs: create input temp: %w", err) + } + defer os.Remove(inFile.Name()) + if _, err := inFile.Write(data); err != nil { + inFile.Close() + return nil, fmt.Errorf("raster: gs: write PS: %w", err) + } + if err := inFile.Close(); err != nil { + return nil, fmt.Errorf("raster: gs: close input: %w", err) + } + + // Create a temp file path for the PNG output. + outFile, err := os.CreateTemp("", "mfp-png-*.png") + if err != nil { + return nil, fmt.Errorf("raster: gs: create output temp: %w", err) + } + outPath := outFile.Name() + outFile.Close() + defer os.Remove(outPath) + + // Run Ghostscript: render only the first page at 150 dpi. + cmd := exec.Command("gs", + "-dBATCH", "-dNOPAUSE", "-dQUIET", + "-sDEVICE=png16m", + "-r150", + "-dFirstPage=1", "-dLastPage=1", + "-sOutputFile="+outPath, + inFile.Name(), + ) + if out, err := cmd.CombinedOutput(); err != nil { + return nil, fmt.Errorf("raster: gs: %w: %s", err, out) + } + + return os.ReadFile(outPath) +} + +// convertRasterToPNG decodes a PWG Raster or Apple URF stream and encodes +// the first page as PNG. +func convertRasterToPNG(data []byte) ([]byte, error) { + r := bytes.NewReader(data) + pages, err := cupsraster.Decode(r) + if err != nil { + return nil, fmt.Errorf("raster: decode: %w", err) + } + if len(pages) == 0 { + return nil, fmt.Errorf("raster: no pages in raster stream") + } + + var buf bytes.Buffer + if err := png.Encode(&buf, pages[0]); err != nil { + return nil, fmt.Errorf("raster: encode PNG: %w", err) + } + return buf.Bytes(), nil +} diff --git a/cmd/mfp-test/test/runner.go b/cmd/mfp-test/test/runner.go index 207ecf13..42da241e 100644 --- a/cmd/mfp-test/test/runner.go +++ b/cmd/mfp-test/test/runner.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/OpenPrinting/go-mfp/internal/evaluate" "github.com/OpenPrinting/go-mfp/log" ) @@ -34,12 +35,14 @@ type testResult struct { // runTest runs a single print test using the given configuration: // generates a test PNG, sends it to the CUPS queue with the specified -// job attributes, waits for capture, and returns the test result. +// job attributes, waits for capture, converts the captured document to +// PNG, and evaluates image similarity. // -// Image evaluation is not yet implemented; the function currently -// reports success if the document was captured within the timeout. +// If eval is nil, image evaluation is skipped and capture success is +// reported as a pass with score 1.0. func runTest(ctx context.Context, cfg testConfig, queueName string, - capture *documentCapture, threshold float64, timeout time.Duration, keep, verbose bool) (*testResult, error) { + capture *documentCapture, eval *evaluate.Evaluator, + threshold float64, timeout time.Duration, keep, verbose bool) (*testResult, error) { // Reset capture so we get only this job's document. capture.reset() @@ -97,14 +100,44 @@ func runTest(ctx context.Context, cfg testConfig, queueName string, } } - // Image evaluation will be wired here in Phase 5 once raster - // conversion (captured bytes → PNG) is implemented. For now, - // a successful capture counts as a pass with a placeholder score. - score := 1.0 + // Skip image evaluation if no evaluator is configured. + if eval == nil { + return &testResult{Config: cfg, Score: 1.0, Passed: true}, nil + } + + // Convert captured document to PNG for evaluation. + pngData, err := convertToPNG(d.Data, d.Params.Format) + if err != nil { + return nil, fmt.Errorf("convert to PNG: %w", err) + } + + // Write PNG to a temp file for the evaluator. + capturedPNG, err := os.CreateTemp("", "mfp-captured-*.png") + if err != nil { + return nil, fmt.Errorf("create temp PNG: %w", err) + } + capturedPNGName := capturedPNG.Name() + defer os.Remove(capturedPNGName) + + if _, err := capturedPNG.Write(pngData); err != nil { + capturedPNG.Close() + return nil, fmt.Errorf("write captured PNG: %w", err) + } + if err := capturedPNG.Close(); err != nil { + return nil, fmt.Errorf("close captured PNG: %w", err) + } + + // Evaluate image similarity. + res, err := eval.Compare(imgPath, capturedPNGName, threshold, verbose) + if err != nil { + return nil, fmt.Errorf("evaluate: %w", err) + } + return &testResult{ - Config: cfg, - Score: score, - Passed: score >= threshold, + Config: cfg, + Score: res.Score, + Passed: res.Passed, + Details: res.Details, }, nil } diff --git a/go.mod b/go.mod index 485e71e4..2110fcbf 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/OpenPrinting/go-avahi v0.0.0-20260907213706-26c00d557531 github.com/OpenPrinting/goipp v1.2.1-0.20260630192304-61012e2ae9bf github.com/google/go-cmp v0.6.0 + github.com/h2non/bimg v1.1.9 github.com/hashicorp/golang-lru/v2 v2.0.7 github.com/kr/pretty v0.3.1 github.com/thepudds/patience-diff v0.0.0-20220218194023-f6376aca9d74 diff --git a/go.sum b/go.sum index 1ba483ef..19243418 100644 --- a/go.sum +++ b/go.sum @@ -5,6 +5,8 @@ github.com/OpenPrinting/goipp v1.2.1-0.20260630192304-61012e2ae9bf/go.mod h1:ot2 github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= 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/h2non/bimg v1.1.9 h1:WH20Nxko9l/HFm4kZCA3Phbgu2cbHvYzxwxn9YROEGg= +github.com/h2non/bimg v1.1.9/go.mod h1:R3+UiYwkK4rQl6KVFTOFJHitgLbZXBZNFh2cv3AEbp8= github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= diff --git a/internal/evaluate/evaluate.go b/internal/evaluate/evaluate.go index 39320f0d..28a922ec 100644 --- a/internal/evaluate/evaluate.go +++ b/internal/evaluate/evaluate.go @@ -65,20 +65,16 @@ type Result struct { // Evaluator runs ImageComparator in a subprocess and provides // a simple Go API for image quality comparison. // -// Create with [NewEvaluator] and release with [Evaluator.Close]. +// Create with [NewEvaluator] or [NewDefaultEvaluator] and release with [Evaluator.Close]. type Evaluator struct { - comparatorPath string // path to enhanced_comparison.py - runnerPath string // temp file containing the runner script + comparatorPath string // path to enhanced_comparison.py + runnerPath string // temp file containing the runner script + ownedComparator bool // true when comparatorPath is a temp file we must remove on Close } -// NewEvaluator creates a new Evaluator for the given enhanced_comparison.py -// path. A small Python runner script is written to a temp file; it is removed -// when [Evaluator.Close] is called. -func NewEvaluator(comparatorPath string) (*Evaluator, error) { - if _, err := os.Stat(comparatorPath); err != nil { - return nil, fmt.Errorf("evaluate: %w", err) - } - +// newEvaluator is the shared constructor that writes the runner script and +// returns a configured Evaluator. comparatorPath must already exist on disk. +func newEvaluator(comparatorPath string) (*Evaluator, error) { f, err := os.CreateTemp("", "mfp-evaluate-*.py") if err != nil { return nil, fmt.Errorf("evaluate: create runner: %w", err) @@ -96,9 +92,47 @@ func NewEvaluator(comparatorPath string) (*Evaluator, error) { }, nil } -// Close removes the temporary runner script created by [NewEvaluator]. +// NewEvaluator creates a new Evaluator for the given enhanced_comparison.py +// path. A small Python runner script is written to a temp file; it is removed +// when [Evaluator.Close] is called. +func NewEvaluator(comparatorPath string) (*Evaluator, error) { + if _, err := os.Stat(comparatorPath); err != nil { + return nil, fmt.Errorf("evaluate: %w", err) + } + return newEvaluator(comparatorPath) +} + +// NewDefaultEvaluator creates an Evaluator from an in-memory script (typically +// the embedded enhanced_comparison.py). The script is written to a temporary +// file that is removed when [Evaluator.Close] is called. +func NewDefaultEvaluator(script []byte) (*Evaluator, error) { + cf, err := os.CreateTemp("", "mfp-comparator-*.py") + if err != nil { + return nil, fmt.Errorf("evaluate: write comparator: %w", err) + } + comparatorPath := cf.Name() + if _, err := cf.Write(script); err != nil { + cf.Close() + os.Remove(comparatorPath) + return nil, fmt.Errorf("evaluate: write comparator: %w", err) + } + cf.Close() + + e, err := newEvaluator(comparatorPath) + if err != nil { + os.Remove(comparatorPath) + return nil, err + } + e.ownedComparator = true + return e, nil +} + +// Close removes the temporary files created by [NewEvaluator] or [NewDefaultEvaluator]. func (e *Evaluator) Close() { os.Remove(e.runnerPath) + if e.ownedComparator { + os.Remove(e.comparatorPath) + } } // Compare compares the captured image against the original and