From 2046c02e5c2c5a1d4bcdedd3146fb67fe938002c Mon Sep 17 00:00:00 2001 From: Mohammad Arman Date: Fri, 4 Sep 2026 17:51:51 +0000 Subject: [PATCH 01/10] cmd/mfp-test: add raster.go with PWG Raster and Apple URF conversion --- cmd/mfp-test/test/raster.go | 47 +++++++++++++++++++++++++++++++++++++ go.mod | 13 +++++----- go.sum | 12 ++++++++++ 3 files changed, 66 insertions(+), 6 deletions(-) create mode 100644 cmd/mfp-test/test/raster.go diff --git a/cmd/mfp-test/test/raster.go b/cmd/mfp-test/test/raster.go new file mode 100644 index 00000000..4e3b0f46 --- /dev/null +++ b/cmd/mfp-test/test/raster.go @@ -0,0 +1,47 @@ +// 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" + + "github.com/rusq/thermoprint/cupsraster" +) + +// 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) + default: + return nil, fmt.Errorf("raster: unsupported format %q", format) + } +} + +// 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/go.mod b/go.mod index 240b9ccf..a4952742 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/OpenPrinting/go-mfp -go 1.21 +go 1.25.0 require ( github.com/OpenPrinting/go-avahi v0.0.0-20260812135728-151f3263d093 @@ -8,14 +8,15 @@ require ( github.com/google/go-cmp v0.6.0 github.com/kr/pretty v0.3.1 github.com/thepudds/patience-diff v0.0.0-20220218194023-f6376aca9d74 - golang.org/x/image v0.24.0 - golang.org/x/net v0.35.0 - golang.org/x/sys v0.30.0 - golang.org/x/term v0.29.0 + golang.org/x/image v0.43.0 + golang.org/x/net v0.56.0 + golang.org/x/sys v0.46.0 + golang.org/x/term v0.44.0 ) require ( github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect - golang.org/x/text v0.22.0 // indirect + github.com/rusq/thermoprint v0.4.0 // indirect + golang.org/x/text v0.38.0 // indirect ) diff --git a/go.sum b/go.sum index 794651d5..2be0c04d 100644 --- a/go.sum +++ b/go.sum @@ -16,17 +16,29 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= +github.com/rusq/thermoprint v0.4.0 h1:D2jJuL5usIhn8lYwv4uAUn3aUqLgG+ADLBwU+ZqB/uE= +github.com/rusq/thermoprint v0.4.0/go.mod h1:Ymhrx7c6hkrJejx9pib6erAKIE+0jfmODkDTeiShApY= github.com/thepudds/patience-diff v0.0.0-20220218194023-f6376aca9d74 h1:jDYB8S3xUpRVofgtACn0W26aV9yDOyJ1wNVKjxJxV4Q= github.com/thepudds/patience-diff v0.0.0-20220218194023-f6376aca9d74/go.mod h1:jvWGfbrrxC4HaKixGjJlQsO3Z0uln9pBxc/b8FDW3BY= golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +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/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +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/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= From fcefc6afbe470daa2ce659e02dbb0ef0e9fda548 Mon Sep 17 00:00:00 2001 From: Mohammad Arman Date: Fri, 4 Sep 2026 18:37:55 +0000 Subject: [PATCH 02/10] cmd/mfp-test: add bimg support for PDF, JPEG, TIFF conversion to PNG --- cmd/mfp-test/test/raster.go | 18 ++++++++++++++++++ go.mod | 1 + go.sum | 2 ++ 3 files changed, 21 insertions(+) diff --git a/cmd/mfp-test/test/raster.go b/cmd/mfp-test/test/raster.go index 4e3b0f46..9346d025 100644 --- a/cmd/mfp-test/test/raster.go +++ b/cmd/mfp-test/test/raster.go @@ -12,6 +12,7 @@ import ( "fmt" "image/png" + "github.com/h2non/bimg" "github.com/rusq/thermoprint/cupsraster" ) @@ -22,11 +23,28 @@ func convertToPNG(data []byte, format string) ([]byte, error) { switch format { case "image/pwg-raster", "image/urf": return convertRasterToPNG(data) + case "application/pdf", + "image/jpeg", + "image/tiff", + "image/webp", + "image/gif", + "image/png": + return convertVipsToPNG(data) default: 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 +} + // convertRasterToPNG decodes a PWG Raster or Apple URF stream and encodes // the first page as PNG. func convertRasterToPNG(data []byte) ([]byte, error) { diff --git a/go.mod b/go.mod index a4952742..994c497f 100644 --- a/go.mod +++ b/go.mod @@ -15,6 +15,7 @@ require ( ) require ( + github.com/h2non/bimg v1.1.9 // indirect github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/rusq/thermoprint v0.4.0 // indirect diff --git a/go.sum b/go.sum index 2be0c04d..5cba059e 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/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= From 461874bacf4424574e25a9e3563922f2d49507c8 Mon Sep 17 00:00:00 2001 From: Mohammad Arman Date: Fri, 4 Sep 2026 18:46:42 +0000 Subject: [PATCH 03/10] cmd/mfp-test: add imagick support for PostScript conversion to PNG --- cmd/mfp-test/test/raster.go | 27 +++++++++++++++++++++++++++ go.mod | 3 +++ go.sum | 6 ++++++ 3 files changed, 36 insertions(+) diff --git a/cmd/mfp-test/test/raster.go b/cmd/mfp-test/test/raster.go index 9346d025..31973ad9 100644 --- a/cmd/mfp-test/test/raster.go +++ b/cmd/mfp-test/test/raster.go @@ -14,6 +14,7 @@ import ( "github.com/h2non/bimg" "github.com/rusq/thermoprint/cupsraster" + "gopkg.in/gographics/imagick.v2/imagick" ) // convertToPNG converts captured document bytes to a PNG image. @@ -30,6 +31,9 @@ func convertToPNG(data []byte, format string) ([]byte, error) { "image/gif", "image/png": return convertVipsToPNG(data) + case "application/postscript", + "application/vnd.cups-postscript": + return convertPSToPNG(data) default: return nil, fmt.Errorf("raster: unsupported format %q", format) } @@ -45,6 +49,29 @@ func convertVipsToPNG(data []byte) ([]byte, error) { return out, nil } +// convertPSToPNG uses ImageMagick (via Ghostscript delegate) to convert +// PostScript to PNG. Only the first page is returned. +func convertPSToPNG(data []byte) ([]byte, error) { + imagick.Initialize() + defer imagick.Terminate() + + mw := imagick.NewMagickWand() + defer mw.Destroy() + + if err := mw.ReadImageBlob(data); err != nil { + return nil, fmt.Errorf("raster: imagick read PS: %w", err) + } + mw.SetIteratorIndex(0) + if err := mw.SetImageFormat("PNG"); err != nil { + return nil, fmt.Errorf("raster: imagick set format: %w", err) + } + out, err := mw.GetImageBlob() + if err != nil { + return nil, fmt.Errorf("raster: imagick get blob: %w", err) + } + return out, nil +} + // convertRasterToPNG decodes a PWG Raster or Apple URF stream and encodes // the first page as PNG. func convertRasterToPNG(data []byte) ([]byte, error) { diff --git a/go.mod b/go.mod index 994c497f..995961d9 100644 --- a/go.mod +++ b/go.mod @@ -15,9 +15,12 @@ require ( ) require ( + github.com/gographics/imagick v3.2.0+incompatible // indirect github.com/h2non/bimg v1.1.9 // indirect github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect github.com/rusq/thermoprint v0.4.0 // indirect golang.org/x/text v0.38.0 // indirect + gopkg.in/gographics/imagick.v2 v2.7.1 // indirect + gopkg.in/gographics/imagick.v3 v3.7.3 // indirect ) diff --git a/go.sum b/go.sum index 5cba059e..6b2b4edc 100644 --- a/go.sum +++ b/go.sum @@ -3,6 +3,8 @@ github.com/OpenPrinting/go-avahi v0.0.0-20260812135728-151f3263d093/go.mod h1:1v github.com/OpenPrinting/goipp v1.2.1-0.20260630192304-61012e2ae9bf h1:ZP5tvUK5YTp/KxNSA2+IjP47R6aFFu7Tl7BE1hm273M= github.com/OpenPrinting/goipp v1.2.1-0.20260630192304-61012e2ae9bf/go.mod h1:ot2iw+QF7fVLaX+55JUNlF5YSDNiXVo2LRAv21iGcQI= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/gographics/imagick v3.2.0+incompatible h1:KeoHcmV7bVRKnj6tXTIcEGnyDg8kO0vPNUTC/5HzwOI= +github.com/gographics/imagick v3.2.0+incompatible/go.mod h1:gk55mrttmUSR+Vt6dxnxuD7EKU/3e5Ehe8HRGVCI8mw= 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= @@ -44,3 +46,7 @@ golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/gographics/imagick.v2 v2.7.1 h1:7k8qhqYwF093KJ/vmWJHoOkI9zzLOhL/h2qt5LFc7iw= +gopkg.in/gographics/imagick.v2 v2.7.1/go.mod h1:i87+p+KdGE2FerYMoj6JWEqsmpTWyYM8LQqMDtu5Hws= +gopkg.in/gographics/imagick.v3 v3.7.3 h1:Hy2MbJKLJ/9T3ZuV1zwBOy09O9prf2MCCVpM7bcZdpY= +gopkg.in/gographics/imagick.v3 v3.7.3/go.mod h1:7I4S9VWdwr88yzYi7g+ZL4H8oZuH9cmSQI7GsZCcYFM= From 53bb4e1af301fa207c64fd0f73492e97fbd9b198 Mon Sep 17 00:00:00 2001 From: Mohammad Arman Date: Fri, 4 Sep 2026 18:48:52 +0000 Subject: [PATCH 04/10] cmd/mfp-test: wire image evaluation into RunTest via --comparator flag --- cmd/mfp-test/test/command.go | 22 ++++++++++++++- cmd/mfp-test/test/runner.go | 52 ++++++++++++++++++++++++++++-------- 2 files changed, 62 insertions(+), 12 deletions(-) diff --git a/cmd/mfp-test/test/command.go b/cmd/mfp-test/test/command.go index a4d40226..6d9f91b9 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 for image evaluation", + 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,24 @@ func cmdTestHandler(ctx context.Context, inv *argv.Invocation) error { timeout = d } + // Set up image evaluator if --comparator is specified. + 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 + } + 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/runner.go b/cmd/mfp-test/test/runner.go index 207ecf13..d6dc3410 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,41 @@ 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) + } + defer os.Remove(capturedPNG.Name()) + defer capturedPNG.Close() + + if _, err := capturedPNG.Write(pngData); err != nil { + return nil, fmt.Errorf("write captured PNG: %w", err) + } + capturedPNG.Close() + + // Evaluate image similarity. + res, err := eval.Compare(imgPath, capturedPNG.Name(), 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 } From 3f65e4194958a3f38daef01ef245ed2bc46e3bfa Mon Sep 17 00:00:00 2001 From: Mohammad Arman Date: Fri, 4 Sep 2026 18:50:09 +0000 Subject: [PATCH 05/10] cmd/mfp-test: remove unused imagick v3 dependency --- go.mod | 8 +++----- go.sum | 14 -------------- 2 files changed, 3 insertions(+), 19 deletions(-) diff --git a/go.mod b/go.mod index 995961d9..6bf6d7bd 100644 --- a/go.mod +++ b/go.mod @@ -6,21 +6,19 @@ require ( github.com/OpenPrinting/go-avahi v0.0.0-20260812135728-151f3263d093 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/kr/pretty v0.3.1 + github.com/rusq/thermoprint v0.4.0 github.com/thepudds/patience-diff v0.0.0-20220218194023-f6376aca9d74 golang.org/x/image v0.43.0 golang.org/x/net v0.56.0 golang.org/x/sys v0.46.0 golang.org/x/term v0.44.0 + gopkg.in/gographics/imagick.v2 v2.7.1 ) require ( - github.com/gographics/imagick v3.2.0+incompatible // indirect - github.com/h2non/bimg v1.1.9 // indirect github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect - github.com/rusq/thermoprint v0.4.0 // indirect golang.org/x/text v0.38.0 // indirect - gopkg.in/gographics/imagick.v2 v2.7.1 // indirect - gopkg.in/gographics/imagick.v3 v3.7.3 // indirect ) diff --git a/go.sum b/go.sum index 6b2b4edc..4b856034 100644 --- a/go.sum +++ b/go.sum @@ -3,8 +3,6 @@ github.com/OpenPrinting/go-avahi v0.0.0-20260812135728-151f3263d093/go.mod h1:1v github.com/OpenPrinting/goipp v1.2.1-0.20260630192304-61012e2ae9bf h1:ZP5tvUK5YTp/KxNSA2+IjP47R6aFFu7Tl7BE1hm273M= github.com/OpenPrinting/goipp v1.2.1-0.20260630192304-61012e2ae9bf/go.mod h1:ot2iw+QF7fVLaX+55JUNlF5YSDNiXVo2LRAv21iGcQI= github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= -github.com/gographics/imagick v3.2.0+incompatible h1:KeoHcmV7bVRKnj6tXTIcEGnyDg8kO0vPNUTC/5HzwOI= -github.com/gographics/imagick v3.2.0+incompatible/go.mod h1:gk55mrttmUSR+Vt6dxnxuD7EKU/3e5Ehe8HRGVCI8mw= 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= @@ -24,29 +22,17 @@ github.com/rusq/thermoprint v0.4.0 h1:D2jJuL5usIhn8lYwv4uAUn3aUqLgG+ADLBwU+ZqB/u github.com/rusq/thermoprint v0.4.0/go.mod h1:Ymhrx7c6hkrJejx9pib6erAKIE+0jfmODkDTeiShApY= github.com/thepudds/patience-diff v0.0.0-20220218194023-f6376aca9d74 h1:jDYB8S3xUpRVofgtACn0W26aV9yDOyJ1wNVKjxJxV4Q= github.com/thepudds/patience-diff v0.0.0-20220218194023-f6376aca9d74/go.mod h1:jvWGfbrrxC4HaKixGjJlQsO3Z0uln9pBxc/b8FDW3BY= -golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= -golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= 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/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= -golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= 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/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= -golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= 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.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= -golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= 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.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= -golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= gopkg.in/gographics/imagick.v2 v2.7.1 h1:7k8qhqYwF093KJ/vmWJHoOkI9zzLOhL/h2qt5LFc7iw= gopkg.in/gographics/imagick.v2 v2.7.1/go.mod h1:i87+p+KdGE2FerYMoj6JWEqsmpTWyYM8LQqMDtu5Hws= -gopkg.in/gographics/imagick.v3 v3.7.3 h1:Hy2MbJKLJ/9T3ZuV1zwBOy09O9prf2MCCVpM7bcZdpY= -gopkg.in/gographics/imagick.v3 v3.7.3/go.mod h1:7I4S9VWdwr88yzYi7g+ZL4H8oZuH9cmSQI7GsZCcYFM= From 40e0b2facc4db94f4edd552fc12422af985b0b05 Mon Sep 17 00:00:00 2001 From: Mohammad Arman Date: Fri, 4 Sep 2026 18:54:06 +0000 Subject: [PATCH 06/10] cmd/mfp-test: fix imagick init and temp file double close in raster conversion --- cmd/mfp-test/test/raster.go | 10 ++++++++-- cmd/mfp-test/test/runner.go | 11 +++++++---- 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/cmd/mfp-test/test/raster.go b/cmd/mfp-test/test/raster.go index 31973ad9..82ffdd3a 100644 --- a/cmd/mfp-test/test/raster.go +++ b/cmd/mfp-test/test/raster.go @@ -11,12 +11,19 @@ import ( "bytes" "fmt" "image/png" + "sync" "github.com/h2non/bimg" "github.com/rusq/thermoprint/cupsraster" "gopkg.in/gographics/imagick.v2/imagick" ) +var imagickOnce sync.Once + +func initImagick() { + imagickOnce.Do(imagick.Initialize) +} + // 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. @@ -52,8 +59,7 @@ func convertVipsToPNG(data []byte) ([]byte, error) { // convertPSToPNG uses ImageMagick (via Ghostscript delegate) to convert // PostScript to PNG. Only the first page is returned. func convertPSToPNG(data []byte) ([]byte, error) { - imagick.Initialize() - defer imagick.Terminate() + initImagick() mw := imagick.NewMagickWand() defer mw.Destroy() diff --git a/cmd/mfp-test/test/runner.go b/cmd/mfp-test/test/runner.go index d6dc3410..42da241e 100644 --- a/cmd/mfp-test/test/runner.go +++ b/cmd/mfp-test/test/runner.go @@ -116,16 +116,19 @@ func runTest(ctx context.Context, cfg testConfig, queueName string, if err != nil { return nil, fmt.Errorf("create temp PNG: %w", err) } - defer os.Remove(capturedPNG.Name()) - defer capturedPNG.Close() + 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) } - capturedPNG.Close() + if err := capturedPNG.Close(); err != nil { + return nil, fmt.Errorf("close captured PNG: %w", err) + } // Evaluate image similarity. - res, err := eval.Compare(imgPath, capturedPNG.Name(), threshold, verbose) + res, err := eval.Compare(imgPath, capturedPNGName, threshold, verbose) if err != nil { return nil, fmt.Errorf("evaluate: %w", err) } From 85824645cef169b627900c3c87ab60f3d93cbc4d Mon Sep 17 00:00:00 2001 From: Mohammad Arman Date: Fri, 4 Sep 2026 18:55:47 +0000 Subject: [PATCH 07/10] cmd/mfp-test: fix imagick iterator check and add cups-pdf format support --- cmd/mfp-test/test/raster.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cmd/mfp-test/test/raster.go b/cmd/mfp-test/test/raster.go index 82ffdd3a..25361959 100644 --- a/cmd/mfp-test/test/raster.go +++ b/cmd/mfp-test/test/raster.go @@ -32,6 +32,7 @@ func convertToPNG(data []byte, format string) ([]byte, error) { case "image/pwg-raster", "image/urf": return convertRasterToPNG(data) case "application/pdf", + "application/vnd.cups-pdf", "image/jpeg", "image/tiff", "image/webp", @@ -67,7 +68,9 @@ func convertPSToPNG(data []byte) ([]byte, error) { if err := mw.ReadImageBlob(data); err != nil { return nil, fmt.Errorf("raster: imagick read PS: %w", err) } - mw.SetIteratorIndex(0) + if !mw.SetIteratorIndex(0) { + return nil, fmt.Errorf("raster: imagick: no pages in document") + } if err := mw.SetImageFormat("PNG"); err != nil { return nil, fmt.Errorf("raster: imagick set format: %w", err) } From a675932f4ab2351b254d018d532b4af5bfd91ad3 Mon Sep 17 00:00:00 2001 From: Mohammad Arman Date: Fri, 4 Sep 2026 19:08:08 +0000 Subject: [PATCH 08/10] cmd/mfp-test: document known conversion limitations in raster.go Add notes about Ubuntu ImageMagick policy blocking PostScript coders and unsupported formats (image/vnd.cups-raster, image/jpeg+gzip) not yet handled for image evaluation. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SgcYjdrNyPsMQLUGQvZpaa --- cmd/mfp-test/test/raster.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cmd/mfp-test/test/raster.go b/cmd/mfp-test/test/raster.go index 25361959..ffa315b2 100644 --- a/cmd/mfp-test/test/raster.go +++ b/cmd/mfp-test/test/raster.go @@ -43,6 +43,8 @@ func convertToPNG(data []byte, format string) ([]byte, error) { "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) } } @@ -59,6 +61,10 @@ func convertVipsToPNG(data []byte) ([]byte, error) { // convertPSToPNG uses ImageMagick (via Ghostscript delegate) to convert // PostScript to PNG. Only the first page is returned. +// +// Note: on Ubuntu, /etc/ImageMagick-6/policy.xml blocks the PS and delegate +// coders by default. PostScript conversion requires that the system policy +// allows "read|write" on the PS coder and the Ghostscript delegate. func convertPSToPNG(data []byte) ([]byte, error) { initImagick() From 5dc4dc925249a881c723877f3a507ff26e5e9b8c Mon Sep 17 00:00:00 2001 From: Mohammad Arman Date: Tue, 15 Sep 2026 06:35:12 +0000 Subject: [PATCH 09/10] cmd/mfp-test: replace imagick+thermoprint with gs and vendored cupsraster - Replace ImageMagick PostScript conversion with a direct Ghostscript call, removing the imagick dependency and Ubuntu policy.xml requirement - Vendor cupsraster from github.com/rusq/thermoprint (MIT) into cmd/mfp-test/cupsraster/, removing the thermoprint dependency - Add CREDITS.txt crediting the cupsraster author and license - Downgrade golang.org/x packages to origin/master versions, restoring go.mod to go 1.21 Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SgcYjdrNyPsMQLUGQvZpaa --- CREDITS.txt | 29 +++++ cmd/mfp-test/cupsraster/cupsraster.go | 164 ++++++++++++++++++++++++++ cmd/mfp-test/cupsraster/pwg.go | 146 +++++++++++++++++++++++ cmd/mfp-test/cupsraster/rle.go | 86 ++++++++++++++ cmd/mfp-test/cupsraster/urf.go | 115 ++++++++++++++++++ cmd/mfp-test/test/raster.go | 67 ++++++----- go.mod | 14 +-- go.sum | 24 ++-- 8 files changed, 594 insertions(+), 51 deletions(-) create mode 100644 CREDITS.txt create mode 100644 cmd/mfp-test/cupsraster/cupsraster.go create mode 100644 cmd/mfp-test/cupsraster/pwg.go create mode 100644 cmd/mfp-test/cupsraster/rle.go create mode 100644 cmd/mfp-test/cupsraster/urf.go 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/raster.go b/cmd/mfp-test/test/raster.go index ffa315b2..a2a81242 100644 --- a/cmd/mfp-test/test/raster.go +++ b/cmd/mfp-test/test/raster.go @@ -11,19 +11,13 @@ import ( "bytes" "fmt" "image/png" - "sync" + "os" + "os/exec" + "github.com/OpenPrinting/go-mfp/cmd/mfp-test/cupsraster" "github.com/h2non/bimg" - "github.com/rusq/thermoprint/cupsraster" - "gopkg.in/gographics/imagick.v2/imagick" ) -var imagickOnce sync.Once - -func initImagick() { - imagickOnce.Do(imagick.Initialize) -} - // 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. @@ -59,32 +53,47 @@ func convertVipsToPNG(data []byte) ([]byte, error) { return out, nil } -// convertPSToPNG uses ImageMagick (via Ghostscript delegate) to convert -// PostScript to PNG. Only the first page is returned. -// -// Note: on Ubuntu, /etc/ImageMagick-6/policy.xml blocks the PS and delegate -// coders by default. PostScript conversion requires that the system policy -// allows "read|write" on the PS coder and the Ghostscript delegate. +// 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) { - initImagick() - - mw := imagick.NewMagickWand() - defer mw.Destroy() - - if err := mw.ReadImageBlob(data); err != nil { - return nil, fmt.Errorf("raster: imagick read PS: %w", err) + // 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) } - if !mw.SetIteratorIndex(0) { - return nil, fmt.Errorf("raster: imagick: no pages in document") + 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 := mw.SetImageFormat("PNG"); err != nil { - return nil, fmt.Errorf("raster: imagick set format: %w", err) + if err := inFile.Close(); err != nil { + return nil, fmt.Errorf("raster: gs: close input: %w", err) } - out, err := mw.GetImageBlob() + + // Create a temp file path for the PNG output. + outFile, err := os.CreateTemp("", "mfp-png-*.png") if err != nil { - return nil, fmt.Errorf("raster: imagick get blob: %w", err) + return nil, fmt.Errorf("raster: gs: create output temp: %w", err) } - return out, nil + 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 diff --git a/go.mod b/go.mod index 6bf6d7bd..1641085b 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module github.com/OpenPrinting/go-mfp -go 1.25.0 +go 1.21 require ( github.com/OpenPrinting/go-avahi v0.0.0-20260812135728-151f3263d093 @@ -8,17 +8,15 @@ require ( github.com/google/go-cmp v0.6.0 github.com/h2non/bimg v1.1.9 github.com/kr/pretty v0.3.1 - github.com/rusq/thermoprint v0.4.0 github.com/thepudds/patience-diff v0.0.0-20220218194023-f6376aca9d74 - golang.org/x/image v0.43.0 - golang.org/x/net v0.56.0 - golang.org/x/sys v0.46.0 - golang.org/x/term v0.44.0 - gopkg.in/gographics/imagick.v2 v2.7.1 + golang.org/x/image v0.24.0 + golang.org/x/net v0.35.0 + golang.org/x/sys v0.30.0 + golang.org/x/term v0.29.0 ) require ( github.com/kr/text v0.2.0 // indirect github.com/rogpeppe/go-internal v1.9.0 // indirect - golang.org/x/text v0.38.0 // indirect + golang.org/x/text v0.22.0 // indirect ) diff --git a/go.sum b/go.sum index 4b856034..7d0c0420 100644 --- a/go.sum +++ b/go.sum @@ -18,21 +18,17 @@ github.com/pkg/diff v0.0.0-20210226163009-20ebb0f2a09e/go.mod h1:pJLUxLENpZxwdsK github.com/rogpeppe/go-internal v1.8.1/go.mod h1:JeRgkft04UBgHMgCIwADu4Pn6Mtm5d4nPKWu0nJ5d+o= github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8= github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs= -github.com/rusq/thermoprint v0.4.0 h1:D2jJuL5usIhn8lYwv4uAUn3aUqLgG+ADLBwU+ZqB/uE= -github.com/rusq/thermoprint v0.4.0/go.mod h1:Ymhrx7c6hkrJejx9pib6erAKIE+0jfmODkDTeiShApY= github.com/thepudds/patience-diff v0.0.0-20220218194023-f6376aca9d74 h1:jDYB8S3xUpRVofgtACn0W26aV9yDOyJ1wNVKjxJxV4Q= github.com/thepudds/patience-diff v0.0.0-20220218194023-f6376aca9d74/go.mod h1:jvWGfbrrxC4HaKixGjJlQsO3Z0uln9pBxc/b8FDW3BY= -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/net v0.56.0 h1:Rw8j/hFzGvJUZwNBXnAtf5sVDVt+65SK2C7IxCxZt5o= -golang.org/x/net v0.56.0/go.mod h1:D3Ku6r+V6JROoZK144D2XfMHFcMq/0zSfLelVTCFKec= -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.44.0 h1:0rLvDRCtNj0gZkyIXhCyOb2OAzEhLVqc4B+hrsBhrmc= -golang.org/x/term v0.44.0/go.mod h1:7ze4MdzUzLXpSAoFP1H0bOI9aXDqveSvatT5vKcFh2Y= -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/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ= +golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8= +golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8= +golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= +golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= +golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/term v0.29.0 h1:L6pJp37ocefwRRtYPKSWOWzOtWSxVajvz2ldH/xi3iU= +golang.org/x/term v0.29.0/go.mod h1:6bl4lRlvVuDgSf3179VpIxBF0o10JUpXWOnI7nErv7s= +golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= +golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= -gopkg.in/gographics/imagick.v2 v2.7.1 h1:7k8qhqYwF093KJ/vmWJHoOkI9zzLOhL/h2qt5LFc7iw= -gopkg.in/gographics/imagick.v2 v2.7.1/go.mod h1:i87+p+KdGE2FerYMoj6JWEqsmpTWyYM8LQqMDtu5Hws= From ff4557228154d5af8046c88d89c81a226219593e Mon Sep 17 00:00:00 2001 From: Mohammad Arman Date: Tue, 15 Sep 2026 06:46:10 +0000 Subject: [PATCH 10/10] cmd/mfp-test: embed enhanced_comparison.py into binary Add //go:embed for imgeval/enhanced_comparison.py so that image evaluation works out of the box without requiring the user to supply --comparator. NewDefaultEvaluator writes the embedded script to a temp file and sets ownedComparator so Close() cleans it up. --comparator still overrides with an external file when provided. Co-Authored-By: Claude Sonnet 4.6 Claude-Session: https://claude.ai/code/session_01SgcYjdrNyPsMQLUGQvZpaa --- cmd/mfp-test/test/command.go | 13 ++++++-- cmd/mfp-test/test/comparator.go | 14 ++++++++ internal/evaluate/evaluate.go | 58 ++++++++++++++++++++++++++------- 3 files changed, 71 insertions(+), 14 deletions(-) create mode 100644 cmd/mfp-test/test/comparator.go diff --git a/cmd/mfp-test/test/command.go b/cmd/mfp-test/test/command.go index 6d9f91b9..8d40587a 100644 --- a/cmd/mfp-test/test/command.go +++ b/cmd/mfp-test/test/command.go @@ -73,7 +73,7 @@ var Command = argv.Command{ }, { Name: "--comparator", - Help: "path to enhanced_comparison.py for image evaluation", + Help: "path to enhanced_comparison.py (default: use embedded script)", HelpArg: "file", Singleton: true, Validate: argv.ValidateAny, @@ -271,7 +271,8 @@ func cmdTestHandler(ctx context.Context, inv *argv.Invocation) error { timeout = d } - // Set up image evaluator if --comparator is specified. + // 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) @@ -280,6 +281,14 @@ func cmdTestHandler(ctx context.Context, inv *argv.Invocation) error { } 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") 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/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