diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml
index bd1c674c..13539b07 100644
--- a/.github/workflows/build.yml
+++ b/.github/workflows/build.yml
@@ -76,6 +76,13 @@ jobs:
- name: Run linter
run: ${{ steps.pm.outputs.runner }} lint
+ - name: Run unit tests
+ run: ${{ steps.pm.outputs.runner }} test
+
+ # Runs the same pipeline as deploy.yml, similarity included, so a PR that
+ # breaks vectorize.ts or compute-similarity.ts fails here rather than on
+ # the deploy run after merge. The recommendation artifacts are generated,
+ # never committed, so there is nothing pre-built for CI to fall back on.
- name: Build Next.js
run: ${{ steps.pm.outputs.runner }} build
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 8ad31945..7e2cc77c 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -4,6 +4,10 @@ on:
push:
branches: [master]
+ # Weekly rebuild so recommendations pick up upstream foomatic-db changes.
+ schedule:
+ - cron: "0 2 * * 1"
+
workflow_dispatch:
permissions:
diff --git a/app/foomatic/printers/page.tsx b/app/foomatic/printers/page.tsx
index 180ea524..83db8b8a 100644
--- a/app/foomatic/printers/page.tsx
+++ b/app/foomatic/printers/page.tsx
@@ -261,18 +261,17 @@ export default function HomePage() {
if (selectedColorCapability !== "all") {
result = result.filter((printer) => {
- const type = printer.type?.toLowerCase() || ""
- const model = typeof printer.model === "string" ? printer.model.toLowerCase() : ""
+ const color = printer.color
if (selectedColorCapability === "color") {
- return type.includes("color") || model.includes("color")
+ return color === true
}
if (selectedColorCapability === "monochrome") {
- return type.includes("mono") || type.includes("dot-matrix") || model.includes("mono")
+ return color === false
}
- return true
+ return color === "unknown" || color === undefined
})
}
diff --git a/components/foomatic/DriverPageClient.tsx b/components/foomatic/DriverPageClient.tsx
index 3e365904..58204b40 100644
--- a/components/foomatic/DriverPageClient.tsx
+++ b/components/foomatic/DriverPageClient.tsx
@@ -20,6 +20,7 @@ import {
import { Button } from "@/components/ui/button"
import { withBasePath } from "@/lib/foomatic/base-path"
import { driverHref, printerHref } from "@/lib/foomatic/routes"
+import { sanitizeFoomaticHtml } from "@/lib/foomatic/sanitize"
import type { DriverRecord } from "@/lib/foomatic/types"
interface DriverPageClientProps {
@@ -83,7 +84,7 @@ export default function DriverPageClient({ driverId }: DriverPageClientProps) {
setLoading(true)
setError(null)
- const response = await fetch(withBasePath(`/foomatic-db/drivers/${driverId}.json`))
+ const response = await fetch(withBasePath(`/foomatic-db/drivers/${encodeURIComponent(driverId)}.json`))
if (!response.ok) {
throw new Error("This driver entry could not be loaded.")
}
@@ -339,7 +340,7 @@ export default function DriverPageClient({ driverId }: DriverPageClientProps) {
Description
) : driver.shortDescription ? (
diff --git a/components/foomatic/PpdViewerClient.tsx b/components/foomatic/PpdViewerClient.tsx
index caf67246..4feec5ab 100644
--- a/components/foomatic/PpdViewerClient.tsx
+++ b/components/foomatic/PpdViewerClient.tsx
@@ -14,6 +14,7 @@ import { Button } from "@/components/ui/button"
import { withBasePath } from "@/lib/foomatic/base-path"
import { ppdFilePath } from "@/lib/foomatic/routes"
+// Restrict to known PPD output dirs and reject ".." to prevent path traversal.
function isValidPpdPath(path: string | null) {
return Boolean(path && (path.startsWith("/ppd/") || path.startsWith("/ppds/")) && !path.includes(".."))
}
diff --git a/components/foomatic/PrinterPageClient.tsx b/components/foomatic/PrinterPageClient.tsx
index 21d53e1f..970f40c6 100644
--- a/components/foomatic/PrinterPageClient.tsx
+++ b/components/foomatic/PrinterPageClient.tsx
@@ -21,8 +21,12 @@ import {
import { Button } from "@/components/ui/button"
import { withBasePath } from "@/lib/foomatic/base-path"
import { driverHref, ppdViewHref } from "@/lib/foomatic/routes"
+import { sanitizeFoomaticHtml } from "@/lib/foomatic/sanitize"
import type { Printer } from "@/lib/foomatic/types"
import { calculateAccurateStatus } from "@/lib/foomatic/utils"
+import RecommendedPrintersSection, {
+ SimilarPrintersTeaser,
+} from "@/components/foomatic/RecommendedPrintersSection"
interface PrinterPageClientProps {
printerId: string
@@ -30,19 +34,40 @@ interface PrinterPageClientProps {
function LoadingState() {
return (
-
-
-
-
-
-
-
-
- {Array.from({ length: 2 }).map((_, index) => (
-
-
-
-
+
+
+
+
+
+
+
+
+
+ {Array.from({ length: 2 }).map((_, index) => (
+
+
+
+
+
+ ))}
+
+
+
+
+
+ {Array.from({ length: 3 }).map((_, index) => (
+
+
))}
@@ -61,7 +86,7 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
setLoading(true)
setError(null)
- const response = await fetch(withBasePath(`/foomatic-db/printers/${printerId}.json`))
+ const response = await fetch(withBasePath(`/foomatic-db/printers/${encodeURIComponent(printerId)}.json`))
if (!response.ok) {
throw new Error("This printer entry could not be loaded.")
}
@@ -120,6 +145,12 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
}
const status = calculateAccurateStatus(printer)
+ const hasCapabilities =
+ (printer.color !== undefined && printer.color !== "unknown") ||
+ (printer.duplex !== undefined && printer.duplex !== "unknown") ||
+ printer.maxDpi != null ||
+ (printer.connectivity?.length ?? 0) > 0 ||
+ (printer.commandsets?.length ?? 0) > 0
const drivers = [...(printer.drivers ?? [])].sort((left, right) => {
if (left.id === printer.recommended_driver) return -1
if (right.id === printer.recommended_driver) return 1
@@ -170,19 +201,23 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
- {printer.recommended_driver ? (
-
-
- Recommended driver
-
-
- {printer.recommended_driver.replace(/^driver\//, "")}
-
-
- ) : null}
+
+ {printer.recommended_driver ? (
+
+
+ Recommended driver
+
+
+ {printer.recommended_driver.replace(/^driver\//, "")}
+
+
+ ) : null}
+
+
+
@@ -225,56 +260,66 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
- {printer.color !== undefined && printer.color !== "unknown" ? (
-
-
- Color
-
-
- {printer.color ? "Color output" : "Monochrome only"}
-
-
- ) : null}
- {printer.duplex !== undefined && printer.duplex !== "unknown" ? (
-
-
- Duplex
-
-
- {printer.duplex ? "Supported" : "Not supported"}
-
-
- ) : null}
- {printer.connectivity && printer.connectivity.length > 0 ? (
-
-
- Connectivity
-
-
- {printer.connectivity.map((item) => (
-
- {item}
-
- ))}
-
-
- ) : null}
- {printer.commandsets && printer.commandsets.length > 0 ? (
-
-
- Page description languages
-
-
- {printer.commandsets.map((item) => (
-
- {item}
-
- ))}
-
-
- ) : null}
+ {hasCapabilities ? (
+
+
+ Capabilities
+
+
+ {printer.color !== undefined && printer.color !== "unknown" ? (
+
+
Color
+
+ {printer.color ? "Color output" : "Monochrome only"}
+
+
+ ) : null}
+ {printer.duplex !== undefined && printer.duplex !== "unknown" ? (
+
+
Duplex
+
+ {printer.duplex ? "Supported" : "Not supported"}
+
+
+ ) : null}
+ {printer.maxDpi != null ? (
+
+
Max resolution
+ {printer.maxDpi} dpi
+
+ ) : null}
+ {printer.connectivity && printer.connectivity.length > 0 ? (
+
+
Connectivity
+
+ {printer.connectivity.map((item) => (
+
+ {item}
+
+ ))}
+
+
+ ) : null}
+ {printer.commandsets && printer.commandsets.length > 0 ? (
+
+
+ Page description languages
+
+
+ {printer.commandsets.map((item) => (
+
+ {item}
+
+ ))}
+
+
+ ) : null}
+
+
+ ) : null}
+
{printer.notes ? (
@@ -282,7 +327,7 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
) : null}
@@ -382,7 +427,9 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
@@ -410,6 +457,7 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
))}
+
)
diff --git a/components/foomatic/RecommendedPrintersSection.tsx b/components/foomatic/RecommendedPrintersSection.tsx
new file mode 100644
index 00000000..3ab411c0
--- /dev/null
+++ b/components/foomatic/RecommendedPrintersSection.tsx
@@ -0,0 +1,307 @@
+"use client"
+
+import { useEffect, useState } from "react"
+import Link from "next/link"
+import { ArrowDown } from "lucide-react"
+
+import {
+ FoomaticBadge,
+ FoomaticCard,
+ FoomaticStatusBadge,
+} from "@/components/foomatic/shared"
+import { withBasePath } from "@/lib/foomatic/base-path"
+import { printerHref } from "@/lib/foomatic/routes"
+import { confidenceTier } from "@/lib/foomatic/scoring"
+import type { ConfidenceTone } from "@/lib/foomatic/scoring"
+
+// Display fields are embedded in each per-printer recommendation shard by
+// compute-similarity.ts, so this section needs exactly one small fetch.
+interface Recommendation {
+ id: string
+ score: number
+ sharedFeatures: string[]
+ manufacturer?: string
+ model?: string
+ status: string
+ type: string
+}
+
+interface RecommendedPrintersSectionProps {
+ printerId: string
+}
+
+// One shard fetch per printer id, shared between the hero teaser and the full
+// section below so mounting both costs a single network request. A rejected
+// promise is evicted so the next mount retries instead of reusing the failure.
+const shardCache = new Map>()
+
+function getRecommendations(printerId: string): Promise {
+ let cached = shardCache.get(printerId)
+
+ if (!cached) {
+ cached = fetch(
+ withBasePath(`/foomatic-db/recommendations/${encodeURIComponent(printerId)}.json`)
+ )
+ .then((response) => (response.ok ? (response.json() as Promise) : []))
+ .catch((error) => {
+ shardCache.delete(printerId)
+ throw error
+ })
+
+ shardCache.set(printerId, cached)
+ }
+
+ return cached
+}
+
+const TONE_CLASSES: Record = {
+ high: "text-emerald-700 dark:text-emerald-400",
+ good: "text-sky-700 dark:text-sky-400",
+ moderate: "text-amber-700 dark:text-amber-400",
+ limited: "text-muted-foreground",
+}
+
+// The percentage is labelled "similarity": the score is not a probability that
+// the printer will work.
+function ConfidenceBadge({ score }: { score: number }) {
+ const tier = confidenceTier(score)
+
+ return (
+
+ {tier.label}
+
+ {Math.round(score * 100)}% similarity
+
+
+ )
+}
+
+function RecommendationSkeleton() {
+ return (
+
+
+
+ )
+}
+
+export function SimilarPrintersTeaser({ printerId }: RecommendedPrintersSectionProps) {
+ const [top, setTop] = useState(null)
+ const [count, setCount] = useState(0)
+
+ useEffect(() => {
+ let cancelled = false
+
+ getRecommendations(printerId)
+ .then((recs) => {
+ if (cancelled) return
+ setTop(recs[0] ?? null)
+ setCount(Math.min(recs.length, 3))
+ })
+ .catch(() => {
+ // Network failure: the full section reports it; the teaser stays hidden.
+ })
+
+ return () => {
+ cancelled = true
+ }
+ }, [printerId])
+
+ if (!top) {
+ return null
+ }
+
+ const tier = confidenceTier(top.score)
+
+ return (
+
+
+ Similar printers
+
+
+
+ {top.manufacturer ?? ""} {top.model ?? top.id}
+
+
+
+ {Math.round(top.score * 100)}% similarity ยท{" "}
+ {tier.label}
+
+
+
+ {count === 1
+ ? "1 printer with similar Linux driver support and hardware capabilities."
+ : `${count} printers with similar Linux driver support and hardware capabilities.`}
+
+
+
+ View all similar printers
+
+
+
+ )
+}
+
+export default function RecommendedPrintersSection({
+ printerId,
+}: RecommendedPrintersSectionProps) {
+ const [recommendations, setRecommendations] = useState([])
+ const [loading, setLoading] = useState(true)
+ const [hasRecommendations, setHasRecommendations] = useState(true)
+
+ useEffect(() => {
+ let cancelled = false
+
+ async function loadData() {
+ setLoading(true)
+
+ try {
+ const recs = await getRecommendations(printerId)
+
+ if (cancelled) {
+ return
+ }
+
+ setRecommendations(recs.slice(0, 3))
+ setHasRecommendations(recs.length > 0)
+ } catch (err) {
+ // A missing shard is handled by the !ok branch above, so reaching here
+ // means a network or parse failure worth surfacing to the console.
+ if (!cancelled) {
+ console.error("Failed to load recommendations:", err)
+ setHasRecommendations(false)
+ }
+ } finally {
+ if (!cancelled) {
+ setLoading(false)
+ }
+ }
+ }
+
+ loadData()
+
+ return () => {
+ cancelled = true
+ }
+ }, [printerId])
+
+ return (
+
+
+
+ Similar printers
+
+
+ Matched by Linux driver compatibility and shared hardware capabilities.
+
+
+
+
+ {loading ? (
+ <>
+
+
+
+ >
+ ) : !hasRecommendations ? (
+
+
+ No similar printers found in the database for this entry.
+
+
+ ) : (
+ recommendations.map((recommendation) => {
+ const model = recommendation.model ?? recommendation.id
+ const manufacturer = recommendation.manufacturer ?? ""
+
+ return (
+
+
+
+
+
{manufacturer}
+
{model}
+
+
+
+ {/* The candidate printer's own Foomatic support grade โ independent
+ of the similarity result shown on the right. */}
+
+ Linux support:
+
+
+
+ {recommendation.type !== "unknown" ? (
+
+ {recommendation.type}
+
+ ) : null}
+
+
+ {recommendation.sharedFeatures.length > 0 ? (
+
+
+ Why this printer?
+
+
+ {recommendation.sharedFeatures.map((feature) => (
+
+
+ {feature}
+
+
+ ))}
+
+
+ ) : null}
+
+
+
+
+
+
+ View printer
+
+
+
+
+ )
+ })
+ )}
+
+
+ )
+}
diff --git a/lib/foomatic/__tests__/driver-family.test.ts b/lib/foomatic/__tests__/driver-family.test.ts
new file mode 100644
index 00000000..c4177c79
--- /dev/null
+++ b/lib/foomatic/__tests__/driver-family.test.ts
@@ -0,0 +1,176 @@
+import { describe, expect, it } from "vitest"
+import {
+ normalizeDriverFamily,
+ getRecommendedDriverFamily,
+ getSupportedDriverFamilies,
+} from "../driver-family"
+import type { Printer } from "../types"
+
+describe("normalizeDriverFamily", () => {
+ it("collapses known driver name prefixes into families", () => {
+ expect(normalizeDriverFamily("Postscript-hp")).toBe("postscript")
+ expect(normalizeDriverFamily("hpijs-pcl5")).toBe("hpijs")
+ expect(normalizeDriverFamily("gimp-print-ijs")).toBe("gutenprint")
+ expect(normalizeDriverFamily("ljet4")).toBe("laserjet")
+ })
+
+ it("collapses the Ghostscript PCL variants onto one family", () => {
+ for (const name of ["ljet4", "ljet4d", "lj4dith", "lj5gray"]) {
+ expect(normalizeDriverFamily(name)).toBe("laserjet")
+ }
+ })
+
+ it("strips a driver/ id prefix before matching", () => {
+ expect(normalizeDriverFamily("driver/Postscript")).toBe("postscript")
+ })
+
+ it("lowercases unrecognized driver names instead of dropping them", () => {
+ expect(normalizeDriverFamily("SomeNewDriver")).toBe("somenewdriver")
+ })
+})
+
+describe("getRecommendedDriverFamily", () => {
+ it("normalizes the printer's recommended driver", () => {
+ const printer = { recommended_driver: "driver/hpijs-pcl5" } as Printer
+ expect(getRecommendedDriverFamily(printer)).toBe("hpijs")
+ })
+
+ it("returns null when there is no recommended driver", () => {
+ expect(getRecommendedDriverFamily({} as Printer)).toBeNull()
+ })
+
+ it("resolves an obsolete recommended driver to its declared replacement", () => {
+ const printer = {
+ recommended_driver: "driver/gimp-print",
+ drivers: [
+ { id: "driver/gimp-print", name: "gimp-print", obsolete: true, replacedBy: "gutenprint" },
+ ],
+ } as Printer
+
+ expect(getRecommendedDriverFamily(printer)).toBe("gutenprint")
+ })
+
+ it("never returns the obsolete family itself when a replacement exists", () => {
+ const printer = {
+ recommended_driver: "driver/hpdj",
+ drivers: [{ id: "driver/hpdj", name: "hpdj", obsolete: true, replacedBy: "pcl3" }],
+ } as Printer
+
+ expect(getRecommendedDriverFamily(printer)).not.toBe("hpdj")
+ expect(getRecommendedDriverFamily(printer)).toBe("pcl3")
+ })
+
+ it("contributes no preferred-driver evidence when an obsolete driver names no replacement", () => {
+ // No obsolete entry in the current database lacks a replacement; this guards
+ // the fallback if that ever changes.
+ const printer = {
+ recommended_driver: "driver/hpdj",
+ drivers: [{ id: "driver/hpdj", name: "hpdj", obsolete: true, replacedBy: null }],
+ } as Printer
+
+ expect(getRecommendedDriverFamily(printer)).toBeNull()
+ })
+
+ it("keeps a current recommended driver untouched", () => {
+ const printer = {
+ recommended_driver: "driver/gutenprint",
+ drivers: [
+ { id: "driver/gimp-print", name: "gimp-print", obsolete: true, replacedBy: "gutenprint" },
+ { id: "driver/gutenprint", name: "gutenprint", obsolete: false, replacedBy: null },
+ ],
+ } as Printer
+
+ expect(getRecommendedDriverFamily(printer)).toBe("gutenprint")
+ })
+
+ it("falls back to normalizing the id when the driver has no entry in the list", () => {
+ // Some printers reference a recommended driver whose own XML record is
+ // absent, so obsolescence cannot be determined and the reference stands.
+ const printer = {
+ recommended_driver: "driver/Postscript",
+ drivers: [{ id: "driver/other", name: "other", obsolete: false }],
+ } as Printer
+
+ expect(getRecommendedDriverFamily(printer)).toBe("postscript")
+ })
+})
+
+describe("getSupportedDriverFamilies", () => {
+ it("de-dupes driver families across the printer's driver list", () => {
+ const printer = {
+ drivers: [
+ { id: "driver/a", name: "Postscript-a" },
+ { id: "driver/b", name: "Postscript-b" },
+ { id: "driver/c", name: "hpijs" },
+ ],
+ } as Printer
+
+ expect(getSupportedDriverFamilies(printer).sort()).toEqual(["hpijs", "postscript"])
+ })
+
+ it("contributes the family of a current driver", () => {
+ const printer = {
+ drivers: [{ id: "driver/pcl3", name: "pcl3", obsolete: false }],
+ } as Printer
+
+ expect(getSupportedDriverFamilies(printer)).toEqual(["pcl3"])
+ })
+
+ it("excludes a family that is only reachable through an obsolete driver", () => {
+ const printer = {
+ drivers: [
+ { id: "driver/hpdj", name: "hpdj", obsolete: true, replacedBy: "pcl3" },
+ { id: "driver/hplip", name: "hplip", obsolete: false },
+ ],
+ } as Printer
+
+ expect(getSupportedDriverFamilies(printer)).toEqual(["hplip"])
+ })
+
+ it("keeps a family carried by both an obsolete and a current driver", () => {
+ const printer = {
+ drivers: [
+ { id: "driver/gimp-print", name: "gimp-print", obsolete: true, replacedBy: "gutenprint" },
+ { id: "driver/gutenprint", name: "gutenprint", obsolete: false },
+ ],
+ } as Printer
+
+ expect(getSupportedDriverFamilies(printer)).toEqual(["gutenprint"])
+ })
+
+ it("does not substitute the replacement driver into the supported set", () => {
+ // Supersession is not a support claim: the successor must not be invented
+ // for a printer foomatic-db never lists it against.
+ const printer = {
+ drivers: [{ id: "driver/hpdj", name: "hpdj", obsolete: true, replacedBy: "pcl3" }],
+ } as Printer
+
+ expect(getSupportedDriverFamilies(printer)).toEqual([])
+ })
+
+ it("excludes an obsolete driver that names no replacement", () => {
+ const printer = {
+ drivers: [
+ { id: "driver/legacy-driver", name: "legacy-driver", obsolete: true, replacedBy: null },
+ { id: "driver/live-driver", name: "live-driver", obsolete: false },
+ ],
+ } as Printer
+
+ expect(getSupportedDriverFamilies(printer)).toEqual(["live-driver"])
+ })
+
+ it("leaves no supported families when every driver is obsolete", () => {
+ const printer = {
+ drivers: [
+ { id: "driver/legacy-driver", name: "legacy-driver", obsolete: true, replacedBy: null },
+ { id: "driver/old-driver", name: "old-driver", obsolete: true, replacedBy: "live-driver" },
+ ],
+ } as Printer
+
+ expect(getSupportedDriverFamilies(printer)).toEqual([])
+ })
+
+ it("returns an empty array when the printer has no drivers", () => {
+ expect(getSupportedDriverFamilies({} as Printer)).toEqual([])
+ })
+})
diff --git a/lib/foomatic/__tests__/printer-attributes.test.ts b/lib/foomatic/__tests__/printer-attributes.test.ts
new file mode 100644
index 00000000..acfe2ebc
--- /dev/null
+++ b/lib/foomatic/__tests__/printer-attributes.test.ts
@@ -0,0 +1,213 @@
+import { describe, expect, it } from "vitest"
+import {
+ getFunctionalityStatus,
+ getPrinterType,
+ normalizeCommandsetToken,
+ getCommandsetTokens,
+ getBooleanCapability,
+ getColorCapability,
+ getMaxDpi,
+ getPSLevel,
+ getPCLLevel,
+ encodeFunctionality,
+} from "../printer-attributes"
+
+describe("getFunctionalityStatus", () => {
+ it("maps A to Perfect", () => {
+ expect(getFunctionalityStatus("A")).toBe("Perfect")
+ })
+
+ it("maps B and C to Mostly", () => {
+ expect(getFunctionalityStatus("B")).toBe("Mostly")
+ expect(getFunctionalityStatus("C")).toBe("Mostly")
+ })
+
+ it("maps missing or '?' functionality to Unknown", () => {
+ expect(getFunctionalityStatus(undefined)).toBe("Unknown")
+ expect(getFunctionalityStatus("?")).toBe("Unknown")
+ })
+
+ it("maps any other code to Unsupported", () => {
+ expect(getFunctionalityStatus("D")).toBe("Unsupported")
+ expect(getFunctionalityStatus("")).toBe("Unknown")
+ })
+})
+
+describe("encodeFunctionality", () => {
+ it("encodes the four functionality grades to their documented weights", () => {
+ expect(encodeFunctionality("A")).toBe(1.0)
+ expect(encodeFunctionality("B")).toBe(0.66)
+ expect(encodeFunctionality("C")).toBe(0.33)
+ expect(encodeFunctionality("D")).toBe(0.0)
+ })
+
+ it("is case-insensitive", () => {
+ expect(encodeFunctionality("a")).toBe(1.0)
+ })
+
+ it("defaults to 0 for missing values", () => {
+ expect(encodeFunctionality(undefined)).toBe(0.0)
+ })
+})
+
+describe("getPrinterType", () => {
+ it("detects inkjet from the mechanism.inkjet key", () => {
+ expect(getPrinterType({ mechanism: { inkjet: {} } })).toBe("inkjet")
+ })
+
+ it("detects laser from the mechanism.laser key", () => {
+ expect(getPrinterType({ mechanism: { laser: {} } })).toBe("laser")
+ })
+
+ it("detects dot-matrix from the mechanism.dotmatrix key", () => {
+ expect(getPrinterType({ mechanism: { dotmatrix: {} } })).toBe("dot-matrix")
+ })
+
+ it("falls back to transfer code 'i' for inkjet and 't' for laser", () => {
+ expect(getPrinterType({ mechanism: { transfer: "i" } })).toBe("inkjet")
+ expect(getPrinterType({ mechanism: { transfer: "t" } })).toBe("laser")
+ })
+
+ it("returns unknown when there is no mechanism data", () => {
+ expect(getPrinterType({})).toBe("unknown")
+ expect(getPrinterType({ mechanism: { transfer: "x" } })).toBe("unknown")
+ })
+})
+
+describe("normalizeCommandsetToken", () => {
+ it("folds PostScript variants to a single canonical token", () => {
+ expect(normalizeCommandsetToken("PostScript")).toBe("POSTSCRIPT")
+ expect(normalizeCommandsetToken("PS2")).toBe("POSTSCRIPT")
+ expect(normalizeCommandsetToken("Adobe PostScript")).toBe("POSTSCRIPT")
+ })
+
+ it("folds PCLXL variants to PCLXL", () => {
+ expect(normalizeCommandsetToken("PCL-XL")).toBe("PCLXL")
+ expect(normalizeCommandsetToken("PCL6")).toBe("PCLXL")
+ expect(normalizeCommandsetToken("HP ENHANCED PCL6")).toBe("PCLXL")
+ })
+
+ it("folds PCL5 variants to PCL5E", () => {
+ expect(normalizeCommandsetToken("PCL5e")).toBe("PCL5E")
+ expect(normalizeCommandsetToken("ENHANCED PCL5")).toBe("PCL5E")
+ })
+
+ it("discards noise tokens", () => {
+ expect(normalizeCommandsetToken("NONE")).toBeNull()
+ expect(normalizeCommandsetToken("RASTER")).toBeNull()
+ expect(normalizeCommandsetToken("")).toBeNull()
+ expect(normalizeCommandsetToken(" ")).toBeNull()
+ })
+
+ it("passes through unrecognized tokens uppercased", () => {
+ expect(normalizeCommandsetToken("escp2")).toBe("ESCP2")
+ })
+})
+
+describe("getCommandsetTokens", () => {
+ it("returns an empty array when there is no autodetect data", () => {
+ expect(getCommandsetTokens({})).toEqual([])
+ })
+
+ it("merges, normalizes, de-dupes, and sorts commandsets from multiple sources", () => {
+ const printer = {
+ autodetect: {
+ general: { commandset: "PostScript,PCL6" },
+ usb: { commandset: "PS2" },
+ parallel: { commandset: "PCL-XL" },
+ },
+ }
+
+ expect(getCommandsetTokens(printer)).toEqual(["PCLXL", "POSTSCRIPT"])
+ })
+
+ it("extracts commandsets embedded in an IEEE1284 device ID", () => {
+ const printer = {
+ autodetect: {
+ general: { ieee1284: "MFG:HP;MDL:LaserJet;CMD:PCL,PJL;" },
+ },
+ }
+
+ expect(getCommandsetTokens(printer)).toEqual(["PCL", "PJL"])
+ })
+})
+
+describe("getBooleanCapability", () => {
+ it("recognizes common truthy and falsy text values", () => {
+ expect(getBooleanCapability("yes")).toBe(true)
+ expect(getBooleanCapability("color")).toBe(true)
+ expect(getBooleanCapability("no")).toBe(false)
+ expect(getBooleanCapability("monochrome")).toBe(false)
+ })
+
+ it("passes through native booleans", () => {
+ expect(getBooleanCapability(true)).toBe(true)
+ expect(getBooleanCapability(false)).toBe(false)
+ })
+
+ it("returns 'unknown' for missing or unrecognized values", () => {
+ expect(getBooleanCapability(undefined)).toBe("unknown")
+ expect(getBooleanCapability("maybe")).toBe("unknown")
+ })
+})
+
+describe("getColorCapability", () => {
+ it("returns true when mechanism has a color key", () => {
+ expect(getColorCapability({ mechanism: { color: {} } })).toBe(true)
+ })
+
+ it("returns false when mechanism exists but has no color key", () => {
+ expect(getColorCapability({ mechanism: { laser: {} } })).toBe(false)
+ })
+
+ it("falls back to top-level color fields when there is no mechanism data", () => {
+ expect(getColorCapability({ color: "yes" })).toBe(true)
+ expect(getColorCapability({ colors: "no" })).toBe(false)
+ expect(getColorCapability({})).toBe("unknown")
+ })
+})
+
+describe("getMaxDpi", () => {
+ it("returns the larger of x/y resolution", () => {
+ expect(getMaxDpi({ mechanism: { resolution: { dpi: { x: 600, y: 1200 } } } })).toBe(1200)
+ })
+
+ it("returns null when there is no resolution data", () => {
+ expect(getMaxDpi({})).toBeNull()
+ })
+
+ it("returns null when resolution is zero", () => {
+ expect(getMaxDpi({ mechanism: { resolution: { dpi: { x: 0, y: 0 } } } })).toBeNull()
+ })
+})
+
+describe("getPSLevel", () => {
+ it("parses numeric and roman-numeral PostScript levels", () => {
+ expect(getPSLevel({ lang: { postscript: "3" } })).toBe(3)
+ expect(getPSLevel({ lang: { postscript: "II" } })).toBe(2)
+ expect(getPSLevel({ lang: { postscript: { level: "1" } } })).toBe(1)
+ })
+
+ it("returns 0 for unrecognized non-empty levels", () => {
+ expect(getPSLevel({ lang: { postscript: "weird" } })).toBe(0)
+ })
+
+ it("returns null when PostScript is not supported at all", () => {
+ expect(getPSLevel({})).toBeNull()
+ expect(getPSLevel({ lang: { postscript: "?" } })).toBeNull()
+ })
+})
+
+describe("getPCLLevel", () => {
+ it("detects PCL6", () => {
+ expect(getPCLLevel({ lang: { pcl: "6" } })).toBe(6)
+ })
+
+ it("detects PCL5 variants", () => {
+ expect(getPCLLevel({ lang: { pcl: "5e" } })).toBe(5)
+ })
+
+ it("returns null when PCL is not supported at all", () => {
+ expect(getPCLLevel({})).toBeNull()
+ })
+})
diff --git a/lib/foomatic/__tests__/sanitize.test.ts b/lib/foomatic/__tests__/sanitize.test.ts
new file mode 100644
index 00000000..7ea3447a
--- /dev/null
+++ b/lib/foomatic/__tests__/sanitize.test.ts
@@ -0,0 +1,86 @@
+import { describe, expect, it } from "vitest"
+import { isTrustedEmbedSrc } from "../sanitize"
+
+// The DOMPurify pipeline itself needs a real DOM and is exercised against the
+// built application (live payload injection on printer and driver pages).
+// The security-critical decision, which iframe origins survive, is this pure
+// function, so it is tested exhaustively here.
+
+describe("isTrustedEmbedSrc", () => {
+ it("accepts the real upstream Snapcraft embed URLs", () => {
+ expect(
+ isTrustedEmbedSrc(
+ "https://snapcraft.io/ghostscript-printer-app/embedded?button=black&summary=true"
+ )
+ ).toBe(true)
+ expect(isTrustedEmbedSrc("https://snapcraft.io/gutenprint-printer-app/embedded")).toBe(true)
+ expect(isTrustedEmbedSrc("https://snapcraft.io/ps-printer-app/embedded?button=black")).toBe(
+ true
+ )
+ })
+
+ it("accepts scheme/host case variants of the trusted origin", () => {
+ // URL parsing lowercases scheme and host; these are the same origin.
+ expect(isTrustedEmbedSrc("HTTPS://SNAPCRAFT.IO/ps-printer-app/embedded")).toBe(true)
+ })
+
+ it("rejects http downgrade of the trusted host", () => {
+ expect(isTrustedEmbedSrc("http://snapcraft.io/ghostscript-printer-app/embedded")).toBe(false)
+ })
+
+ it("rejects untrusted origins", () => {
+ expect(isTrustedEmbedSrc("https://evil.example/pwn")).toBe(false)
+ expect(isTrustedEmbedSrc("https://example.com/snapcraft.io/embedded")).toBe(false)
+ })
+
+ it("rejects lookalike and suffixed hosts", () => {
+ expect(isTrustedEmbedSrc("https://snapcraft.io.evil.com/embedded")).toBe(false)
+ expect(isTrustedEmbedSrc("https://evilsnapcraft.io/embedded")).toBe(false)
+ expect(isTrustedEmbedSrc("https://sub.snapcraft.io/embedded")).toBe(false)
+ })
+
+ it("rejects userinfo spoofing", () => {
+ // Everything before @ is userinfo; the real host is evil.com.
+ expect(isTrustedEmbedSrc("https://snapcraft.io@evil.com/embedded")).toBe(false)
+ expect(isTrustedEmbedSrc("https://snapcraft.io:pass@evil.com/embedded")).toBe(false)
+ })
+
+ it("rejects dangerous schemes", () => {
+ expect(isTrustedEmbedSrc("javascript:alert(1)")).toBe(false)
+ expect(isTrustedEmbedSrc("data:text/html,")).toBe(false)
+ expect(isTrustedEmbedSrc("vbscript:msgbox(1)")).toBe(false)
+ expect(isTrustedEmbedSrc("file:///etc/passwd")).toBe(false)
+ })
+
+ it("rejects relative and protocol-relative URLs", () => {
+ expect(isTrustedEmbedSrc("//snapcraft.io/embedded")).toBe(false)
+ expect(isTrustedEmbedSrc("/ghostscript-printer-app/embedded")).toBe(false)
+ expect(isTrustedEmbedSrc("embedded")).toBe(false)
+ })
+
+ it("rejects malformed and obfuscated inputs", () => {
+ expect(isTrustedEmbedSrc("")).toBe(false)
+ expect(isTrustedEmbedSrc("https://")).toBe(false)
+ // %2E does not decode to a dot in the host, so the real host is the
+ // evil.com suffix, not snapcraft.io.
+ expect(isTrustedEmbedSrc("https://snapcraft%2Eio.evil.com/x")).toBe(false)
+ // Tab injected into the scheme: not a valid https URL.
+ expect(isTrustedEmbedSrc("java\tscript:alert(1)")).toBe(false)
+ expect(isTrustedEmbedSrc("not a url at all")).toBe(false)
+ })
+
+ it("normalizes slash obfuscation to the true host", () => {
+ // Browsers and the WHATWG parser treat `/\` as `//` in special schemes,
+ // so this really is the snapcraft.io origin and is correctly trusted;
+ // the host, not the slash style, is what the check depends on.
+ expect(isTrustedEmbedSrc("https:/\\/snapcraft.io/embedded")).toBe(true)
+ // But the same normalization makes this evil.com, correctly rejected.
+ expect(isTrustedEmbedSrc("https:/\\/evil.com/snapcraft.io")).toBe(false)
+ })
+
+ it("rejects backslash host obfuscation", () => {
+ // In special schemes the URL parser treats \\ as /, so the host here is
+ // evil.com, not snapcraft.io.
+ expect(isTrustedEmbedSrc("https:\\\\evil.com\\snapcraft.io")).toBe(false)
+ })
+})
diff --git a/lib/foomatic/__tests__/scoring.test.ts b/lib/foomatic/__tests__/scoring.test.ts
new file mode 100644
index 00000000..ed71c91d
--- /dev/null
+++ b/lib/foomatic/__tests__/scoring.test.ts
@@ -0,0 +1,230 @@
+import { describe, expect, it } from "vitest"
+import {
+ COLOR_CONFLICT_PENALTY,
+ CONFIDENCE_GOOD_THRESHOLD,
+ CONFIDENCE_HIGH_THRESHOLD,
+ CONFIDENCE_MODERATE_THRESHOLD,
+ EVIDENCE_TAU,
+ MIN_SIMILARITY_SCORE,
+ RESOLUTION_CONFLICT_PENALTY,
+ RESOLUTION_CONFLICT_RATIO,
+ TYPE_CONFLICT_PENALTY,
+ confidenceTier,
+ conflictPenalty,
+ evidenceWeight,
+ overlapCount,
+ resolutionTier,
+ scoreThenIdComparator,
+} from "../scoring"
+import type { Printer } from "../types"
+
+const printer = (overrides: Partial): Printer =>
+ ({ id: "test", manufacturer: "Test", model: "T", ...overrides }) as Printer
+
+describe("evidenceWeight", () => {
+ it("gives zero confidence to zero overlap", () => {
+ expect(evidenceWeight(0)).toBe(0)
+ })
+
+ it("is strictly monotonic in the amount of evidence", () => {
+ let prev = evidenceWeight(0)
+ for (let k = 1; k <= 20; k++) {
+ const w = evidenceWeight(k)
+ expect(w).toBeGreaterThan(prev)
+ prev = w
+ }
+ })
+
+ it("never exceeds 1, and stays below 1 across the realistic evidence range", () => {
+ // In exact arithmetic 1 - exp(-k/tau) < 1 for all finite k, but in float64
+ // the difference underflows to exactly 1 once exp(-k/tau) < 2^-53. The
+ // guarantee that matters is: <= 1 always, and strictly < 1 for overlaps a
+ // real printer pair can produce (well past the 463-feature space).
+ expect(evidenceWeight(50)).toBeLessThan(1)
+ expect(evidenceWeight(10000)).toBeLessThanOrEqual(1)
+ })
+
+ it("matches the documented damping curve for tau", () => {
+ expect(evidenceWeight(EVIDENCE_TAU)).toBeCloseTo(1 - Math.exp(-1), 10)
+ })
+
+ it("keeps a single-signal pair below the score floor even at perfect cosine", () => {
+ expect(1.0 * evidenceWeight(1)).toBeLessThan(MIN_SIMILARITY_SCORE)
+ })
+
+ it("ranks a well-evidenced match above a thin one at equal cosine", () => {
+ expect(0.9 * evidenceWeight(8)).toBeGreaterThan(1.0 * evidenceWeight(1))
+ })
+})
+
+describe("overlapCount", () => {
+ it("returns zero when the vectors share no active dimension", () => {
+ expect(overlapCount([1, 0, 2], [0, 3, 0])).toBe(0)
+ })
+
+ it("counts only dimensions active in both vectors", () => {
+ expect(overlapCount([1, 2, 0, 4], [5, 0, 6, 7])).toBe(2)
+ })
+
+ it("ignores the magnitude of the weights", () => {
+ expect(overlapCount([0.001, 9], [100, 0.5])).toBe(2)
+ })
+})
+
+describe("conflictPenalty", () => {
+ it("is neutral when nothing conflicts", () => {
+ const a = printer({ type: "laser", color: true, maxDpi: 600 })
+ const b = printer({ type: "laser", color: true, maxDpi: 1200 })
+ expect(conflictPenalty(a, b)).toBe(1)
+ })
+
+ it("penalizes a known mechanism-type conflict", () => {
+ const a = printer({ type: "inkjet" })
+ const b = printer({ type: "laser" })
+ expect(conflictPenalty(a, b)).toBe(TYPE_CONFLICT_PENALTY)
+ })
+
+ it("does not penalize when either type is unknown", () => {
+ expect(conflictPenalty(printer({ type: "unknown" }), printer({ type: "laser" }))).toBe(1)
+ expect(conflictPenalty(printer({}), printer({ type: "laser" }))).toBe(1)
+ })
+
+ it("penalizes colour vs monochrome in both directions", () => {
+ expect(conflictPenalty(printer({ color: true }), printer({ color: false }))).toBe(
+ COLOR_CONFLICT_PENALTY
+ )
+ expect(conflictPenalty(printer({ color: false }), printer({ color: true }))).toBe(
+ COLOR_CONFLICT_PENALTY
+ )
+ })
+
+ it("treats unknown colour as no evidence, not as a conflict", () => {
+ expect(conflictPenalty(printer({ color: "unknown" }), printer({ color: true }))).toBe(1)
+ })
+
+ it("penalizes an extreme resolution gap but not a moderate one", () => {
+ const ratio = RESOLUTION_CONFLICT_RATIO
+ expect(conflictPenalty(printer({ maxDpi: 300 }), printer({ maxDpi: 300 * ratio }))).toBe(
+ RESOLUTION_CONFLICT_PENALTY
+ )
+ expect(
+ conflictPenalty(printer({ maxDpi: 300 }), printer({ maxDpi: 300 * ratio - 1 }))
+ ).toBe(1)
+ })
+
+ it("stacks independent conflicts multiplicatively", () => {
+ const a = printer({ type: "inkjet", color: true, maxDpi: 4800 })
+ const b = printer({ type: "laser", color: false, maxDpi: 300 })
+ expect(conflictPenalty(a, b)).toBeCloseTo(
+ TYPE_CONFLICT_PENALTY * COLOR_CONFLICT_PENALTY * RESOLUTION_CONFLICT_PENALTY,
+ 10
+ )
+ })
+
+ it("keeps the combined score inside [0, 1)", () => {
+ const a = printer({ type: "inkjet", color: true, maxDpi: 4800 })
+ const b = printer({ type: "laser", color: false, maxDpi: 300 })
+ const worst = 1.0 * evidenceWeight(500) * conflictPenalty(a, b)
+ expect(worst).toBeGreaterThan(0)
+ expect(worst).toBeLessThan(1)
+ })
+})
+
+describe("resolutionTier", () => {
+ it("returns null when resolution is unknown", () => {
+ expect(resolutionTier(null)).toBeNull()
+ expect(resolutionTier(undefined)).toBeNull()
+ })
+
+ it("assigns tier boundaries inclusively at the top of each range", () => {
+ expect(resolutionTier(300)).toBe("up to 300 dpi")
+ expect(resolutionTier(301)).toBe("300-600 dpi")
+ expect(resolutionTier(600)).toBe("300-600 dpi")
+ expect(resolutionTier(601)).toBe("600-1200 dpi")
+ expect(resolutionTier(1200)).toBe("600-1200 dpi")
+ expect(resolutionTier(1201)).toBe("over 1200 dpi")
+ })
+
+ it("never labels a tier with a bare exact figure", () => {
+ // Tier labels must always read as ranges, never as a specific capability
+ // claim about either printer.
+ for (const dpi of [120, 306, 720, 1440, 5760]) {
+ expect(resolutionTier(dpi)).toMatch(/^(up to|over|\d+-)/)
+ }
+ })
+})
+
+describe("confidenceTier", () => {
+ const LABELS = ["High confidence", "Good match", "Moderate match", "Limited evidence"]
+
+ it("assigns every representable score to exactly one tier", () => {
+ // Scores are rounded to 3 decimals upstream, so sweep that whole domain.
+ for (let i = 0; i <= 1000; i++) {
+ const tier = confidenceTier(i / 1000)
+ expect(LABELS).toContain(tier.label)
+ }
+ })
+
+ it("maps tier boundaries inclusively at the lower edge", () => {
+ expect(confidenceTier(CONFIDENCE_HIGH_THRESHOLD).label).toBe("High confidence")
+ expect(confidenceTier(CONFIDENCE_HIGH_THRESHOLD - 0.001).label).toBe("Good match")
+ expect(confidenceTier(CONFIDENCE_GOOD_THRESHOLD).label).toBe("Good match")
+ expect(confidenceTier(CONFIDENCE_GOOD_THRESHOLD - 0.001).label).toBe("Moderate match")
+ expect(confidenceTier(CONFIDENCE_MODERATE_THRESHOLD).label).toBe("Moderate match")
+ expect(confidenceTier(CONFIDENCE_MODERATE_THRESHOLD - 0.001).label).toBe("Limited evidence")
+ })
+
+ it("covers the observed score range without an unreachable tier", () => {
+ // Measured bounds of the generated artifacts: min 0.351, max 0.991.
+ expect(confidenceTier(0.351).label).toBe("Limited evidence")
+ expect(confidenceTier(0.991).label).toBe("High confidence")
+ // Even a hypothetical perfect score gets similarity wording, never a
+ // claim of exactness: the model measures similarity, not identity.
+ expect(confidenceTier(1.0).label).toBe("High confidence")
+ })
+
+ it("never labels any score as an exact match", () => {
+ for (let i = 0; i <= 1000; i++) {
+ expect(confidenceTier(i / 1000).label).not.toMatch(/exact/i)
+ }
+ })
+
+ it("keeps a single-signal pair out of every tier above Limited evidence", () => {
+ expect(1.0 * evidenceWeight(1)).toBeLessThan(CONFIDENCE_MODERATE_THRESHOLD)
+ expect(confidenceTier(1.0 * evidenceWeight(1)).label).toBe("Limited evidence")
+ })
+
+ it("is deterministic", () => {
+ for (const s of [0, 0.393, 0.5, 0.7, 0.85, 0.991]) {
+ expect(confidenceTier(s)).toEqual(confidenceTier(s))
+ }
+ })
+})
+
+describe("scoreThenIdComparator", () => {
+ const ids = ["Alps-MD-1000", "Canon-BJC-50", "Epson-LQ-570"]
+ const cmp = scoreThenIdComparator<{ index: number; score: number }>((i) => ids[i])
+
+ it("orders by score descending first", () => {
+ const sorted = [
+ { index: 0, score: 0.5 },
+ { index: 1, score: 0.9 },
+ ].sort(cmp)
+ expect(sorted.map((c) => c.index)).toEqual([1, 0])
+ })
+
+ it("breaks exact ties by id ascending, independent of input order", () => {
+ const a = [
+ { index: 2, score: 0.7 },
+ { index: 0, score: 0.7 },
+ { index: 1, score: 0.7 },
+ ].sort(cmp)
+ const b = [
+ { index: 1, score: 0.7 },
+ { index: 2, score: 0.7 },
+ { index: 0, score: 0.7 },
+ ].sort(cmp)
+ expect(a.map((c) => c.index)).toEqual([0, 1, 2])
+ expect(b.map((c) => c.index)).toEqual([0, 1, 2])
+ })
+})
diff --git a/lib/foomatic/__tests__/similarity-math.test.ts b/lib/foomatic/__tests__/similarity-math.test.ts
new file mode 100644
index 00000000..7a7aca89
--- /dev/null
+++ b/lib/foomatic/__tests__/similarity-math.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest"
+import { cosineSimilarity, dotProduct, magnitude, insertTopK } from "../similarity-math"
+import type { ScoredCandidate } from "../similarity-math"
+
+describe("cosineSimilarity", () => {
+ it("returns 1 for identical vectors", () => {
+ const a = [1, 2, 3]
+ expect(cosineSimilarity(a, a, magnitude(a), magnitude(a))).toBeCloseTo(1)
+ })
+
+ it("returns 0 for orthogonal vectors", () => {
+ const a = [1, 0]
+ const b = [0, 1]
+ expect(cosineSimilarity(a, b, magnitude(a), magnitude(b))).toBe(0)
+ })
+
+ it("returns 0 when either magnitude is 0, instead of dividing by zero", () => {
+ const a = [0, 0]
+ const b = [1, 1]
+ expect(cosineSimilarity(a, b, magnitude(a), magnitude(b))).toBe(0)
+ })
+
+ it("scales with the angle between two non-identical vectors", () => {
+ const a = [1, 1]
+ const b = [1, 0]
+ const score = cosineSimilarity(a, b, magnitude(a), magnitude(b))
+ expect(score).toBeGreaterThan(0)
+ expect(score).toBeLessThan(1)
+ })
+})
+
+describe("dotProduct / magnitude", () => {
+ it("computes the dot product element-wise", () => {
+ expect(dotProduct([1, 2, 3], [4, 5, 6])).toBe(32)
+ })
+
+ it("computes the Euclidean magnitude", () => {
+ expect(magnitude([3, 4])).toBe(5)
+ })
+})
+
+describe("insertTopK", () => {
+ it("keeps the list sorted ascending by score while under capacity", () => {
+ const topK: ScoredCandidate[] = []
+ insertTopK(topK, { index: 0, score: 0.5 }, 3)
+ insertTopK(topK, { index: 1, score: 0.2 }, 3)
+ insertTopK(topK, { index: 2, score: 0.8 }, 3)
+
+ expect(topK.map((c) => c.score)).toEqual([0.2, 0.5, 0.8])
+ })
+
+ it("evicts the lowest-scoring candidate once at capacity", () => {
+ const topK: ScoredCandidate[] = [
+ { index: 0, score: 0.1 },
+ { index: 1, score: 0.5 },
+ ]
+
+ insertTopK(topK, { index: 2, score: 0.9 }, 2)
+
+ expect(topK.map((c) => c.index)).toEqual([1, 2])
+ })
+
+ it("does not insert a candidate that scores below the current minimum once at capacity", () => {
+ const topK: ScoredCandidate[] = [
+ { index: 0, score: 0.4 },
+ { index: 1, score: 0.6 },
+ ]
+
+ insertTopK(topK, { index: 2, score: 0.1 }, 2)
+
+ expect(topK.map((c) => c.index)).toEqual([0, 1])
+ })
+})
diff --git a/lib/foomatic/__tests__/utils.test.ts b/lib/foomatic/__tests__/utils.test.ts
new file mode 100644
index 00000000..f63d4a58
--- /dev/null
+++ b/lib/foomatic/__tests__/utils.test.ts
@@ -0,0 +1,113 @@
+import { describe, expect, it } from "vitest"
+import { calculateAccurateStatus } from "../utils"
+import type { Printer, PrinterSummary } from "../types"
+
+describe("calculateAccurateStatus", () => {
+ it("maps grade A/Perfect to Perfect", () => {
+ expect(calculateAccurateStatus({ functionality: "A" } as PrinterSummary)).toBe("Perfect")
+ expect(calculateAccurateStatus({ functionality: "Perfect" } as PrinterSummary)).toBe("Perfect")
+ })
+
+ it("maps grades B/C/Good/Partial to Mostly", () => {
+ expect(calculateAccurateStatus({ functionality: "B" } as PrinterSummary)).toBe("Mostly")
+ expect(calculateAccurateStatus({ functionality: "C" } as PrinterSummary)).toBe("Mostly")
+ expect(calculateAccurateStatus({ functionality: "Good" } as PrinterSummary)).toBe("Mostly")
+ expect(calculateAccurateStatus({ functionality: "Partial" } as PrinterSummary)).toBe("Mostly")
+ })
+
+ it("is case-insensitive on the functionality grade", () => {
+ expect(calculateAccurateStatus({ functionality: "a" } as PrinterSummary)).toBe("Perfect")
+ })
+
+ it("treats missing/unknown functionality with no drivers as Unsupported", () => {
+ expect(calculateAccurateStatus({ functionality: "?", driverCount: 0 } as PrinterSummary)).toBe(
+ "Unsupported"
+ )
+ expect(
+ calculateAccurateStatus({ functionality: "unknown", driverCount: 0 } as PrinterSummary)
+ ).toBe("Unsupported")
+ })
+
+ it("treats missing/unknown functionality with drivers present as Unknown", () => {
+ expect(calculateAccurateStatus({ functionality: "?", driverCount: 2 } as PrinterSummary)).toBe(
+ "Unknown"
+ )
+ })
+
+ it("falls back to driver count when the functionality grade is unrecognized", () => {
+ expect(calculateAccurateStatus({ functionality: "X", driverCount: 0 } as PrinterSummary)).toBe(
+ "Unsupported"
+ )
+ expect(calculateAccurateStatus({ functionality: "X", driverCount: 1 } as PrinterSummary)).toBe(
+ "Unknown"
+ )
+ })
+
+ it("derives driver count from the full Printer.drivers array when driverCount is absent", () => {
+ const printer = {
+ status: "?",
+ drivers: [{ id: "driver/a", name: "a" }],
+ } as unknown as Printer
+
+ expect(calculateAccurateStatus(printer)).toBe("Unknown")
+ })
+
+ it("falls back to the Printer.status field when functionality is absent", () => {
+ const printer = { status: "A", drivers: [] } as unknown as Printer
+ expect(calculateAccurateStatus(printer)).toBe("Perfect")
+ })
+
+ it("does not count an obsolete driver as driver support", () => {
+ const printer = {
+ functionality: "?",
+ drivers: [
+ { id: "driver/legacy-driver", name: "legacy-driver", obsolete: true, replacedBy: null },
+ ],
+ } as unknown as Printer
+
+ expect(calculateAccurateStatus(printer)).toBe("Unsupported")
+ })
+
+ it("keeps a printer out of Unsupported while one current driver remains", () => {
+ const printer = {
+ functionality: "?",
+ drivers: [
+ { id: "driver/legacy-driver", name: "legacy-driver", obsolete: true, replacedBy: null },
+ { id: "driver/live-driver", name: "live-driver", obsolete: false },
+ ],
+ } as unknown as Printer
+
+ expect(calculateAccurateStatus(printer)).toBe("Unknown")
+ })
+
+ it("never overwrites a recorded functionality grade when every driver is obsolete", () => {
+ const drivers = [
+ { id: "driver/legacy-driver", name: "legacy-driver", obsolete: true, replacedBy: null },
+ ]
+
+ expect(calculateAccurateStatus({ functionality: "A", drivers } as unknown as Printer)).toBe(
+ "Perfect"
+ )
+ expect(calculateAccurateStatus({ functionality: "B", drivers } as unknown as Printer)).toBe(
+ "Mostly"
+ )
+ })
+
+ it("honours the stored status for summaries, which cannot see obsolescence", () => {
+ // printersMap.json carries a driver total but not each driver's obsolete
+ // flag, so a summary defers to the status the pipeline already derived.
+ const summary = {
+ functionality: "?",
+ driverCount: 1,
+ status: "Unsupported",
+ } as PrinterSummary
+
+ expect(calculateAccurateStatus(summary)).toBe("Unsupported")
+ })
+
+ it("still reports Unknown for a summary the pipeline did not mark unsupported", () => {
+ const summary = { functionality: "?", driverCount: 1, status: "Unknown" } as PrinterSummary
+
+ expect(calculateAccurateStatus(summary)).toBe("Unknown")
+ })
+})
diff --git a/lib/foomatic/driver-family.ts b/lib/foomatic/driver-family.ts
new file mode 100644
index 00000000..a8825865
--- /dev/null
+++ b/lib/foomatic/driver-family.ts
@@ -0,0 +1,94 @@
+// Driver-name normalization shared by the vectorization and similarity stages.
+// Upstream driver entries name the same underlying driver family in many ways
+// (Postscript-hp, gimp-print-ijs, ljet4, ...), so names are collapsed onto a
+// canonical family before they are used as similarity features.
+//
+// A shared family is compatibility evidence; the number of entries a printer
+// accumulates is not, and is never used as a signal.
+
+import type { Driver, Printer } from "./types"
+
+// Add new entries here to fold another upstream driver naming variant onto an
+// existing family. Order matters: the first matching pattern wins.
+const DRIVER_PREFIX_NORMALIZERS: Array<[RegExp, string]> = [
+ [/^Postscript/i, "postscript"],
+ [/^PDF/i, "pdf"],
+ [/^pxlmono/i, "pxlmono"],
+ [/^pxlcolor/i, "pxlcolor"],
+ [/^foo2zjs/i, "foo2zjs"],
+ [/^foo2hp/i, "foo2hp"],
+ [/^foo2qpdl/i, "foo2qpdl"],
+ [/^hpijs/i, "hpijs"],
+ [/^gutenprint/i, "gutenprint"],
+ [/^gimp-print/i, "gutenprint"],
+ [/^hplip/i, "hplip"],
+ [/^ljet/i, "laserjet"],
+ [/^lj/i, "laserjet"],
+]
+
+export function trim(value: string | undefined): string {
+ return (value ?? "").trim()
+}
+
+export function normalizeDriverFamily(driverName: string): string {
+ const normalized = trim(driverName).replace(/^driver\//i, "")
+
+ for (const [pattern, family] of DRIVER_PREFIX_NORMALIZERS) {
+ if (pattern.test(normalized)) {
+ return family
+ }
+ }
+
+ return normalized.toLowerCase()
+}
+
+// foomatic-db marks superseded drivers ` `. Such entries
+// are still listed on the printer and driver pages, but must not count as
+// evidence that two printers are currently compatible.
+function isObsolete(driver: Driver): boolean {
+ return driver.obsolete === true
+}
+
+export function getRecommendedDriverFamily(printer: Printer): string | null {
+ const recommended = trim(printer.recommended_driver)
+
+ if (!recommended) {
+ return null
+ }
+
+ const entry = (printer.drivers ?? []).find((driver) => driver.id === recommended)
+
+ if (entry && isObsolete(entry)) {
+ // Only the replacement named in `replacedBy` is substituted; none is ever
+ // inferred. Without a named successor there is nothing to fall back to, so
+ // the printer contributes no preferred-driver evidence rather than an
+ // obsolete one.
+ const replacement = trim(entry.replacedBy ?? undefined)
+
+ return replacement ? normalizeDriverFamily(replacement) : null
+ }
+
+ return normalizeDriverFamily(recommended)
+}
+
+// Families reachable through at least one current driver entry; a family
+// carried by both an obsolete and a current driver survives. Replacements are
+// not added here: foomatic-db records that a driver is superseded, not that the
+// successor supports this particular printer.
+export function getSupportedDriverFamilies(printer: Printer): string[] {
+ const families = new Set()
+
+ for (const driver of printer.drivers ?? []) {
+ if (isObsolete(driver)) {
+ continue
+ }
+
+ const family = normalizeDriverFamily(driver.name)
+
+ if (family) {
+ families.add(family)
+ }
+ }
+
+ return [...families]
+}
diff --git a/lib/foomatic/printer-attributes.ts b/lib/foomatic/printer-attributes.ts
new file mode 100644
index 00000000..aac5a0ed
--- /dev/null
+++ b/lib/foomatic/printer-attributes.ts
@@ -0,0 +1,246 @@
+// Pure helpers for deriving normalized printer attributes from raw
+// foomatic-db XML (already parsed to JSON). Shared between
+// scripts/foomatic/combine-data.ts, the vectorization stage, and their tests.
+
+// Raw printer XML, already parsed to JSON by fast-xml-parser. Shape varies
+// freely per upstream entry and is accessed via deep optional-chained
+// property paths below, so it is intentionally untyped at this boundary.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type RawXmlNode = any
+
+export function getText(value: unknown): string | undefined {
+ if (value === undefined || value === null) {
+ return undefined
+ }
+
+ if (typeof value === "string") {
+ return value.trim() || undefined
+ }
+
+ if (typeof value === "number" || typeof value === "boolean") {
+ return String(value)
+ }
+
+ if (Array.isArray(value)) {
+ const text = value.map(getText).filter(Boolean).join(", ").trim()
+ return text || undefined
+ }
+
+ if (typeof value === "object") {
+ const obj = value as Record
+
+ if (typeof obj.en === "string") {
+ return obj.en.trim() || undefined
+ }
+
+ if (typeof obj["#text"] === "string") {
+ return obj["#text"].trim() || undefined
+ }
+
+ for (const key of Object.keys(obj)) {
+ const nested = getText(obj[key])
+ if (nested) {
+ return nested
+ }
+ }
+ }
+
+ return undefined
+}
+
+export function getFunctionalityStatus(func: string | undefined): string {
+ if (!func || func === "?") {
+ return "Unknown"
+ }
+
+ switch (func) {
+ case "A":
+ return "Perfect"
+ case "B":
+ case "C":
+ return "Mostly"
+ default:
+ return "Unsupported"
+ }
+}
+
+export function getPrinterType(printer: RawXmlNode): string {
+ if (!printer.mechanism) {
+ return "unknown"
+ }
+
+ const mechanism = printer.mechanism
+
+ if (mechanism.inkjet !== undefined) {
+ return "inkjet"
+ }
+
+ if (mechanism.laser !== undefined) {
+ return "laser"
+ }
+
+ if (mechanism.dotmatrix !== undefined) {
+ return "dot-matrix"
+ }
+
+ if (mechanism.transfer === "i") {
+ return "inkjet"
+ }
+
+ if (mechanism.transfer === "t") {
+ return "laser"
+ }
+
+ return "unknown"
+}
+
+export function normalizeCommandsetToken(raw: string): string | null {
+ const t = raw.trim()
+ if (!t) return null
+ const u = t.toUpperCase()
+ if (
+ /^(POSTSCRIPT\d*|ADOBE\s+POSTSCRIPT|ADOBE\s+LEVEL\s+\d+\s+POSTSCRIPT|PS\d?|POSTS$|POSTSCRIP$|POSTSCRI$|POSTSCRIPT\s+EMULATION|POSTSCRIPT\s+LEVEL|POSTSCRIPT\s+LE$|POSTSCRIPT\s+LEV$)/.test(
+ u
+ )
+ )
+ return "POSTSCRIPT"
+ if (/^(PCLXL|PCXL|PCL-XL|PCL6|PCL 6 EMULATION|HP ENHANCED PCL6)$/.test(u)) return "PCLXL"
+ if (/^(PCL5[CE]?\d*|HP ENHANCED PCL5[E]?|ENHANCED PCL5|PCL 5 EMULATION)$/.test(u))
+ return "PCL5E"
+ if (/^(DW-PCL)$/.test(u)) return "PCL"
+ if (
+ /^(NONE|NA|P$|LPT1|1284\.4|DW-$|AUTOMATIC|DOWNLOAD|RASTER|GDI;MDL|PRINTGEAR;PCL;PLJ)$/.test(
+ u
+ )
+ )
+ return null
+ return u
+}
+
+// Normalized autodetect command-set tokens (POSTSCRIPT, PCLXL, ...). These are
+// the machine-comparable tokens used as similarity features, as opposed to the
+// human-readable `commandsets` labels shown in the UI.
+export function getCommandsetTokens(printer: RawXmlNode): string[] {
+ const a = printer.autodetect
+ if (!a) return []
+
+ const rawTokens: string[] = []
+
+ const pushCommaSplit = (val: unknown) => {
+ if (!val) return
+ for (const t of String(val).split(",")) rawTokens.push(t.trim())
+ }
+
+ pushCommaSplit(a.general?.commandset)
+ pushCommaSplit(a.usb?.commandset)
+ pushCommaSplit(a.parallel?.commandset)
+
+ if (a.general?.ieee1284) {
+ const m = String(a.general.ieee1284).match(/CMD:([^;]+)/i)
+ if (m) pushCommaSplit(m[1])
+ }
+
+ const seen = new Set()
+ const result: string[] = []
+ for (const raw of rawTokens) {
+ const norm = normalizeCommandsetToken(raw)
+ if (norm && !seen.has(norm)) {
+ seen.add(norm)
+ result.push(norm)
+ }
+ }
+ return result.sort()
+}
+
+export function getBooleanCapability(value: unknown): boolean | "unknown" {
+ if (value === undefined || value === null) {
+ return "unknown"
+ }
+
+ if (typeof value === "boolean") {
+ return value
+ }
+
+ const text = getText(value)?.toLowerCase()
+ if (!text) {
+ return "unknown"
+ }
+
+ if (["1", "true", "yes", "y", "color", "duplex"].includes(text)) {
+ return true
+ }
+
+ if (["0", "false", "no", "n", "mono", "monochrome", "simplex"].includes(text)) {
+ return false
+ }
+
+ return "unknown"
+}
+
+export function getColorCapability(printer: RawXmlNode): boolean | "unknown" {
+ if (printer.mechanism && "color" in printer.mechanism) {
+ return true
+ }
+
+ if (printer.mechanism && Object.keys(printer.mechanism).length > 0) {
+ return false
+ }
+
+ return getBooleanCapability(
+ printer.color ?? printer.colors ?? printer.colorDevice ?? printer.capabilities?.color
+ )
+}
+
+export function getDuplexCapability(printer: RawXmlNode): boolean | "unknown" {
+ return getBooleanCapability(
+ printer.duplex ?? printer.duplexer ?? printer.capabilities?.duplex
+ )
+}
+
+export function getMaxDpi(printer: RawXmlNode): number | null {
+ const dpi = printer.mechanism?.resolution?.dpi
+ if (!dpi) return null
+ const x = Number(dpi.x ?? dpi["@x"] ?? 0)
+ const y = Number(dpi.y ?? dpi["@y"] ?? 0)
+ const max = Math.max(x, y)
+ return max > 0 ? max : null
+}
+
+export function getPSLevel(printer: RawXmlNode): number | null {
+ const ps = printer.lang?.postscript
+ if (ps === undefined) return null
+ const raw = typeof ps === "object" && ps !== null ? ps["@level"] ?? ps.level ?? "" : String(ps)
+ const s = String(raw).trim()
+ if (!s || s === "?") return null
+ if (["3", "III", "3.0"].includes(s)) return 3
+ if (["2", "II"].includes(s)) return 2
+ if (["1", "I"].includes(s)) return 1
+ return 0
+}
+
+export function getPCLLevel(printer: RawXmlNode): number | null {
+ const pcl = printer.lang?.pcl
+ if (pcl === undefined) return null
+ const raw =
+ typeof pcl === "object" && pcl !== null ? pcl["@level"] ?? pcl.level ?? "" : String(pcl)
+ const s = String(raw).trim()
+ if (!s || s === "?") return null
+ if (/^6|\/6$|,\s*6$|^6\//i.test(s)) return 6
+ if (/5[eEcC]/.test(s) || /^5/.test(s)) return 5
+ if (/^4/.test(s)) return 4
+ if (/^3/.test(s)) return 3
+ return 0
+}
+
+export function encodeFunctionality(value: string | undefined): number {
+ switch ((value ?? "").toUpperCase()) {
+ case "A":
+ return 1.0
+ case "B":
+ return 0.66
+ case "C":
+ return 0.33
+ default:
+ return 0.0
+ }
+}
diff --git a/lib/foomatic/sanitize.ts b/lib/foomatic/sanitize.ts
new file mode 100644
index 00000000..200b084b
--- /dev/null
+++ b/lib/foomatic/sanitize.ts
@@ -0,0 +1,86 @@
+"use client"
+
+import DOMPurify from "dompurify"
+
+// foomatic-db XML accepts external contributions and intentionally embeds
+// HTML in comments/notes fields, so it must be sanitized before rendering.
+const ALLOWED_TAGS = [
+ "a",
+ "b",
+ "strong",
+ "i",
+ "em",
+ "u",
+ "br",
+ "p",
+ "ul",
+ "ol",
+ "li",
+ "code",
+ "span",
+ // Only for the trusted Snapcraft store embeds; every other iframe is
+ // removed by the uponSanitizeElement hook below.
+ "iframe",
+]
+
+// width/height/frameborder are inert on the non-iframe elements above. The
+// upstream embeds' cosmetic `style` attribute is deliberately NOT allowed:
+// a global style allowance would weaken sanitization of every element.
+const ALLOWED_ATTR = ["href", "title", "target", "rel", "src", "width", "height", "frameborder"]
+
+// Upstream driver comments embed Snapcraft store cards for the printer
+// applications (e.g. https://snapcraft.io/ghostscript-printer-app/embedded),
+// the only iframes allowed to survive sanitization.
+//
+// Security invariant: only HTTPS URLs with the exact snapcraft.io hostname are
+// allowed. WHATWG URL parsing fails closed on everything else (see the bypass
+// cases in sanitize.test.ts).
+export function isTrustedEmbedSrc(src: string): boolean {
+ let url: URL
+
+ try {
+ url = new URL(src)
+ } catch {
+ return false
+ }
+
+ return url.protocol === "https:" && url.hostname === "snapcraft.io"
+}
+
+let hooksRegistered = false
+
+function registerHooks(): void {
+ if (hooksRegistered) return
+ hooksRegistered = true
+
+ DOMPurify.addHook("uponSanitizeElement", (node, data) => {
+ if (data.tagName !== "iframe") return
+
+ const element = node as Element
+ const src =
+ typeof element.getAttribute === "function" ? element.getAttribute("src") : null
+
+ if (!src || !isTrustedEmbedSrc(src)) {
+ node.parentNode?.removeChild(node)
+ }
+ })
+
+ // Harden the surviving trusted embeds beyond what upstream ships.
+ DOMPurify.addHook("afterSanitizeAttributes", (node) => {
+ if (node.tagName === "IFRAME") {
+ node.setAttribute("sandbox", "allow-scripts allow-same-origin allow-popups")
+ node.setAttribute("loading", "lazy")
+ node.setAttribute("referrerpolicy", "no-referrer")
+ }
+ })
+}
+
+export function sanitizeFoomaticHtml(html: string): string {
+ registerHooks()
+
+ return DOMPurify.sanitize(html, {
+ ALLOWED_TAGS,
+ ALLOWED_ATTR,
+ ALLOW_DATA_ATTR: false,
+ })
+}
diff --git a/lib/foomatic/scoring.ts b/lib/foomatic/scoring.ts
new file mode 100644
index 00000000..dc24c2a7
--- /dev/null
+++ b/lib/foomatic/scoring.ts
@@ -0,0 +1,122 @@
+// Scoring model for the recommendation engine, shared between
+// scripts/foomatic/compute-similarity.ts and its test suite.
+//
+// The published score is not a bare cosine:
+//
+// score(a, b) = cos(a, b) * (1 - exp(-k / EVIDENCE_TAU)) * conflictPenalty(a, b)
+//
+// where k counts feature dimensions on which both printers are non-zero.
+// Each factor exists because a measured failure mode demanded it โ see
+// docs/foomatic-recommendation-quality.md for the evidence.
+
+import type { Printer } from "./types"
+
+// Cosine on sparse one-hot vectors saturates at 1.0 whenever both vectors are
+// nearly empty: two printers that share a single dimension and have no other
+// known attributes are geometrically identical, yet that is almost no evidence.
+// tau = 4 maps overlap 1 -> 0.22, 4 -> 0.63, 8 -> 0.86, 12 -> 0.95.
+export const EVIDENCE_TAU = 4
+
+// Floor applied to the fully-damped score. Because evidenceWeight(1) ~= 0.22,
+// any pair resting on a single shared dimension falls below this automatically,
+// which is what removes "recommended because both use a catch-all driver".
+export const MIN_SIMILARITY_SCORE = 0.35
+
+// The feature vector only rewards agreement and cannot express that two
+// capabilities actively conflict: a mono printer sharing a driver family with a
+// colour one is not a substitute for it. These multiplicative penalties encode
+// hard substitution barriers.
+export const TYPE_CONFLICT_PENALTY = 0.5
+export const COLOR_CONFLICT_PENALTY = 0.6
+export const RESOLUTION_CONFLICT_PENALTY = 0.7
+export const RESOLUTION_CONFLICT_RATIO = 4
+
+export function evidenceWeight(overlap: number): number {
+ return 1 - Math.exp(-overlap / EVIDENCE_TAU)
+}
+
+export function overlapCount(a: number[], b: number[]): number {
+ let n = 0
+ for (let i = 0; i < a.length; i++) {
+ if (a[i] !== 0 && b[i] !== 0) n++
+ }
+ return n
+}
+
+export function conflictPenalty(a: Printer, b: Printer): number {
+ let penalty = 1
+
+ if (
+ a.type &&
+ b.type &&
+ a.type !== "unknown" &&
+ b.type !== "unknown" &&
+ a.type !== b.type
+ ) {
+ penalty *= TYPE_CONFLICT_PENALTY
+ }
+
+ if (
+ (a.color === true && b.color === false) ||
+ (a.color === false && b.color === true)
+ ) {
+ penalty *= COLOR_CONFLICT_PENALTY
+ }
+
+ if (a.maxDpi != null && b.maxDpi != null && a.maxDpi > 0 && b.maxDpi > 0) {
+ const ratio = Math.max(a.maxDpi, b.maxDpi) / Math.min(a.maxDpi, b.maxDpi)
+
+ if (ratio >= RESOLUTION_CONFLICT_RATIO) {
+ penalty *= RESOLUTION_CONFLICT_PENALTY
+ }
+ }
+
+ return penalty
+}
+
+// Resolution explanations name the tier's range, never a specific figure, so
+// the UI cannot claim a DPI that neither printer actually has.
+export function resolutionTier(dpi: number | null | undefined): string | null {
+ if (dpi == null) return null
+ if (dpi <= 300) return "up to 300 dpi"
+ if (dpi <= 600) return "300-600 dpi"
+ if (dpi <= 1200) return "600-1200 dpi"
+ return "over 1200 dpi"
+}
+
+// User-facing interpretation of the final score. The score is an engineered
+// similarity value (IDF-weighted cosine, damped by evidence, penalized for
+// capability conflicts) โ it is not a probability and not a validated
+// compatibility guarantee, so tier wording deliberately avoids implying either.
+//
+// Thresholds sit at the midpoints between the mean scores of the observed
+// evidence bands, so each tier corresponds to a real difference in supporting
+// evidence rather than to a cosmetic share of the distribution. The measured
+// bands are recorded in docs/foomatic-recommendation-quality.md.
+export const CONFIDENCE_HIGH_THRESHOLD = 0.85
+export const CONFIDENCE_GOOD_THRESHOLD = 0.7
+export const CONFIDENCE_MODERATE_THRESHOLD = 0.5
+
+export type ConfidenceTone = "high" | "good" | "moderate" | "limited"
+
+export interface ConfidenceTier {
+ label: string
+ tone: ConfidenceTone
+}
+
+export function confidenceTier(score: number): ConfidenceTier {
+ if (score >= CONFIDENCE_HIGH_THRESHOLD) return { label: "High confidence", tone: "high" }
+ if (score >= CONFIDENCE_GOOD_THRESHOLD) return { label: "Good match", tone: "good" }
+ if (score >= CONFIDENCE_MODERATE_THRESHOLD) return { label: "Moderate match", tone: "moderate" }
+ return { label: "Limited evidence", tone: "limited" }
+}
+
+// Candidates with byte-identical feature vectors are genuinely
+// indistinguishable given the recorded data, so ties are broken by id to keep
+// output deterministic across runs and platforms rather than pretending to a
+// ranking the data cannot support.
+export function scoreThenIdComparator(
+ idOf: (index: number) => string
+): (x: T, y: T) => number {
+ return (x, y) => y.score - x.score || idOf(x.index).localeCompare(idOf(y.index))
+}
diff --git a/lib/foomatic/similarity-math.ts b/lib/foomatic/similarity-math.ts
new file mode 100644
index 00000000..4a998f9c
--- /dev/null
+++ b/lib/foomatic/similarity-math.ts
@@ -0,0 +1,54 @@
+// Pure vector maths shared by the vectorization and similarity stages of the
+// recommendation pipeline. Kept dependency-free so it can be unit tested
+// without touching the filesystem or generated artifacts.
+
+export function dotProduct(a: number[], b: number[]): number {
+ let sum = 0
+
+ for (let i = 0; i < a.length; i++) {
+ sum += a[i] * b[i]
+ }
+
+ return sum
+}
+
+export function magnitude(vec: number[]): number {
+ return Math.sqrt(dotProduct(vec, vec))
+}
+
+export function cosineSimilarity(
+ a: number[],
+ b: number[],
+ magA: number,
+ magB: number
+): number {
+ if (magA === 0 || magB === 0) {
+ return 0
+ }
+
+ return dotProduct(a, b) / (magA * magB)
+}
+
+export interface ScoredCandidate {
+ index: number
+ score: number
+}
+
+// Maintains `topK` as a min-heap-like array sorted ascending by score, so the
+// weakest surviving candidate is always at index 0 and cheap to evict.
+export function insertTopK(
+ topK: ScoredCandidate[],
+ candidate: ScoredCandidate,
+ k: number
+): void {
+ if (topK.length < k) {
+ topK.push(candidate)
+ topK.sort((a, b) => a.score - b.score)
+ return
+ }
+
+ if (candidate.score > topK[0].score) {
+ topK[0] = candidate
+ topK.sort((a, b) => a.score - b.score)
+ }
+}
diff --git a/lib/foomatic/types.ts b/lib/foomatic/types.ts
index 8c50991e..3e039122 100644
--- a/lib/foomatic/types.ts
+++ b/lib/foomatic/types.ts
@@ -45,11 +45,15 @@ export interface Printer {
ppdPath?: string
supportContacts?: SupportContact[]
commandsets?: string[]
+ commandsetTokens?: string[]
ppdOptions?: PpdOption[]
color?: boolean | "unknown"
duplex?: boolean | "unknown"
recommended?: boolean
hasOwnEntry?: boolean
+ psLevel?: number | null
+ pclLevel?: number | null
+ maxDpi?: number | null
}
export type PrinterStatus = 'Perfect' | 'Mostly' | 'Unsupported' | 'Unknown'
@@ -66,6 +70,7 @@ export interface PrinterSummary {
status?: string
driverCount?: number
functionality?: string
+ color?: boolean | "unknown"
}
export interface DriverPrinterRef {
diff --git a/lib/foomatic/utils.ts b/lib/foomatic/utils.ts
index f93a0679..6253e85d 100644
--- a/lib/foomatic/utils.ts
+++ b/lib/foomatic/utils.ts
@@ -12,18 +12,22 @@ export function calculateAccurateStatus(
const functionality = typeof rawFunctionality === "string" ? rawFunctionality : undefined
- const driverCount =
- "driverCount" in printer
- ? (printer as PrinterSummary).driverCount
- : "drivers" in printer
- ? Array.isArray((printer as Printer).drivers)
- ? (printer as Printer).drivers!.length
- : 0
- : 0
+ // A driver the database marks obsolete cannot be used, so it is not driver
+ // support. Full printer records carry the driver list and can be counted
+ // directly; the summary projection only carries a total and cannot tell the
+ // two apart, which is why its precomputed status is preferred below.
+ const drivers = (printer as Printer).drivers
+ const usableDriverCount = Array.isArray(drivers)
+ ? drivers.filter((driver) => !driver.obsolete).length
+ : ((printer as PrinterSummary).driverCount ?? 0)
+
+ const noUsableDrivers = (): boolean =>
+ usableDriverCount === 0 ||
+ (!Array.isArray(drivers) && (printer as PrinterSummary).status === "Unsupported")
if (!functionality || functionality === "?" || functionality === "unknown") {
- if (driverCount === 0) {
+ if (noUsableDrivers()) {
return "Unsupported"
}
return "Unknown"
@@ -49,7 +53,7 @@ export function calculateAccurateStatus(
return "Unsupported"
default:
- if (driverCount === 0) {
+ if (noUsableDrivers()) {
return "Unsupported"
}
return "Unknown"
diff --git a/package.json b/package.json
index b124cf10..e3f1b451 100644
--- a/package.json
+++ b/package.json
@@ -9,7 +9,16 @@
"build": "yarn generate && next build && tsx scripts/foomatic/generate-legacy-redirects.ts && tsx scripts/generate-trailing-slash-aliases.ts",
"verify:urls": "tsx scripts/verify-urls.ts",
"start": "next start",
- "lint": "next lint"
+ "lint": "next lint",
+ "test": "vitest run",
+ "foomatic:pipeline": "tsx scripts/foomatic/data-generate.ts",
+ "foomatic:generate:xml": "tsx scripts/foomatic/generate-from-xml.ts",
+ "foomatic:generate:ppds": "bash scripts/foomatic/generate-ppds.sh",
+ "foomatic:data:combine": "tsx scripts/foomatic/combine-data.ts",
+ "foomatic:data:split": "tsx scripts/foomatic/split-printers.ts && tsx scripts/foomatic/split-drivers.ts",
+ "foomatic:data:vectorize": "tsx scripts/foomatic/vectorize.ts",
+ "foomatic:data:similarity": "tsx scripts/foomatic/compute-similarity.ts",
+ "foomatic:eval": "node tools/eval/metrics.mjs && node tools/eval/check-docs.mjs && node tools/eval/grade.mjs"
},
"dependencies": {
"@giscus/react": "^3.1.0",
@@ -17,6 +26,7 @@
"@tailwindcss/typography": "^0.5.16",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
+ "dompurify": "^3.4.13",
"fast-xml-parser": "^5.7.0",
"framer-motion": "^12.5.0",
"github-slugger": "^2.0.0",
@@ -52,6 +62,7 @@
"postcss": "^8",
"tailwindcss": "^3.4.1",
"tsx": "^4.21.0",
- "typescript": "^5"
+ "typescript": "^5",
+ "vitest": "^4.1.10"
}
}
diff --git a/scripts/foomatic/combine-data.ts b/scripts/foomatic/combine-data.ts
index e614baab..1cf8b67f 100644
--- a/scripts/foomatic/combine-data.ts
+++ b/scripts/foomatic/combine-data.ts
@@ -2,6 +2,17 @@
import fs from "fs";
import path from "path";
import { fileURLToPath } from "url";
+import {
+ getText,
+ getFunctionalityStatus,
+ getPrinterType,
+ getCommandsetTokens,
+ getColorCapability,
+ getDuplexCapability,
+ getMaxDpi,
+ getPSLevel,
+ getPCLLevel,
+} from "../../lib/foomatic/printer-attributes";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -20,90 +31,6 @@ function toArray(value) {
return Array.isArray(value) ? value : [value];
}
-function getText(value) {
- if (value === undefined || value === null) {
- return undefined;
- }
-
- if (typeof value === "string") {
- return value.trim() || undefined;
- }
-
- if (typeof value === "number" || typeof value === "boolean") {
- return String(value);
- }
-
- if (Array.isArray(value)) {
- const text = value.map(getText).filter(Boolean).join(", ").trim();
- return text || undefined;
- }
-
- if (typeof value === "object") {
- if (typeof value.en === "string") {
- return value.en.trim() || undefined;
- }
-
- if (typeof value["#text"] === "string") {
- return value["#text"].trim() || undefined;
- }
-
- for (const key of Object.keys(value)) {
- const nested = getText(value[key]);
- if (nested) {
- return nested;
- }
- }
- }
-
- return undefined;
-}
-
-function getFunctionalityStatus(func) {
- if (!func || func === "?") {
- return "Unknown";
- }
-
- switch (func) {
- case "A":
- return "Perfect";
- case "B":
- case "C":
- return "Mostly";
- default:
- return "Unsupported";
- }
-}
-
-function getPrinterType(printer) {
- if (!printer.mechanism) {
- return "unknown";
- }
-
- const mechanism = printer.mechanism;
-
- if (mechanism.inkjet !== undefined) {
- return "inkjet";
- }
-
- if (mechanism.laser !== undefined) {
- return "laser";
- }
-
- if (mechanism.dotmatrix !== undefined) {
- return "dot-matrix";
- }
-
- if (mechanism.transfer === "i") {
- return "inkjet";
- }
-
- if (mechanism.transfer === "t") {
- return "laser";
- }
-
- return "unknown";
-}
-
function parseConnectivity(printer) {
const connectivity = [];
if (!printer.autodetect) {
@@ -267,48 +194,6 @@ function getSupportContacts(printer) {
.filter(Boolean);
}
-function getBooleanCapability(value) {
- if (value === undefined || value === null) {
- return "unknown";
- }
-
- if (typeof value === "boolean") {
- return value;
- }
-
- const text = getText(value)?.toLowerCase();
- if (!text) {
- return "unknown";
- }
-
- if (["1", "true", "yes", "y", "color", "duplex"].includes(text)) {
- return true;
- }
-
- if (["0", "false", "no", "n", "mono", "monochrome", "simplex"].includes(text)) {
- return false;
- }
-
- return "unknown";
-}
-
-function getColorCapability(printer) {
- return getBooleanCapability(
- printer.color ??
- printer.colors ??
- printer.colorDevice ??
- printer.capabilities?.color
- );
-}
-
-function getDuplexCapability(printer) {
- return getBooleanCapability(
- printer.duplex ??
- printer.duplexer ??
- printer.capabilities?.duplex
- );
-}
-
function buildPpdFileName(printerId, driverId) {
return `${normalizePrinterId(printerId)}-${driverId.replace(/^driver\//, "")}.ppd`;
}
@@ -494,7 +379,12 @@ async function combineData() {
const functionality = getText(printer.functionality) || "?";
const driverDetails = buildDriverDetails(printerId, driverIds, drivers, generatedPpdPaths);
const status = getFunctionalityStatus(functionality);
- const finalStatus = driverDetails.length === 0 && status === "Unknown" ? "Unsupported" : status;
+ // Drivers the database marks obsolete cannot be used, so a printer left with
+ // only obsolete entries has no driver support. Where its support level is
+ // otherwise unknown that makes it unsupported; a graded status is never
+ // overwritten.
+ const usableDrivers = driverDetails.filter((driver) => !driver.obsolete);
+ const finalStatus = usableDrivers.length === 0 && status === "Unknown" ? "Unsupported" : status;
const recommendedDriverWithPpd =
driverDetails.find((driver) => driver.id === recommendedDriverId && driver.hasPpd) ||
driverDetails.find((driver) => driver.hasPpd);
@@ -515,9 +405,13 @@ async function combineData() {
...(recommendedDriverWithPpd?.ppdPath ? { ppdPath: recommendedDriverWithPpd.ppdPath } : {}),
supportContacts: getSupportContacts(printer),
commandsets: getCommandsets(printer),
+ commandsetTokens: getCommandsetTokens(printer),
ppdOptions: getPpdOptions(printer),
color: getColorCapability(printer),
duplex: getDuplexCapability(printer),
+ psLevel: getPSLevel(printer),
+ pclLevel: getPCLLevel(printer),
+ maxDpi: getMaxDpi(printer),
recommended: Boolean(printer.driver || recommendedDriverId),
hasOwnEntry: printersWithOwnEntry.has(printerId),
});
diff --git a/scripts/foomatic/compute-similarity.ts b/scripts/foomatic/compute-similarity.ts
new file mode 100644
index 00000000..c8482721
--- /dev/null
+++ b/scripts/foomatic/compute-similarity.ts
@@ -0,0 +1,480 @@
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+import type { Printer } from "../../lib/foomatic/types";
+import {
+ getRecommendedDriverFamily,
+ getSupportedDriverFamilies,
+} from "../../lib/foomatic/driver-family";
+import {
+ cosineSimilarity,
+ magnitude,
+ insertTopK,
+} from "../../lib/foomatic/similarity-math";
+import type { ScoredCandidate } from "../../lib/foomatic/similarity-math";
+import {
+ MIN_SIMILARITY_SCORE,
+ conflictPenalty,
+ evidenceWeight,
+ overlapCount,
+ resolutionTier,
+ scoreThenIdComparator,
+} from "../../lib/foomatic/scoring";
+
+const ROOT_DIR = path.join(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+ "..",
+);
+
+// Build-time intermediates live outside public/: the feature matrix and the
+// combined recommendation map are inputs to this script and to tools/eval, and
+// nothing in the browser fetches either, so shipping them in the static export
+// would add ~50 MB to GitHub Pages for no runtime benefit.
+const MATRIX_FILE = path.join(
+ ROOT_DIR,
+ "cache",
+ "foomatic",
+ "feature-matrix.json",
+);
+
+const PRINTERS_FILE = path.join(
+ ROOT_DIR,
+ "public",
+ "foomatic-db",
+ "printers.json",
+);
+
+const OUTPUT_FILE = path.join(
+ ROOT_DIR,
+ "cache",
+ "foomatic",
+ "recommendations.json",
+);
+
+// The per-printer shards are the only client-facing output of this script, so
+// they alone belong under public/.
+const RECOMMENDATIONS_DIR = path.join(
+ ROOT_DIR,
+ "public",
+ "foomatic-db",
+ "recommendations",
+);
+
+const TOP_K = 10;
+
+interface FeatureMatrix {
+ printerCount: number;
+ featureCount: number;
+ featureNames: string[];
+ ids: string[];
+ matrix: number[][];
+}
+
+interface Recommendation {
+ id: string;
+ score: number;
+ sharedFeatures: string[];
+}
+
+interface RecommendationMap {
+ [printerId: string]: Recommendation[];
+}
+
+interface Output {
+ version: string;
+ printerCount: number;
+ topK: number;
+ recommendations: RecommendationMap;
+}
+
+function computeSharedFeatures(a: Printer, b: Printer): string[] {
+ const shared: string[] = [];
+
+ const aRecommended = getRecommendedDriverFamily(a);
+ const bRecommended = getRecommendedDriverFamily(b);
+
+ if (aRecommended && bRecommended && aRecommended === bRecommended) {
+ shared.push(`Preferred Linux driver: ${aRecommended}`);
+ }
+
+ const aSupported = new Set(getSupportedDriverFamilies(a));
+
+ const commonDrivers = getSupportedDriverFamilies(b)
+ .filter((driver) => aSupported.has(driver))
+ .slice(0, 3);
+
+ for (const driver of commonDrivers) {
+ if (driver !== aRecommended) {
+ shared.push(`Shared driver family: ${driver}`);
+ }
+ }
+
+ if (a.type && b.type && a.type !== "unknown" && a.type === b.type) {
+ const label: Record = {
+ laser: "Laser printer",
+ inkjet: "Inkjet printer",
+ "dot-matrix": "Dot-matrix printer",
+ };
+
+ shared.push(label[a.type] ?? a.type);
+ }
+
+ if (a.color === true && b.color === true) {
+ shared.push("Color printing");
+ }
+
+ const aCommandsets = new Set(a.commandsetTokens ?? []);
+ const COMMANDSET_LABELS: Record = {
+ POSTSCRIPT: "PostScript",
+ PCLXL: "PCL XL (PCL6)",
+ PCL5E: "PCL5e",
+ PCL: "PCL",
+ PDF: "PDF printing",
+ ESCPL2: "Epson ESC/P2",
+ ESCPR2: "Epson ESC/P-R",
+ BDC: "Epson BDC",
+ D4: "Epson D4",
+ D4PX: "Epson D4PX",
+ PJL: "PJL",
+ MLC: "MLC",
+ };
+ for (const cs of (b.commandsetTokens ?? [])) {
+ if (aCommandsets.has(cs)) {
+ const label = COMMANDSET_LABELS[cs] ?? cs;
+ shared.push(`Shared command set: ${label}`);
+ }
+ }
+
+ if (a.psLevel != null && b.psLevel != null && a.psLevel === b.psLevel) {
+ const psLabel: Record = { 3: "PostScript 3", 2: "PostScript 2", 1: "PostScript 1" };
+ shared.push(psLabel[a.psLevel] ?? "PostScript");
+ }
+
+ if (a.pclLevel != null && b.pclLevel != null && a.pclLevel === b.pclLevel) {
+ const pclLabel: Record = { 6: "PCL 6 / PCL XL", 5: "PCL 5e", 4: "PCL 4", 3: "PCL 3" };
+ shared.push(pclLabel[a.pclLevel] ?? "PCL");
+ }
+
+ const aTier = resolutionTier(a.maxDpi);
+ const bTier = resolutionTier(b.maxDpi);
+ if (aTier != null && aTier === bTier) {
+ shared.push(`Similar resolution (${aTier})`);
+ }
+
+ if (
+ a.functionality &&
+ b.functionality &&
+ a.functionality === b.functionality
+ ) {
+ const label: Record = {
+ A: "Excellent Linux driver support",
+ B: "Good Linux driver support",
+ C: "Basic Linux driver support",
+ };
+
+ if (label[a.functionality]) {
+ shared.push(label[a.functionality]);
+ }
+ }
+
+ return [...new Set(shared)];
+}
+
+function buildRecommendation(
+ target: Printer,
+ candidate: Printer,
+ score: number,
+): Recommendation {
+ return {
+ id: candidate.id,
+ score: Number(score.toFixed(3)),
+ sharedFeatures: computeSharedFeatures(target, candidate),
+ };
+}
+
+function logScoreDistribution(recommendations: RecommendationMap): void {
+ const allScores = Object.values(recommendations)
+ .flat()
+ .map((r) => r.score)
+ .sort((a, b) => a - b);
+
+ const p = (pct: number): string =>
+ allScores[Math.floor(allScores.length * pct)].toFixed(3);
+
+ console.log("\nScore distribution across all recommendations:");
+ console.log(` min : ${allScores[0].toFixed(3)}`);
+ console.log(` p10 : ${p(0.1)}`);
+ console.log(` p25 : ${p(0.25)}`);
+ console.log(` p50 : ${p(0.5)}`);
+ console.log(` p75 : ${p(0.75)}`);
+ console.log(` p90 : ${p(0.9)}`);
+ console.log(` max : ${allScores[allScores.length - 1].toFixed(3)}`);
+}
+
+function logSpotCheck(
+ recommendations: RecommendationMap,
+ printerMap: Map,
+): void {
+ const targets = [
+ "HP-2000C",
+ "Canon-i560",
+ "Gestetner-DSc445",
+ "Epson-LQ-570",
+ ];
+
+ console.log("\nSpot-checks:");
+
+ for (const id of targets) {
+ const printer = printerMap.get(id);
+
+ if (!printer) {
+ continue;
+ }
+
+ console.log(`\n ${printer.id} โ ${printer.manufacturer} ${printer.type}`);
+
+ const recs = recommendations[id] ?? [];
+
+ for (const [index, rec] of recs.slice(0, 3).entries()) {
+ const candidate = printerMap.get(rec.id);
+
+ console.log(` ${index + 1}. ${rec.id}`);
+
+ console.log(` score : ${rec.score}`);
+
+ console.log(
+ ` type : ${candidate?.type ?? "unknown"} | manufacturer: ${candidate?.manufacturer ?? "unknown"}`,
+ );
+
+ console.log(
+ ` shared : ${rec.sharedFeatures.length > 0 ? rec.sharedFeatures.join(", ") : "none"}`,
+ );
+ }
+ }
+}
+
+// A handful of upstream printer ids differ only by letter case. On a
+// case-insensitive filesystem (Windows, and macOS by default) the later shard
+// overwrites the earlier one, so those printers would show the wrong
+// recommendations locally. CI builds on Linux, where every id gets its own
+// file, so this is a local-development caveat rather than a production bug.
+function warnOnCaseInsensitiveCollisions(printerIds: string[]): void {
+ const seen = new Map();
+ const collisions: Array<[string, string]> = [];
+
+ for (const id of printerIds) {
+ const key = id.toLowerCase();
+ const previous = seen.get(key);
+
+ if (previous) {
+ collisions.push([previous, id]);
+ } else {
+ seen.set(key, id);
+ }
+ }
+
+ if (collisions.length === 0) {
+ return;
+ }
+
+ const written = fs.readdirSync(RECOMMENDATIONS_DIR).length;
+
+ if (written === printerIds.length) {
+ return;
+ }
+
+ console.warn(
+ `\n! ${collisions.length} printer id(s) differ only by case and collapsed on this filesystem:`,
+ );
+
+ for (const [a, b] of collisions) {
+ console.warn(` ${a} <-> ${b}`);
+ }
+
+ console.warn(
+ ` ${written}/${printerIds.length} shards written. Linux/CI builds are unaffected.`,
+ );
+}
+
+function loadFeatureMatrix(): FeatureMatrix {
+ if (!fs.existsSync(MATRIX_FILE)) {
+ throw new Error(
+ `Missing feature matrix: ${MATRIX_FILE}\n` +
+ `Run: yarn foomatic:data:vectorize`,
+ );
+ }
+
+ return JSON.parse(fs.readFileSync(MATRIX_FILE, "utf-8"));
+}
+
+function loadPrinters(): Printer[] {
+ if (!fs.existsSync(PRINTERS_FILE)) {
+ throw new Error(
+ `Missing printers.json: ${PRINTERS_FILE}\n` +
+ `Run: yarn foomatic:generate:xml && yarn foomatic:data:combine`,
+ );
+ }
+
+ const raw = JSON.parse(fs.readFileSync(PRINTERS_FILE, "utf-8"));
+
+ return raw.printers;
+}
+
+function main(): void {
+ const start = performance.now();
+
+ console.log("Loading feature matrix...");
+
+ const matrixData = loadFeatureMatrix();
+
+ console.log(` Printers : ${matrixData.printerCount}`);
+
+ console.log(` Features : ${matrixData.featureCount}`);
+
+ console.log("Loading printer metadata...");
+
+ const printers = loadPrinters();
+
+ const printerMap = new Map(printers.map((p) => [p.id, p]));
+
+ console.log("Pre-computing magnitudes...");
+
+ const magnitudes = matrixData.matrix.map(magnitude);
+
+ const recommendations: RecommendationMap = {};
+
+ console.log(`Computing top-${TOP_K} similarities...`);
+
+ for (let i = 0; i < matrixData.printerCount; i++) {
+ const vecA = matrixData.matrix[i];
+ const magA = magnitudes[i];
+
+ const topK: ScoredCandidate[] = [];
+
+ for (let j = 0; j < matrixData.printerCount; j++) {
+ if (i === j) {
+ continue;
+ }
+
+ const vecB = matrixData.matrix[j];
+ const magB = magnitudes[j];
+
+ const cosine = cosineSimilarity(vecA, vecB, magA, magB);
+
+ if (cosine < MIN_SIMILARITY_SCORE) {
+ continue;
+ }
+
+ const targetPrinter = printerMap.get(matrixData.ids[i]);
+ const candidatePrinter = printerMap.get(matrixData.ids[j]);
+
+ const penalty =
+ targetPrinter && candidatePrinter
+ ? conflictPenalty(targetPrinter, candidatePrinter)
+ : 1;
+
+ const score =
+ cosine * evidenceWeight(overlapCount(vecA, vecB)) * penalty;
+
+ if (score < MIN_SIMILARITY_SCORE) {
+ continue;
+ }
+
+ insertTopK(
+ topK,
+ {
+ index: j,
+ score,
+ },
+ TOP_K,
+ );
+ }
+
+ topK.sort(scoreThenIdComparator((index) => matrixData.ids[index]));
+
+ const printerId = matrixData.ids[i];
+
+ recommendations[printerId] = topK.map(({ index, score }) => {
+ const target = printerMap.get(printerId);
+
+ const candidate = printerMap.get(matrixData.ids[index]);
+
+ if (!target || !candidate) {
+ throw new Error(
+ "Printer lookup failed during recommendation generation",
+ );
+ }
+
+ return buildRecommendation(target, candidate, score);
+ });
+
+ if ((i + 1) % 1000 === 0) {
+ const elapsed = ((performance.now() - start) / 1000).toFixed(1);
+
+ console.log(
+ ` ${i + 1}/${matrixData.printerCount} โ ${elapsed}s elapsed`,
+ );
+ }
+ }
+
+ const output: Output = {
+ version: "2.0.0",
+ printerCount: matrixData.printerCount,
+ topK: TOP_K,
+ recommendations,
+ };
+
+ fs.mkdirSync(path.dirname(OUTPUT_FILE), {
+ recursive: true,
+ });
+
+ fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2));
+
+ fs.mkdirSync(RECOMMENDATIONS_DIR, { recursive: true });
+
+ // Each per-printer shard embeds the handful of display fields the UI needs
+ // for its cards, so the printer page renders recommendations without also
+ // downloading the much larger printersMap.json.
+ for (const [printerId, recs] of Object.entries(output.recommendations)) {
+ const enriched = recs.map((rec) => {
+ const candidate = printerMap.get(rec.id);
+
+ // Status and type defaults mirror split-printers.ts. A driver count is
+ // deliberately not carried: the number of entries is not a measure of
+ // support quality, so the cards cannot present it as one.
+ return {
+ ...rec,
+ manufacturer: candidate?.manufacturer,
+ model: candidate?.model,
+ status: candidate?.status || "Unknown",
+ type: candidate?.type || "unknown",
+ };
+ });
+
+ fs.writeFileSync(
+ path.join(RECOMMENDATIONS_DIR, `${printerId}.json`),
+ JSON.stringify(enriched),
+ );
+ }
+
+ warnOnCaseInsensitiveCollisions(Object.keys(output.recommendations));
+
+ logScoreDistribution(recommendations);
+
+ logSpotCheck(recommendations, printerMap);
+
+ const runtime = ((performance.now() - start) / 1000).toFixed(1);
+
+ console.log(`\nโ Recommendations written to ${OUTPUT_FILE}`);
+
+ console.log(
+ ` File size : ${(fs.statSync(OUTPUT_FILE).size / 1024 / 1024).toFixed(2)} MB`,
+ );
+
+ console.log(` Runtime : ${runtime}s`);
+ console.log(` Printers : ${matrixData.printerCount}`);
+ console.log(` Top-K : ${TOP_K}`);
+}
+
+main();
diff --git a/scripts/foomatic/data-generate.ts b/scripts/foomatic/data-generate.ts
index 41ad442d..637e9680 100644
--- a/scripts/foomatic/data-generate.ts
+++ b/scripts/foomatic/data-generate.ts
@@ -2,12 +2,30 @@ import { spawnSync } from "child_process";
const forwardedArgs = process.argv.slice(2);
const skipPpd = forwardedArgs.includes("--skip-ppd");
+const skipSimilarity =
+ forwardedArgs.includes("--skip-similarity") ||
+ process.env.FOOMATIC_SKIP_SIMILARITY === "1";
+
+// generate-ppds.sh is driven through /bin/bash, which does not resolve on
+// Windows, so the step is dropped there instead of failing the whole run.
+const canRunPpds = process.platform !== "win32";
+
const steps: Array<[string, string[]]> = [
["scripts/foomatic/generate-from-xml.ts", []],
- ["scripts/foomatic/generate-ppds.sh", skipPpd ? ["--skip-ppd"] : []],
+ ...(canRunPpds
+ ? ([
+ ["scripts/foomatic/generate-ppds.sh", skipPpd ? ["--skip-ppd"] : []],
+ ] as Array<[string, string[]]>)
+ : []),
["scripts/foomatic/combine-data.ts", forwardedArgs],
["scripts/foomatic/split-printers.ts", []],
["scripts/foomatic/split-drivers.ts", []],
+ ...(skipSimilarity
+ ? []
+ : ([
+ ["scripts/foomatic/vectorize.ts", []],
+ ["scripts/foomatic/compute-similarity.ts", []],
+ ] as Array<[string, string[]]>)),
];
for (const [scriptPath, args] of steps) {
@@ -17,6 +35,7 @@ for (const [scriptPath, args] of steps) {
: [scriptPath, ...args];
const result = spawnSync(command, commandArgs, {
stdio: "inherit",
+ shell: true,
});
if (result.status !== 0) {
diff --git a/scripts/foomatic/split-printers.ts b/scripts/foomatic/split-printers.ts
index e8f8f382..b2638dcb 100644
--- a/scripts/foomatic/split-printers.ts
+++ b/scripts/foomatic/split-printers.ts
@@ -14,6 +14,7 @@ type PrinterRecord = {
status?: string
functionality?: string
drivers?: unknown[]
+ color?: boolean | "unknown"
}
type PrintersPayload = {
@@ -40,7 +41,8 @@ async function splitPrintersData() {
type: printer.type || 'unknown',
status: printer.status || 'Unknown',
functionality: printer.functionality || '?',
- driverCount: printer.drivers ? printer.drivers.length : 0
+ driverCount: printer.drivers ? printer.drivers.length : 0,
+ color: printer.color ?? 'unknown',
}))
}
const mapPath = path.join(ROOT_DIR, 'public', 'foomatic-db', 'printersMap.json')
diff --git a/scripts/foomatic/vectorize.ts b/scripts/foomatic/vectorize.ts
new file mode 100644
index 00000000..8051e233
--- /dev/null
+++ b/scripts/foomatic/vectorize.ts
@@ -0,0 +1,283 @@
+import fs from "fs";
+import path from "path";
+import { fileURLToPath } from "url";
+import type { Printer } from "../../lib/foomatic/types";
+import {
+ trim,
+ getRecommendedDriverFamily,
+ getSupportedDriverFamilies,
+} from "../../lib/foomatic/driver-family";
+import { encodeFunctionality } from "../../lib/foomatic/printer-attributes";
+
+const ROOT_DIR = path.join(
+ path.dirname(fileURLToPath(import.meta.url)),
+ "..",
+ "..",
+);
+
+const INPUT_FILE = path.join(
+ ROOT_DIR,
+ "public",
+ "foomatic-db",
+ "printers.json",
+);
+
+// Build-time intermediate: read back only by compute-similarity.ts and never
+// fetched by the site, so it is written outside public/ to keep it out of the
+// static export.
+const OUTPUT_FILE = path.join(
+ ROOT_DIR,
+ "cache",
+ "foomatic",
+ "feature-matrix.json",
+);
+
+interface Vocabulary {
+ recommendedDrivers: string[];
+ supportedDrivers: string[];
+ types: string[];
+ commandsets: string[];
+ // Inverse-document-frequency scale per vocabulary term, normalized to mean 1
+ // so overall feature-group balance is preserved while rare terms outweigh
+ // ubiquitous ones. Sharing "postscript" (1746 printers) is weak evidence;
+ // sharing "necp6" (8 printers) is strong evidence.
+ idf: {
+ recommendedDrivers: number[];
+ supportedDrivers: number[];
+ commandsets: number[];
+ };
+}
+
+// idf(t) = ln(1 + N / df(t)), rescaled so the mean across the vocabulary is 1.
+function idfScale(terms: string[], df: Map, total: number): number[] {
+ const raw = terms.map((t) => Math.log(1 + total / Math.max(1, df.get(t) ?? 1)));
+ const mean = raw.reduce((a, b) => a + b, 0) / (raw.length || 1);
+ return mean > 0 ? raw.map((v) => v / mean) : raw.map(() => 1);
+}
+
+interface FeatureMatrix {
+ printerCount: number;
+ featureCount: number;
+ featureNames: string[];
+ vocab: Vocabulary;
+ ids: string[];
+ matrix: number[][];
+}
+
+const RECOMMENDED_DRIVER_WEIGHT = 3.0;
+const SUPPORTED_DRIVER_WEIGHT = 1.0;
+const TYPE_WEIGHT = 0.5;
+const FUNCTIONALITY_WEIGHT = 0.25;
+const COLOR_WEIGHT = 1.0;
+const COMMANDSET_WEIGHT = 1.5;
+const MIN_COMMANDSET_FREQUENCY = 20;
+const LANG_WEIGHT = 1.0;
+const LANG_LEVEL_WEIGHT = 0.5;
+const RESOLUTION_WEIGHT = 0.75;
+
+function buildVocabularies(printers: Printer[]): Vocabulary {
+ const recommendedDrivers = new Set();
+
+ const supportedDrivers = new Set();
+
+ const types = new Set();
+
+ const commandsetFreq = new Map();
+
+ const recommendedFreq = new Map();
+
+ const supportedFreq = new Map();
+
+ for (const printer of printers) {
+ const recommended = getRecommendedDriverFamily(printer);
+
+ if (recommended) {
+ recommendedDrivers.add(recommended);
+ recommendedFreq.set(recommended, (recommendedFreq.get(recommended) ?? 0) + 1);
+ }
+
+ for (const family of getSupportedDriverFamilies(printer)) {
+ supportedDrivers.add(family);
+ supportedFreq.set(family, (supportedFreq.get(family) ?? 0) + 1);
+ }
+
+ const type = trim(printer.type);
+
+ if (type && type !== "unknown") {
+ types.add(type);
+ }
+
+ for (const cs of printer.commandsetTokens ?? []) {
+ commandsetFreq.set(cs, (commandsetFreq.get(cs) ?? 0) + 1);
+ }
+ }
+
+ const commandsets = [...commandsetFreq.entries()]
+ .filter(([, count]) => count >= MIN_COMMANDSET_FREQUENCY)
+ .map(([cs]) => cs)
+ .sort();
+
+ const recommendedList = [...recommendedDrivers].sort();
+ const supportedList = [...supportedDrivers].sort();
+ const total = printers.length;
+
+ return {
+ recommendedDrivers: recommendedList,
+ supportedDrivers: supportedList,
+ types: [...types].sort(),
+ commandsets,
+ idf: {
+ recommendedDrivers: idfScale(recommendedList, recommendedFreq, total),
+ supportedDrivers: idfScale(supportedList, supportedFreq, total),
+ commandsets: idfScale(commandsets, commandsetFreq, total),
+ },
+ };
+}
+
+function buildFeatureNames(vocab: Vocabulary): string[] {
+ return [
+ ...vocab.recommendedDrivers.map((driver) => `recommended_driver:${driver}`),
+
+ ...vocab.supportedDrivers.map((driver) => `supported_driver:${driver}`),
+
+ ...vocab.types.map((type) => `type:${type}`),
+
+ "functionality",
+
+ "color",
+
+ ...vocab.commandsets.map((cs) => `commandset:${cs}`),
+
+ "lang:postscript",
+ "lang:postscript_3",
+ "lang:pcl",
+ "lang:pcl_6",
+
+ "res:300",
+ "res:600",
+ "res:1200",
+ "res:2400plus",
+ ];
+}
+
+function encodePrinter(printer: Printer, vocab: Vocabulary): number[] {
+ const recommended = getRecommendedDriverFamily(printer);
+
+ const supported = new Set(getSupportedDriverFamilies(printer));
+
+ const type = trim(printer.type);
+
+ return [
+ ...vocab.recommendedDrivers.map((driver, i) =>
+ driver === recommended
+ ? RECOMMENDED_DRIVER_WEIGHT * vocab.idf.recommendedDrivers[i]
+ : 0,
+ ),
+
+ ...vocab.supportedDrivers.map((driver, i) =>
+ supported.has(driver)
+ ? SUPPORTED_DRIVER_WEIGHT * vocab.idf.supportedDrivers[i]
+ : 0,
+ ),
+
+ ...vocab.types.map((t) => (t === type ? TYPE_WEIGHT : 0)),
+
+ encodeFunctionality(printer.functionality) * FUNCTIONALITY_WEIGHT,
+
+ printer.color === true ? COLOR_WEIGHT : 0,
+
+ ...vocab.commandsets.map((cs, i) =>
+ (printer.commandsetTokens ?? []).includes(cs)
+ ? COMMANDSET_WEIGHT * vocab.idf.commandsets[i]
+ : 0,
+ ),
+
+ printer.psLevel != null ? LANG_WEIGHT : 0,
+ printer.psLevel === 3 ? LANG_LEVEL_WEIGHT : 0,
+ printer.pclLevel != null ? LANG_WEIGHT : 0,
+ printer.pclLevel === 6 ? LANG_LEVEL_WEIGHT : 0,
+
+ printer.maxDpi != null && printer.maxDpi <= 300 ? RESOLUTION_WEIGHT : 0,
+ printer.maxDpi != null && printer.maxDpi > 300 && printer.maxDpi <= 600 ? RESOLUTION_WEIGHT : 0,
+ printer.maxDpi != null && printer.maxDpi > 600 && printer.maxDpi <= 1200 ? RESOLUTION_WEIGHT : 0,
+ printer.maxDpi != null && printer.maxDpi > 1200 ? RESOLUTION_WEIGHT : 0,
+ ];
+}
+
+function loadAndValidate(): Printer[] {
+ if (!fs.existsSync(INPUT_FILE)) {
+ throw new Error(
+ `Input not found: ${INPUT_FILE}\n` +
+ `Run: yarn foomatic:generate:xml && yarn foomatic:data:combine`,
+ );
+ }
+
+ const raw: unknown = JSON.parse(fs.readFileSync(INPUT_FILE, "utf-8"));
+
+ if (
+ typeof raw !== "object" ||
+ raw === null ||
+ !Array.isArray((raw as Record).printers)
+ ) {
+ throw new Error("Invalid printers.json: expected { printers: Printer[] }");
+ }
+
+ const printers = (raw as { printers: Printer[] }).printers;
+
+ if (printers.length === 0) {
+ throw new Error("printers.json is empty. Re-run the pipeline.");
+ }
+
+ return printers;
+}
+
+function main(): void {
+ console.log("Loading printers.json...");
+
+ const printers = loadAndValidate();
+
+ console.log(`Loaded ${printers.length} printers`);
+
+ const vocab = buildVocabularies(printers);
+
+ const featureNames = buildFeatureNames(vocab);
+
+ const ids: string[] = [];
+
+ const matrix: number[][] = [];
+
+ for (const printer of printers) {
+ ids.push(printer.id);
+
+ matrix.push(encodePrinter(printer, vocab));
+ }
+
+ const output: FeatureMatrix = {
+ printerCount: printers.length,
+ featureCount: featureNames.length,
+ featureNames,
+ vocab,
+ ids,
+ matrix,
+ };
+
+ fs.mkdirSync(path.dirname(OUTPUT_FILE), {
+ recursive: true,
+ });
+
+ fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2));
+
+ console.log(`โ Feature matrix generated: ${OUTPUT_FILE}`);
+
+ console.log(`Features: ${output.featureCount}`);
+
+ console.log(`Recommended drivers: ${vocab.recommendedDrivers.length}`);
+
+ console.log(`Supported drivers: ${vocab.supportedDrivers.length}`);
+
+ console.log(`Printer types: ${vocab.types.length}`);
+
+ console.log(`Commandset tokens: ${vocab.commandsets.length}`);
+}
+
+main();
diff --git a/tools/eval/check-docs.mjs b/tools/eval/check-docs.mjs
new file mode 100644
index 00000000..77357e21
--- /dev/null
+++ b/tools/eval/check-docs.mjs
@@ -0,0 +1,63 @@
+// Guards against documentation drift: every tunable listed in
+// docs/foomatic-recommendation-quality.md must exist in the source with the
+// documented value, and every scoring constant in the source must be listed.
+// Exits non-zero on any mismatch so it can run as part of `yarn foomatic:eval`.
+import fs from "fs"
+
+const SOURCES = [
+ "scripts/foomatic/vectorize.ts",
+ "scripts/foomatic/compute-similarity.ts",
+ "lib/foomatic/scoring.ts",
+]
+const DOC = "docs/foomatic-recommendation-quality.md"
+
+// The constants that define pipeline behaviour. Add new tunables here so the
+// check fails loudly until they are documented.
+const TRACKED = [
+ "RECOMMENDED_DRIVER_WEIGHT", "COMMANDSET_WEIGHT", "SUPPORTED_DRIVER_WEIGHT",
+ "COLOR_WEIGHT", "LANG_WEIGHT", "RESOLUTION_WEIGHT", "TYPE_WEIGHT",
+ "LANG_LEVEL_WEIGHT", "FUNCTIONALITY_WEIGHT", "MIN_COMMANDSET_FREQUENCY",
+ "TOP_K", "MIN_SIMILARITY_SCORE", "EVIDENCE_TAU",
+ "TYPE_CONFLICT_PENALTY", "COLOR_CONFLICT_PENALTY",
+ "RESOLUTION_CONFLICT_PENALTY", "RESOLUTION_CONFLICT_RATIO",
+ "CONFIDENCE_HIGH_THRESHOLD", "CONFIDENCE_GOOD_THRESHOLD",
+ "CONFIDENCE_MODERATE_THRESHOLD",
+]
+
+const src = SOURCES.map((f) => fs.readFileSync(f, "utf8")).join("\n")
+const doc = fs.readFileSync(DOC, "utf8")
+
+let failures = 0
+for (const name of TRACKED) {
+ const inCode = new RegExp(`const ${name} = ([0-9.]+)`).exec(src)
+ const inDoc = new RegExp(`\\| \`${name}\`\\s*\\|\\s*([0-9.]+)`).exec(doc)
+
+ if (!inCode) {
+ console.error(`MISSING IN CODE : ${name} (tracked but not defined in any source file)`)
+ failures++
+ } else if (!inDoc) {
+ console.error(`UNDOCUMENTED : ${name} = ${inCode[1]} (not in ${DOC})`)
+ failures++
+ } else if (Number(inCode[1]) !== Number(inDoc[1])) {
+ console.error(`VALUE MISMATCH : ${name} code=${inCode[1]} doc=${inDoc[1]}`)
+ failures++
+ }
+}
+
+// Terminology that must not reappear anywhere in the docs.
+const BANNED = [/Exact match/, /% match\b/, /EXACT_MATCH_THRESHOLD/, /STRONG_MATCH_PERCENT/, /MODERATE_MATCH_PERCENT/]
+for (const file of fs.readdirSync("docs").filter((f) => f.startsWith("foomatic-"))) {
+ const text = fs.readFileSync(`docs/${file}`, "utf8")
+ for (const re of BANNED) {
+ if (re.test(text)) {
+ console.error(`STALE TERM : ${re} in docs/${file}`)
+ failures++
+ }
+ }
+}
+
+if (failures > 0) {
+ console.error(`\ndoc-drift check FAILED: ${failures} problem(s)`)
+ process.exit(1)
+}
+console.log(`doc-drift check OK: ${TRACKED.length} tunables match ${DOC}; no stale terminology`)
diff --git a/tools/eval/grade.mjs b/tools/eval/grade.mjs
new file mode 100644
index 00000000..3cd03d44
--- /dev/null
+++ b/tools/eval/grade.mjs
@@ -0,0 +1,68 @@
+// Applies a fixed rubric to every top-3 recommendation of the sampled printers.
+// Rubric (deterministic, applied uniformly โ no per-case judgement):
+// Incorrect : 0 reasons, OR type contradiction, OR colour contradiction, OR >=4x dpi gap
+// Weak : only 1 reason, OR the sole substantive reason is a generic/catch-all driver
+// Acceptable: 2-3 reasons and no contradiction
+// Good : 4-6 reasons, or same product series
+// Excellent : >=7 reasons, or a specific (non-generic) shared preferred driver + >=4 reasons
+import fs from "fs"
+
+const P = JSON.parse(fs.readFileSync(`${process.cwd()}/public/foomatic-db/printers.json`, "utf8")).printers
+const R = JSON.parse(fs.readFileSync(`${process.cwd()}/cache/foomatic/recommendations.json`, "utf8")).recommendations
+const byId = new Map(P.map((p) => [p.id, p]))
+const sampleArg = process.argv.find((a) => a.endsWith(".json"))
+const sample = sampleArg
+ ? JSON.parse(fs.readFileSync(sampleArg, "utf8"))
+ : (await import("./sample.mjs")).sample
+
+const GENERIC = new Set(["postscript", "pdf", "omni", "gutenprint", "laserjet", "hpijs", "pxlmono", "pxlcolor", "gdi"])
+const NORM = [[/^Postscript/i, "postscript"], [/^PDF/i, "pdf"], [/^pxlmono/i, "pxlmono"], [/^pxlcolor/i, "pxlcolor"],
+ [/^foo2zjs/i, "foo2zjs"], [/^foo2hp/i, "foo2hp"], [/^foo2qpdl/i, "foo2qpdl"], [/^hpijs/i, "hpijs"],
+ [/^gutenprint/i, "gutenprint"], [/^gimp-print/i, "gutenprint"], [/^hplip/i, "hplip"], [/^ljet/i, "laserjet"], [/^lj/i, "laserjet"]]
+const fam = (n) => { const s = (n || "").trim().replace(/^driver\//i, ""); for (const [re, f] of NORM) if (re.test(s)) return f; return s.toLowerCase() }
+const recFam = (p) => { const d = (p.recommended_driver || "").trim(); return d ? fam(d) : null }
+const series = (p) => `${p.manufacturer}|${(p.model || "").replace(/[\d_\-].*$/, "")}`
+
+function grade(a, b, r) {
+ const n = r.sharedFeatures.length
+ if (n === 0) return ["Incorrect", "no stated reason at all"]
+ if (a.type && b.type && a.type !== "unknown" && b.type !== "unknown" && a.type !== b.type) return ["Incorrect", `type contradiction ${a.type} vs ${b.type}`]
+ if (a.color === true && b.color === false) return ["Incorrect", "colour contradiction: colour vs mono"]
+ if (a.color === false && b.color === true) return ["Incorrect", "colour contradiction: mono vs colour"]
+ if (a.maxDpi != null && b.maxDpi != null) {
+ const hi = Math.max(a.maxDpi, b.maxDpi), lo = Math.min(a.maxDpi, b.maxDpi)
+ if (hi / lo >= 4) return ["Incorrect", `dpi gap ${a.maxDpi} vs ${b.maxDpi}`]
+ }
+ const rf = recFam(a)
+ const specificDriver = rf && rf === recFam(b) && !GENERIC.has(rf)
+ if (n === 1) return ["Weak", `single reason: ${r.sharedFeatures[0]}`]
+ if (rf && GENERIC.has(rf) && n <= 3) return ["Weak", `generic driver (${rf}) + only ${n} reasons`]
+ if (n >= 7 || (specificDriver && n >= 4)) return ["Excellent", specificDriver ? `specific driver ${rf}, ${n} reasons` : `${n} corroborating reasons`]
+ if (n >= 4 || series(a) === series(b)) return ["Good", series(a) === series(b) ? "same product series" : `${n} reasons`]
+ return ["Acceptable", `${n} reasons`]
+}
+
+const tally = {}
+const rows = []
+for (const { id, strata } of sample) {
+ const a = byId.get(id)
+ for (const [i, r] of (R[id] || []).slice(0, 3).entries()) {
+ const b = byId.get(r.id)
+ const [g, why] = grade(a, b, r)
+ tally[g] = (tally[g] || 0) + 1
+ rows.push({ id, strata: strata.join(","), rank: i + 1, rec: r.id, score: r.score, n: r.sharedFeatures.length, grade: g, why,
+ sameMfr: a.manufacturer === b.manufacturer })
+ }
+}
+if (process.argv.includes("--rows")) {
+ for (const r of rows) console.log(`${r.id.padEnd(34)} #${r.rank} -> ${r.rec.padEnd(34)} s=${String(r.score).padEnd(5)} n=${String(r.n).padStart(2)} ${r.sameMfr ? "SAME" : " "} ${r.grade.padEnd(10)} ${r.why}`)
+}
+const total = Object.values(tally).reduce((a, b) => a + b, 0)
+console.log("\nGRADE DISTRIBUTION over", total, "sampled recommendations")
+for (const g of ["Excellent", "Good", "Acceptable", "Weak", "Incorrect"]) {
+ const c = tally[g] || 0
+ console.log(` ${g.padEnd(11)} ${String(c).padStart(4)} ${(c / total * 100).toFixed(1)}%`)
+}
+const goodish = (tally.Excellent || 0) + (tally.Good || 0)
+console.log(` => usable (Excellent+Good): ${(goodish / total * 100).toFixed(1)}%`)
+console.log(` => problematic (Weak+Incorrect): ${(((tally.Weak || 0) + (tally.Incorrect || 0)) / total * 100).toFixed(1)}%`)
diff --git a/tools/eval/metrics.mjs b/tools/eval/metrics.mjs
new file mode 100644
index 00000000..511dc7ef
--- /dev/null
+++ b/tools/eval/metrics.mjs
@@ -0,0 +1,276 @@
+// Ranking-quality metrics for the Foomatic recommendation engine.
+// Reads the generated artifacts, computes, prints JSON, then enforces the hard
+// invariants listed at the bottom of the file (exits non-zero on violation).
+import fs from "fs"
+
+const ROOT = process.cwd()
+const P = JSON.parse(fs.readFileSync(`${ROOT}/public/foomatic-db/printers.json`, "utf8")).printers
+const R = JSON.parse(fs.readFileSync(`${ROOT}/cache/foomatic/recommendations.json`, "utf8")).recommendations
+const byId = new Map(P.map((p) => [p.id, p]))
+
+const NORM = [[/^Postscript/i, "postscript"], [/^PDF/i, "pdf"], [/^pxlmono/i, "pxlmono"], [/^pxlcolor/i, "pxlcolor"],
+ [/^foo2zjs/i, "foo2zjs"], [/^foo2hp/i, "foo2hp"], [/^foo2qpdl/i, "foo2qpdl"], [/^hpijs/i, "hpijs"],
+ [/^gutenprint/i, "gutenprint"], [/^gimp-print/i, "gutenprint"], [/^hplip/i, "hplip"], [/^ljet/i, "laserjet"], [/^lj/i, "laserjet"]]
+const fam = (n) => { const s = (n || "").trim().replace(/^driver\//i, ""); for (const [re, f] of NORM) if (re.test(s)) return f; return s.toLowerCase() }
+// Mirrors lib/foomatic/driver-family.ts deliberately (an independent
+// re-implementation, so a drift between the two shows up as a failed claim):
+// obsolete drivers are not compatibility evidence, and an obsolete recommended
+// driver resolves to the replacement foomatic-db names explicitly.
+const liveFams = (p) => new Set((p.drivers || []).filter((d) => !d.obsolete).map((d) => fam(d.name)))
+const recFam = (p) => {
+ const d = (p.recommended_driver || "").trim()
+ if (!d) return null
+ const entry = (p.drivers || []).find((x) => x.id === d)
+ if (entry && entry.obsolete) return entry.replacedBy ? fam(entry.replacedBy) : null
+ return fam(d)
+}
+const REBADGE = new Set(["Ricoh", "Lanier", "NRG", "Gestetner", "Savin", "Infotec", "Rex-Rotary", "Nashuatec"])
+
+const clusterOf = {}
+for (const p of P) { const f = recFam(p); if (f) clusterOf[f] = (clusterOf[f] || 0) + 1 }
+
+const top3 = []
+for (const [pid, recs] of Object.entries(R)) for (const r of recs.slice(0, 3)) top3.push([pid, r])
+
+const m = {}
+m.printers = P.length
+m.printersWithRecs = Object.values(R).filter((r) => r.length > 0).length
+m.printersWithZeroRecs = Object.values(R).filter((r) => r.length === 0).length
+m.top3Count = top3.length
+
+const avg = (a) => (a.length ? a.reduce((x, y) => x + y, 0) / a.length : 0)
+
+// --- score distribution ---
+const scores = top3.map(([, r]) => r.score).sort((a, b) => a - b)
+const q = (f) => scores[Math.min(scores.length - 1, Math.floor(scores.length * f))]
+m.scoreMean = +avg(scores).toFixed(4)
+m.scoreP10 = q(0.10); m.scoreP50 = q(0.50); m.scoreP90 = q(0.90)
+m.saturationPct = +(scores.filter((s) => s >= 0.9995).length / scores.length * 100).toFixed(2)
+m.perfectMatches = scores.filter((s) => s >= 0.9995).length
+const hist = {}
+for (const s of scores) { const b = (Math.floor(s * 10) / 10).toFixed(1); hist[b] = (hist[b] || 0) + 1 }
+m.scoreHistogram = hist
+
+// --- ties / arbitrariness ---
+let tieSum = 0, allTie = 0, nWith = 0
+for (const recs of Object.values(R)) {
+ if (!recs.length) continue
+ nWith++
+ const t = recs[0].score
+ const c = recs.filter((r) => r.score === t).length
+ tieSum += c
+ if (c === recs.length && recs.length >= 3) allTie++
+}
+m.avgTiedAtTopScore = +(tieSum / nWith).toFixed(2)
+m.pctAllRetainedTied = +(allTie / nWith * 100).toFixed(2)
+
+// --- evidence quality ---
+const featCounts = top3.map(([, r]) => r.sharedFeatures.length)
+m.avgSharedFeatures = +avg(featCounts).toFixed(2)
+m.pctZeroExplanation = +(featCounts.filter((c) => c === 0).length / featCounts.length * 100).toFixed(2)
+m.pctSingleFeature = +(featCounts.filter((c) => c === 1).length / featCounts.length * 100).toFixed(2)
+const GENERIC = new Set(["postscript", "pdf", "omni", "gutenprint", "laserjet", "hpijs", "pxlmono", "pxlcolor"])
+let genericOnly = 0
+for (const [, r] of top3) {
+ if (r.sharedFeatures.length !== 1) continue
+ const mm = /^Preferred Linux driver: (.+)$/.exec(r.sharedFeatures[0])
+ if (mm && GENERIC.has(mm[1])) genericOnly++
+}
+m.pctGenericDriverOnly = +(genericOnly / top3.length * 100).toFixed(2)
+m.pctFromLargeCluster = +(top3.filter(([pid]) => { const f = recFam(byId.get(pid)); return f && (clusterOf[f] || 0) >= 200 }).length / top3.length * 100).toFixed(2)
+
+// --- confidence/evidence correlation ---
+const weak = top3.filter(([, r]) => r.sharedFeatures.length <= 1).map(([, r]) => r.score)
+const strong = top3.filter(([, r]) => r.sharedFeatures.length >= 2).map(([, r]) => r.score)
+m.meanScoreWeakEvidence = +avg(weak).toFixed(4)
+m.meanScoreStrongEvidence = +avg(strong).toFixed(4)
+m.confidenceInversion = +(m.meanScoreWeakEvidence - m.meanScoreStrongEvidence).toFixed(4) // > 0 means broken
+{
+ const xs = featCounts, ys = top3.map(([, r]) => r.score)
+ const mx = avg(xs), my = avg(ys)
+ let num = 0, dx = 0, dy = 0
+ for (let i = 0; i < xs.length; i++) { const a = xs[i] - mx, b = ys[i] - my; num += a * b; dx += a * a; dy += b * b }
+ m.corrEvidenceScore = +(num / Math.sqrt(dx * dy)).toFixed(4) // expected strongly positive
+}
+
+// --- diversity ---
+let sameMfr = 0, cross = 0, rebadge = 0, allSameMfr = 0, full = 0
+const recCounts = {}
+for (const [pid, recs] of Object.entries(R)) {
+ const t = recs.slice(0, 3)
+ if (t.length === 3) {
+ full++
+ const a = byId.get(pid)
+ const set = new Set(t.map((r) => byId.get(r.id).manufacturer))
+ if (set.size === 1 && set.has(a.manufacturer)) allSameMfr++
+ }
+ for (const r of t) {
+ const a = byId.get(pid), b = byId.get(r.id)
+ recCounts[r.id] = (recCounts[r.id] || 0) + 1
+ if (a.manufacturer === b.manufacturer) sameMfr++
+ else { cross++; if (REBADGE.has(a.manufacturer) && REBADGE.has(b.manufacturer)) rebadge++ }
+ }
+}
+m.pctSameManufacturer = +(sameMfr / top3.length * 100).toFixed(2)
+m.pctCrossVendor = +(cross / top3.length * 100).toFixed(2)
+m.pctRebadgeOfCross = +(rebadge / cross * 100).toFixed(2)
+m.pctGenuinelyNovelVendor = +((cross - rebadge) / top3.length * 100).toFixed(2)
+m.pctAll3SameManufacturer = +(allSameMfr / full * 100).toFixed(2)
+
+// catalogue coverage + exposure concentration
+const counts = Object.values(recCounts)
+m.distinctPrintersRecommended = counts.length
+m.catalogueCoveragePct = +(counts.length / P.length * 100).toFixed(2)
+{
+ const tot = counts.reduce((a, b) => a + b, 0)
+ let H = 0
+ for (const c of counts) { const p = c / tot; H -= p * Math.log2(p) }
+ m.recommendationEntropyBits = +H.toFixed(3)
+ m.maxEntropyBits = +Math.log2(P.length).toFixed(3)
+ m.entropyRatio = +(H / Math.log2(P.length)).toFixed(4)
+ const s = counts.slice().sort((a, b) => a - b)
+ let cum = 0, g = 0
+ const n = s.length, t = s.reduce((a, b) => a + b, 0)
+ for (let i = 0; i < n; i++) { cum += s[i]; g += cum / t }
+ m.giniConcentration = +(1 - 2 * g / n + 1 / n).toFixed(4)
+}
+let mixedDriver = 0
+for (const [, recs] of Object.entries(R)) {
+ const t = recs.slice(0, 3); if (t.length < 3) continue
+ const s = new Set(t.map((r) => recFam(byId.get(r.id))))
+ if (s.size > 1) mixedDriver++
+}
+m.pctTop3WithMixedDrivers = +(mixedDriver / full * 100).toFixed(2)
+
+// --- contradictions ---
+let typeBad = 0, typeBoth = 0, colorBad = 0, colorBoth = 0, dpiFar = 0, dpiBoth = 0
+for (const [pid, r] of top3) {
+ const a = byId.get(pid), b = byId.get(r.id)
+ if (a.type && b.type && a.type !== "unknown" && b.type !== "unknown") { typeBoth++; if (a.type !== b.type) typeBad++ }
+ if (a.color != null && b.color != null && a.color !== "unknown" && b.color !== "unknown") { colorBoth++; if (a.color !== b.color) colorBad++ }
+ if (a.maxDpi != null && b.maxDpi != null) { dpiBoth++; const hi = Math.max(a.maxDpi, b.maxDpi), lo = Math.min(a.maxDpi, b.maxDpi); if (hi / lo >= 4) dpiFar++ }
+}
+m.pctTypeContradiction = +(typeBad / typeBoth * 100).toFixed(2)
+m.pctColorContradiction = +(colorBad / colorBoth * 100).toFixed(2)
+m.pctDpiContradiction4x = +(dpiFar / dpiBoth * 100).toFixed(2)
+
+// --- explanation truthfulness: validate every user-visible claim ---
+let claims = 0, falseClaims = 0
+const falseBy = {}
+const bump = (k) => { falseBy[k] = (falseBy[k] || 0) + 1; falseClaims++ }
+for (const [pid, r] of top3) {
+ const a = byId.get(pid), b = byId.get(r.id)
+ const aFams = liveFams(a)
+ const bFams = liveFams(b)
+ const aCmd = new Set(a.commandsetTokens || []), bCmd = new Set(b.commandsetTokens || [])
+ for (const f of r.sharedFeatures) {
+ claims++
+ let mm
+ if ((mm = /^Preferred Linux driver: (.+)$/.exec(f))) {
+ if (!(recFam(a) === mm[1] && recFam(b) === mm[1])) bump("preferredDriver")
+ } else if ((mm = /^Shared driver family: (.+)$/.exec(f))) {
+ if (!(aFams.has(mm[1]) && bFams.has(mm[1]))) bump("sharedDriverFamily")
+ } else if (/^(Laser|Inkjet|Dot-matrix) printer$/.test(f)) {
+ const want = { "Laser printer": "laser", "Inkjet printer": "inkjet", "Dot-matrix printer": "dot-matrix" }[f]
+ if (!(a.type === want && b.type === want)) bump("printerType")
+ } else if (f === "Color printing") {
+ if (!(a.color === true && b.color === true)) bump("colorPrinting")
+ } else if ((mm = /^Shared command set: (.+)$/.exec(f))) {
+ const LAB = { "PostScript": "POSTSCRIPT", "PCL XL (PCL6)": "PCLXL", "PCL5e": "PCL5E", "PCL": "PCL", "PDF printing": "PDF",
+ "Epson ESC/P2": "ESCPL2", "Epson ESC/P-R": "ESCPR2", "Epson BDC": "BDC", "Epson D4": "D4", "Epson D4PX": "D4PX", "PJL": "PJL", "MLC": "MLC" }
+ const tok = LAB[mm[1]] ?? mm[1]
+ if (!(aCmd.has(tok) && bCmd.has(tok))) bump("commandSet")
+ } else if ((mm = /^PostScript (\d)$/.exec(f))) {
+ if (!(a.psLevel === +mm[1] && b.psLevel === +mm[1])) bump("postscriptLevel")
+ } else if (/^PCL (6 \/ PCL XL|5e|\d)$/.test(f)) {
+ const lv = f === "PCL 6 / PCL XL" ? 6 : f === "PCL 5e" ? 5 : +f.slice(4)
+ if (!(a.pclLevel === lv && b.pclLevel === lv)) bump("pclLevel")
+ } else if ((mm = /^(\d+)(\+?) dpi resolution$/.exec(f))) {
+ const stated = +mm[1], plus = mm[2] === "+"
+ const ok = plus ? (a.maxDpi > stated && b.maxDpi > stated) : (a.maxDpi === stated && b.maxDpi === stated)
+ if (!ok) bump(plus ? "dpiTierPlus" : "dpiExact")
+ } else if (/^(Similar resolution|Excellent|Good|Basic) /.test(f)) {
+ // range-style or support-grade labels: verified elsewhere
+ }
+ }
+}
+m.totalClaims = claims
+m.falseOrMisleadingClaims = falseClaims
+m.pctClaimsFalseOrMisleading = +(falseClaims / claims * 100).toFixed(2)
+m.falseClaimBreakdown = falseBy
+
+// --- obsolete drivers must never be cited as current compatibility evidence ---
+// foomatic-db marks some drivers ` `. Those entries are
+// excluded from the similarity features, so no explanation may rest on a family
+// a printer reaches only through an obsolete driver.
+const obsoleteOnlyFamilies = new Map()
+for (const p of P) {
+ const live = new Set((p.drivers || []).filter((d) => !d.obsolete).map((d) => fam(d.name)))
+ const dead = new Set()
+ for (const d of p.drivers || []) {
+ if (d.obsolete && !live.has(fam(d.name))) dead.add(fam(d.name))
+ }
+ obsoleteOnlyFamilies.set(p.id, dead)
+}
+let driverClaims = 0, obsoleteClaims = 0
+const obsoleteExamples = []
+for (const [pid, r] of top3) {
+ for (const f of r.sharedFeatures) {
+ const mm = /^(?:Shared driver family|Preferred Linux driver): (.+)$/.exec(f)
+ if (!mm) continue
+ driverClaims++
+ if (obsoleteOnlyFamilies.get(pid)?.has(mm[1]) || obsoleteOnlyFamilies.get(r.id)?.has(mm[1])) {
+ obsoleteClaims++
+ if (obsoleteExamples.length < 5) obsoleteExamples.push(`${pid} -> ${r.id}: ${f}`)
+ }
+ }
+}
+m.driverFamilyClaims = driverClaims
+m.claimsCitingObsoleteOnlyFamily = obsoleteClaims
+m.obsoleteClaimExamples = obsoleteExamples
+
+// Reported so the relationship between driver-list size and score stays
+// visible. It is a property of evidence damping, not a support-quality signal;
+// nothing in the pipeline consumes a driver count.
+{
+ const rows = []
+ for (const [pid, recs] of Object.entries(R)) {
+ const p = byId.get(pid)
+ if (!p || !recs.length) continue
+ rows.push([(p.drivers || []).length, avg(recs.slice(0, 3).map((r) => r.score))])
+ }
+ const xs = rows.map((r) => r[0]), ys = rows.map((r) => r[1])
+ const mx = avg(xs), my = avg(ys)
+ let num = 0, dx = 0, dy = 0
+ for (let i = 0; i < xs.length; i++) { const a = xs[i] - mx, b = ys[i] - my; num += a * b; dx += a * a; dy += b * b }
+ m.corrDriverCountScore = +(num / Math.sqrt(dx * dy)).toFixed(4)
+}
+
+// A driver marked obsolete cannot be used, so a printer left with only obsolete
+// entries has no driver support and must not still read as merely unrated.
+{
+ const noUsable = P.filter((p) => (p.drivers || []).length > 0 && (p.drivers || []).every((d) => d.obsolete))
+ m.printersWithOnlyObsoleteDrivers = noUsable.length
+ m.printersUnratedWithNoUsableDriver = noUsable.filter((p) => p.status === "Unknown").length
+}
+
+console.log(JSON.stringify(m, null, 1))
+
+// Hard invariants. Everything above is measurement; these two properties the
+// user-facing explanations depend on must fail `yarn foomatic:eval` when broken.
+const INVARIANTS = [
+ ["claimsCitingObsoleteOnlyFamily", m.claimsCitingObsoleteOnlyFamily, 0],
+ ["falseOrMisleadingClaims", m.falseOrMisleadingClaims, 0],
+ ["printersUnratedWithNoUsableDriver", m.printersUnratedWithNoUsableDriver, 0],
+]
+let broken = 0
+for (const [name, actual, expected] of INVARIANTS) {
+ if (actual !== expected) {
+ console.error(`INVARIANT FAILED: ${name} = ${actual}, expected ${expected}`)
+ broken++
+ }
+}
+if (broken > 0) {
+ if (m.obsoleteClaimExamples.length) console.error(`examples: ${m.obsoleteClaimExamples.join(" | ")}`)
+ process.exit(1)
+}
diff --git a/tools/eval/pairs.mjs b/tools/eval/pairs.mjs
new file mode 100644
index 00000000..0c4fc510
--- /dev/null
+++ b/tools/eval/pairs.mjs
@@ -0,0 +1,73 @@
+// Maintainer-facing inspection of real recommendation pairs.
+//
+// Usage: node tools/eval/pairs.mjs [sample.json]
+// With no argument the deterministic sample is generated in-process.
+//
+// For every recommendation of every sampled printer it prints the full source
+// and target attributes next to the user-visible explanation, then buckets the
+// pair into one of four defensibility categories:
+//
+// good multiple independent discriminative signals agree
+// low-value technically correct but a rebadge / same-series sibling,
+// so it offers the user little new information
+// weak rests on a single signal or a generic catch-all driver
+// problematic capability conflict or unexplained pairing survives
+//
+// The bucketing is deterministic so the same artifacts always produce the
+// same report. It is a lens for human review, not a ground-truth label.
+import fs from "fs"
+
+const P = JSON.parse(fs.readFileSync(`${process.cwd()}/public/foomatic-db/printers.json`, "utf8")).printers
+const R = JSON.parse(fs.readFileSync(`${process.cwd()}/cache/foomatic/recommendations.json`, "utf8")).recommendations
+const byId = new Map(P.map((p) => [p.id, p]))
+const sampleArg = process.argv.find((a) => a.endsWith(".json"))
+const sample = sampleArg
+ ? JSON.parse(fs.readFileSync(sampleArg, "utf8"))
+ : (await import("./sample.mjs")).sample
+
+const REBADGE = new Set(["Ricoh", "Lanier", "NRG", "Gestetner", "Savin", "Infotec", "Rex-Rotary", "Nashuatec"])
+const GENERIC = new Set(["postscript", "pdf", "omni", "gutenprint", "laserjet", "hpijs", "pxlmono", "pxlcolor", "gdi"])
+const NORM = [[/^Postscript/i, "postscript"], [/^PDF/i, "pdf"], [/^pxlmono/i, "pxlmono"], [/^pxlcolor/i, "pxlcolor"],
+ [/^foo2zjs/i, "foo2zjs"], [/^foo2hp/i, "foo2hp"], [/^foo2qpdl/i, "foo2qpdl"], [/^hpijs/i, "hpijs"],
+ [/^gutenprint/i, "gutenprint"], [/^gimp-print/i, "gutenprint"], [/^hplip/i, "hplip"], [/^ljet/i, "laserjet"], [/^lj/i, "laserjet"]]
+const fam = (n) => { const s = (n || "").trim().replace(/^driver\//i, ""); for (const [re, f] of NORM) if (re.test(s)) return f; return s.toLowerCase() }
+const recFam = (p) => { const d = (p.recommended_driver || "").trim(); return d ? fam(d) : null }
+const series = (p) => `${p.manufacturer}|${(p.model || "").replace(/[\d_\-].*$/, "")}`
+
+const cap = (p) =>
+ `type=${p.type ?? "?"} color=${p.color} dpi=${p.maxDpi ?? "?"} ps=${p.psLevel ?? "-"} pcl=${p.pclLevel ?? "-"} ` +
+ `func=${p.functionality ?? "?"} drv=${recFam(p) ?? "none"} cmds=[${(p.commandsetTokens ?? []).join(",")}] drivers=${(p.drivers ?? []).length}`
+
+function bucket(a, b, r) {
+ const n = r.sharedFeatures.length
+ if (n === 0) return "problematic"
+ if (a.type && b.type && a.type !== "unknown" && b.type !== "unknown" && a.type !== b.type) return "problematic"
+ if ((a.color === true && b.color === false) || (a.color === false && b.color === true)) return "problematic"
+ if (a.maxDpi != null && b.maxDpi != null && Math.max(a.maxDpi, b.maxDpi) / Math.min(a.maxDpi, b.maxDpi) >= 4) return "problematic"
+ const rf = recFam(a)
+ if (n === 1 || (rf && GENERIC.has(rf) && n <= 3)) return "weak"
+ const rebadge = REBADGE.has(a.manufacturer) && REBADGE.has(b.manufacturer)
+ if (rebadge || series(a) === series(b)) return "low-value"
+ return "good"
+}
+
+const buckets = { good: [], "low-value": [], weak: [], problematic: [] }
+for (const { id } of sample) {
+ const a = byId.get(id)
+ for (const [i, r] of (R[id] || []).slice(0, 3).entries()) {
+ const b = byId.get(r.id)
+ buckets[bucket(a, b, r)].push({ a, b, r, rank: i + 1 })
+ }
+}
+
+const PER = Number(process.env.PAIRS_PER_BUCKET ?? 4)
+for (const [name, list] of Object.entries(buckets)) {
+ console.log(`\n===== ${name.toUpperCase()} (${list.length} in sample, showing first ${Math.min(PER, list.length)}) =====`)
+ for (const { a, b, r, rank } of list.slice(0, PER)) {
+ console.log(`\n${a.id} -> #${rank} ${b.id} score=${r.score}`)
+ console.log(` source : ${cap(a)}`)
+ console.log(` target : ${cap(b)}`)
+ console.log(` shown : ${r.sharedFeatures.join("; ") || "(none)"}`)
+ }
+}
+console.log("\nBucket totals:", Object.fromEntries(Object.entries(buckets).map(([k, v]) => [k, v.length])))
diff --git a/tools/eval/sample.mjs b/tools/eval/sample.mjs
new file mode 100644
index 00000000..20495a2f
--- /dev/null
+++ b/tools/eval/sample.mjs
@@ -0,0 +1,67 @@
+// Deterministic stratified sample for qualitative recommendation review.
+// Strategy: define disjoint-ish strata by technology / form-factor / rarity,
+// sort each stratum by id (stable), take evenly-spaced picks. No randomness,
+// no cherry-picking: the same input data always yields the same sample.
+import fs from "fs"
+
+const P = JSON.parse(fs.readFileSync(`${process.cwd()}/public/foomatic-db/printers.json`, "utf8")).printers
+const R = JSON.parse(fs.readFileSync(`${process.cwd()}/cache/foomatic/recommendations.json`, "utf8")).recommendations
+
+const nameOf = (p) => `${p.manufacturer} ${p.model}`
+const drvNames = (p) => (p.drivers || []).map((d) => d.name).join(" ").toLowerCase()
+
+const STRATA = {
+ laser: (p) => p.type === "laser",
+ inkjet: (p) => p.type === "inkjet",
+ dotmatrix: (p) => p.type === "dot-matrix",
+ labelThermal: (p) => /label|dymo|costar|slp|zebra|brother.*ql|thermal/i.test(nameOf(p)) || /pbm2lwxl|slap|ptouch/.test(drvNames(p)),
+ dyeSub: (p) => /printiva|dye|md-\d|p-\d{3}|cp\d{2}|selphy|picturemate/i.test(nameOf(p)) || /ppmtomd|md2k/.test(drvNames(p)),
+ mfp: (p) => /mfp|mfc|bizhub|ir-adv|imagerunner|workcentre|laserjet.*m\d|aficio|im\d/i.test(nameOf(p)),
+ industrial: (p) => /digimaster|production|nuvera|docutech|varioprint|indigo|press/i.test(nameOf(p)),
+ color: (p) => p.color === true,
+ mono: (p) => p.color === false,
+ highRes: (p) => p.maxDpi != null && p.maxDpi >= 2400,
+ lowRes: (p) => p.maxDpi != null && p.maxDpi <= 300,
+ postscript: (p) => p.psLevel != null,
+}
+const NORM = [[/^Postscript/i, "postscript"], [/^PDF/i, "pdf"], [/^pxlmono/i, "pxlmono"], [/^pxlcolor/i, "pxlcolor"],
+ [/^foo2zjs/i, "foo2zjs"], [/^foo2hp/i, "foo2hp"], [/^foo2qpdl/i, "foo2qpdl"], [/^hpijs/i, "hpijs"],
+ [/^gutenprint/i, "gutenprint"], [/^gimp-print/i, "gutenprint"], [/^hplip/i, "hplip"], [/^ljet/i, "laserjet"], [/^lj/i, "laserjet"]]
+const fam = (n) => { const s = (n || "").trim().replace(/^driver\//i, ""); for (const [re, f] of NORM) if (re.test(s)) return f; return s.toLowerCase() }
+const recFam = (p) => { const d = (p.recommended_driver || "").trim(); return d ? fam(d) : null }
+const cluster = {}
+for (const p of P) { const f = recFam(p); if (f) cluster[f] = (cluster[f] || 0) + 1 }
+STRATA.commonDriver = (p) => (cluster[recFam(p)] || 0) >= 500
+STRATA.rareDriver = (p) => { const c = cluster[recFam(p)] || 0; return c > 0 && c <= 3 }
+
+const PER_STRATUM = 4
+const picked = new Map()
+for (const [name, pred] of Object.entries(STRATA)) {
+ const pool = P.filter((p) => pred(p) && (R[p.id] || []).length > 0).sort((a, b) => a.id.localeCompare(b.id))
+ if (!pool.length) continue
+ const step = Math.max(1, Math.floor(pool.length / PER_STRATUM))
+ for (let i = 0, taken = 0; i < pool.length && taken < PER_STRATUM; i += step, taken++) {
+ const p = pool[i]
+ if (!picked.has(p.id)) picked.set(p.id, [])
+ picked.get(p.id).push(name)
+ }
+}
+const mfrs = [...new Set(P.map((p) => p.manufacturer))].sort()
+const mstep = Math.max(1, Math.floor(mfrs.length / 20))
+for (let i = 0, t = 0; i < mfrs.length && t < 20; i += mstep, t++) {
+ const pool = P.filter((p) => p.manufacturer === mfrs[i] && (R[p.id] || []).length > 0).sort((a, b) => a.id.localeCompare(b.id))
+ if (!pool.length) continue
+ const p = pool[Math.floor(pool.length / 2)]
+ if (!picked.has(p.id)) picked.set(p.id, [])
+ picked.get(p.id).push(`mfr:${mfrs[i]}`)
+}
+
+const out = [...picked.entries()].map(([id, strata]) => ({ id, strata })).sort((a, b) => a.id.localeCompare(b.id))
+
+// Importable for grade.mjs / pairs.mjs; prints JSON when run directly.
+export const sample = out
+
+import { pathToFileURL } from "url"
+if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
+ console.log(JSON.stringify(out, null, 1))
+}
diff --git a/tsconfig.json b/tsconfig.json
index 7a452cc2..64bfe3ac 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -32,6 +32,7 @@
"include": [
"next-env.d.ts",
"**/*.ts",
+ "**/*.mts",
"**/*.tsx",
".next/types/**/*.ts"
],
diff --git a/vitest.config.mts b/vitest.config.mts
new file mode 100644
index 00000000..aadf3674
--- /dev/null
+++ b/vitest.config.mts
@@ -0,0 +1,17 @@
+import { defineConfig } from "vitest/config"
+import path from "path"
+import { fileURLToPath } from "url"
+
+const rootDir = path.dirname(fileURLToPath(import.meta.url))
+
+export default defineConfig({
+ test: {
+ environment: "node",
+ include: ["lib/**/*.test.ts", "scripts/**/*.test.ts"],
+ },
+ resolve: {
+ alias: {
+ "@": rootDir,
+ },
+ },
+})
diff --git a/yarn.lock b/yarn.lock
index 818ccc37..f911edda 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -615,7 +615,7 @@ __metadata:
languageName: node
linkType: hard
-"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0":
+"@jridgewell/sourcemap-codec@npm:^1.4.14, @jridgewell/sourcemap-codec@npm:^1.5.0, @jridgewell/sourcemap-codec@npm:^1.5.5":
version: 1.5.5
resolution: "@jridgewell/sourcemap-codec@npm:1.5.5"
checksum: 10c0/f9e538f302b63c0ebc06eecb1dd9918dd4289ed36147a0ddce35d6ea4d7ebbda243cda7b2213b6a5e1d8087a298d5cf630fb2bd39329cdecb82017023f6081a0
@@ -772,6 +772,13 @@ __metadata:
languageName: node
linkType: hard
+"@oxc-project/types@npm:=0.144.0":
+ version: 0.144.0
+ resolution: "@oxc-project/types@npm:0.144.0"
+ checksum: 10c0/997c6c33f09706af604ece0e99c698965757887aca4b42c5fb5ddcb11c39876c1e824b188b1e6582276355468a9b13f955d3b822d3b532cd6bedbeb64b603ff0
+ languageName: node
+ linkType: hard
+
"@radix-ui/react-compose-refs@npm:1.1.2":
version: 1.1.2
resolution: "@radix-ui/react-compose-refs@npm:1.1.2"
@@ -800,6 +807,111 @@ __metadata:
languageName: node
linkType: hard
+"@rolldown/binding-android-arm64@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-android-arm64@npm:1.2.4"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-darwin-arm64@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-darwin-arm64@npm:1.2.4"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-darwin-x64@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-darwin-x64@npm:1.2.4"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-freebsd-x64@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-freebsd-x64@npm:1.2.4"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.4"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-arm64-gnu@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.4"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-arm64-musl@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.4"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-ppc64-gnu@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.4"
+ conditions: os=linux & cpu=ppc64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-s390x-gnu@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.4"
+ conditions: os=linux & cpu=s390x & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-x64-gnu@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.4"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-linux-x64-musl@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.4"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-openharmony-arm64@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.4"
+ conditions: os=openharmony & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-win32-arm64-msvc@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.4"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"@rolldown/binding-win32-x64-msvc@npm:1.2.4":
+ version: 1.2.4
+ resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.4"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"@rolldown/pluginutils@npm:^1.0.0":
+ version: 1.0.1
+ resolution: "@rolldown/pluginutils@npm:1.0.1"
+ checksum: 10c0/99d9b06d90196823e4d8c841f258db7a16e5dbba5824a2962b05d907b79f1ba929d56f22dd744fd530936e568c865ee56a719dc31e57e13bc0a8eb4764a8d8dd
+ languageName: node
+ linkType: hard
+
"@rtsao/scc@npm:^1.1.0":
version: 1.1.0
resolution: "@rtsao/scc@npm:1.1.0"
@@ -814,6 +926,13 @@ __metadata:
languageName: node
linkType: hard
+"@standard-schema/spec@npm:^1.1.0":
+ version: 1.1.0
+ resolution: "@standard-schema/spec@npm:1.1.0"
+ checksum: 10c0/d90f55acde4b2deb983529c87e8025fa693de1a5e8b49ecc6eb84d1fd96328add0e03d7d551442156c7432fd78165b2c26ff561b970a9a881f046abb78d6a526
+ languageName: node
+ linkType: hard
+
"@swc/helpers@npm:0.5.15":
version: 0.5.15
resolution: "@swc/helpers@npm:0.5.15"
@@ -843,6 +962,16 @@ __metadata:
languageName: node
linkType: hard
+"@types/chai@npm:^5.2.2":
+ version: 5.2.3
+ resolution: "@types/chai@npm:5.2.3"
+ dependencies:
+ "@types/deep-eql": "npm:*"
+ assertion-error: "npm:^2.0.1"
+ checksum: 10c0/e0ef1de3b6f8045a5e473e867c8565788c444271409d155588504840ad1a53611011f85072188c2833941189400228c1745d78323dac13fcede9c2b28bacfb2f
+ languageName: node
+ linkType: hard
+
"@types/debug@npm:^4.0.0":
version: 4.1.12
resolution: "@types/debug@npm:4.1.12"
@@ -852,6 +981,13 @@ __metadata:
languageName: node
linkType: hard
+"@types/deep-eql@npm:*":
+ version: 4.0.2
+ resolution: "@types/deep-eql@npm:4.0.2"
+ checksum: 10c0/bf3f811843117900d7084b9d0c852da9a044d12eb40e6de73b552598a6843c21291a8a381b0532644574beecd5e3491c5ff3a0365ab86b15d59862c025384844
+ languageName: node
+ linkType: hard
+
"@types/estree-jsx@npm:^1.0.0":
version: 1.0.5
resolution: "@types/estree-jsx@npm:1.0.5"
@@ -943,7 +1079,7 @@ __metadata:
languageName: node
linkType: hard
-"@types/trusted-types@npm:^2.0.2":
+"@types/trusted-types@npm:^2.0.2, @types/trusted-types@npm:^2.0.7":
version: 2.0.7
resolution: "@types/trusted-types@npm:2.0.7"
checksum: 10c0/4c4855f10de7c6c135e0d32ce462419d8abbbc33713b31d294596c0cc34ae1fa6112a2f9da729c8f7a20707782b0d69da3b1f8df6645b0366d08825ca1522e0c
@@ -1241,6 +1377,88 @@ __metadata:
languageName: node
linkType: hard
+"@vitest/expect@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/expect@npm:4.1.10"
+ dependencies:
+ "@standard-schema/spec": "npm:^1.1.0"
+ "@types/chai": "npm:^5.2.2"
+ "@vitest/spy": "npm:4.1.10"
+ "@vitest/utils": "npm:4.1.10"
+ chai: "npm:^6.2.2"
+ tinyrainbow: "npm:^3.1.0"
+ checksum: 10c0/a817ad0d9bd6a039776a7228d54fb8319c17e4af15917407f5566ac61781a8511f591d302519d6999217399915bc3c0290028189fc73f5c38f80cb01b6f19c8d
+ languageName: node
+ linkType: hard
+
+"@vitest/mocker@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/mocker@npm:4.1.10"
+ dependencies:
+ "@vitest/spy": "npm:4.1.10"
+ estree-walker: "npm:^3.0.3"
+ magic-string: "npm:^0.30.21"
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+ checksum: 10c0/4aa70b0df58681652e2e28093437fb2e8f4d02a6d03f5619abc266ac1c5ae5f43326148061d13ae6e071e0f6cfcf7634659af63644de8ce098a7c98949a3d1ad
+ languageName: node
+ linkType: hard
+
+"@vitest/pretty-format@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/pretty-format@npm:4.1.10"
+ dependencies:
+ tinyrainbow: "npm:^3.1.0"
+ checksum: 10c0/1a5daba730ffe23f2000bff484b4b2842f3b178d93663cb487b215516b8d3b62caa3e2bb2a3c63307b61a9fe58fb9bfff38559bc0c5e49d8aa403d6803a1d918
+ languageName: node
+ linkType: hard
+
+"@vitest/runner@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/runner@npm:4.1.10"
+ dependencies:
+ "@vitest/utils": "npm:4.1.10"
+ pathe: "npm:^2.0.3"
+ checksum: 10c0/554b72639de9694271b99be8ae273fe12ec793093ec91cce143816cd1187d40b7138a4d9d4de4f456cfca9567de986825bff97e107c05b9eb4abc130e854286d
+ languageName: node
+ linkType: hard
+
+"@vitest/snapshot@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/snapshot@npm:4.1.10"
+ dependencies:
+ "@vitest/pretty-format": "npm:4.1.10"
+ "@vitest/utils": "npm:4.1.10"
+ magic-string: "npm:^0.30.21"
+ pathe: "npm:^2.0.3"
+ checksum: 10c0/e71398725f51af5fd0c07bb4b957d0f987daf9b4c564ac24cb2a4d1afde1a6939f535ac17761a32dcc41b0a1e6d4088af66dc44df89fdebebb92aabed1a92b5f
+ languageName: node
+ linkType: hard
+
+"@vitest/spy@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/spy@npm:4.1.10"
+ checksum: 10c0/e5c08012560af6727fd66741c5cda25560d7c5442103d0c83e4276a9b0dd90b9da6cdf823a461195229a16c6ff87768ce788a68d0fa29dea73ee285618668178
+ languageName: node
+ linkType: hard
+
+"@vitest/utils@npm:4.1.10":
+ version: 4.1.10
+ resolution: "@vitest/utils@npm:4.1.10"
+ dependencies:
+ "@vitest/pretty-format": "npm:4.1.10"
+ convert-source-map: "npm:^2.0.0"
+ tinyrainbow: "npm:^3.1.0"
+ checksum: 10c0/05b0ecec6997ec22fc08377e57dbd8fa37992e05961f3a7a916d98b1ab56d15c2a87dbd83d392b628242bdc156b1705e7fa60a3bf0c54bdb51158c153e05fc5d
+ languageName: node
+ linkType: hard
+
"abbrev@npm:^4.0.0":
version: 4.0.0
resolution: "abbrev@npm:4.0.0"
@@ -1448,6 +1666,13 @@ __metadata:
languageName: node
linkType: hard
+"assertion-error@npm:^2.0.1":
+ version: 2.0.1
+ resolution: "assertion-error@npm:2.0.1"
+ checksum: 10c0/bbbcb117ac6480138f8c93cf7f535614282dea9dc828f540cdece85e3c665e8f78958b96afac52f29ff883c72638e6a87d469ecc9fe5bc902df03ed24a55dba8
+ languageName: node
+ linkType: hard
+
"ast-types-flow@npm:^0.0.8":
version: 0.0.8
resolution: "ast-types-flow@npm:0.0.8"
@@ -1601,6 +1826,13 @@ __metadata:
languageName: node
linkType: hard
+"chai@npm:^6.2.2":
+ version: 6.2.2
+ resolution: "chai@npm:6.2.2"
+ checksum: 10c0/e6c69e5f0c11dffe6ea13d0290936ebb68fcc1ad688b8e952e131df6a6d5797d5e860bc55cef1aca2e950c3e1f96daf79e9d5a70fb7dbaab4e46355e2635ed53
+ languageName: node
+ linkType: hard
+
"chalk@npm:^4.0.0":
version: 4.1.2
resolution: "chalk@npm:4.1.2"
@@ -1725,6 +1957,13 @@ __metadata:
languageName: node
linkType: hard
+"convert-source-map@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "convert-source-map@npm:2.0.0"
+ checksum: 10c0/8f2f7a27a1a011cc6cc88cc4da2d7d0cfa5ee0369508baae3d98c260bb3ac520691464e5bbe4ae7cdf09860c1d69ecc6f70c63c6e7c7f7e3f18ec08484dc7d9b
+ languageName: node
+ linkType: hard
+
"cross-spawn@npm:^7.0.6":
version: 7.0.6
resolution: "cross-spawn@npm:7.0.6"
@@ -1858,7 +2097,7 @@ __metadata:
languageName: node
linkType: hard
-"detect-libc@npm:^2.1.2":
+"detect-libc@npm:^2.0.3, detect-libc@npm:^2.1.2":
version: 2.1.2
resolution: "detect-libc@npm:2.1.2"
checksum: 10c0/acc675c29a5649fa1fb6e255f993b8ee829e510b6b56b0910666949c80c364738833417d0edb5f90e4e46be17228b0f2b66a010513984e18b15deeeac49369c4
@@ -1904,6 +2143,18 @@ __metadata:
languageName: node
linkType: hard
+"dompurify@npm:^3.4.13":
+ version: 3.4.13
+ resolution: "dompurify@npm:3.4.13"
+ dependencies:
+ "@types/trusted-types": "npm:^2.0.7"
+ dependenciesMeta:
+ "@types/trusted-types":
+ optional: true
+ checksum: 10c0/9c2a1a71e1a1d8b77953db7a39ffc935ef4ed4f10fe40770232128c2cbfd4c3dc677d3d7bae51eb24cc52d9ff3548612dc540ecb4343e89b26e809d2be2e0cfe
+ languageName: node
+ linkType: hard
+
"dunder-proto@npm:^1.0.0, dunder-proto@npm:^1.0.1":
version: 1.0.1
resolution: "dunder-proto@npm:1.0.1"
@@ -2036,6 +2287,13 @@ __metadata:
languageName: node
linkType: hard
+"es-module-lexer@npm:^2.0.0":
+ version: 2.3.1
+ resolution: "es-module-lexer@npm:2.3.1"
+ checksum: 10c0/ada8b222772b5b8ea92eb6054c383233207418621855a07b480fdd36979b658a41414be09e793fcdd8a67a182741475f47830a01ff2ebd4353d7f6965c7c45f9
+ languageName: node
+ linkType: hard
+
"es-object-atoms@npm:^1.0.0, es-object-atoms@npm:^1.1.1":
version: 1.1.1
resolution: "es-object-atoms@npm:1.1.1"
@@ -2475,6 +2733,15 @@ __metadata:
languageName: node
linkType: hard
+"estree-walker@npm:^3.0.3":
+ version: 3.0.3
+ resolution: "estree-walker@npm:3.0.3"
+ dependencies:
+ "@types/estree": "npm:^1.0.0"
+ checksum: 10c0/c12e3c2b2642d2bcae7d5aa495c60fa2f299160946535763969a1c83fc74518ffa9c2cd3a8b69ac56aea547df6a8aac25f729a342992ef0bbac5f1c73e78995d
+ languageName: node
+ linkType: hard
+
"esutils@npm:^2.0.2":
version: 2.0.3
resolution: "esutils@npm:2.0.3"
@@ -2482,6 +2749,13 @@ __metadata:
languageName: node
linkType: hard
+"expect-type@npm:^1.3.0":
+ version: 1.4.0
+ resolution: "expect-type@npm:1.4.0"
+ checksum: 10c0/d40d76b8570695d36587beb3cc28494da2ca3ec8f04e67f5622ed2d372d850e401a9adef19c6835e1a8173903f157c79540b34c7b3fbd7cd8ce726cc903c57b7
+ languageName: node
+ linkType: hard
+
"exponential-backoff@npm:^3.1.1":
version: 3.1.3
resolution: "exponential-backoff@npm:3.1.3"
@@ -3594,6 +3868,126 @@ __metadata:
languageName: node
linkType: hard
+"lightningcss-android-arm64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-android-arm64@npm:1.33.0"
+ conditions: os=android & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lightningcss-darwin-arm64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-darwin-arm64@npm:1.33.0"
+ conditions: os=darwin & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lightningcss-darwin-x64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-darwin-x64@npm:1.33.0"
+ conditions: os=darwin & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lightningcss-freebsd-x64@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-freebsd-x64@npm:1.33.0"
+ conditions: os=freebsd & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-arm-gnueabihf@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-arm-gnueabihf@npm:1.33.0"
+ conditions: os=linux & cpu=arm
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-arm64-gnu@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-arm64-gnu@npm:1.33.0"
+ conditions: os=linux & cpu=arm64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-arm64-musl@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-arm64-musl@npm:1.33.0"
+ conditions: os=linux & cpu=arm64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-x64-gnu@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-x64-gnu@npm:1.33.0"
+ conditions: os=linux & cpu=x64 & libc=glibc
+ languageName: node
+ linkType: hard
+
+"lightningcss-linux-x64-musl@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-linux-x64-musl@npm:1.33.0"
+ conditions: os=linux & cpu=x64 & libc=musl
+ languageName: node
+ linkType: hard
+
+"lightningcss-win32-arm64-msvc@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-win32-arm64-msvc@npm:1.33.0"
+ conditions: os=win32 & cpu=arm64
+ languageName: node
+ linkType: hard
+
+"lightningcss-win32-x64-msvc@npm:1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss-win32-x64-msvc@npm:1.33.0"
+ conditions: os=win32 & cpu=x64
+ languageName: node
+ linkType: hard
+
+"lightningcss@npm:^1.33.0":
+ version: 1.33.0
+ resolution: "lightningcss@npm:1.33.0"
+ dependencies:
+ detect-libc: "npm:^2.0.3"
+ lightningcss-android-arm64: "npm:1.33.0"
+ lightningcss-darwin-arm64: "npm:1.33.0"
+ lightningcss-darwin-x64: "npm:1.33.0"
+ lightningcss-freebsd-x64: "npm:1.33.0"
+ lightningcss-linux-arm-gnueabihf: "npm:1.33.0"
+ lightningcss-linux-arm64-gnu: "npm:1.33.0"
+ lightningcss-linux-arm64-musl: "npm:1.33.0"
+ lightningcss-linux-x64-gnu: "npm:1.33.0"
+ lightningcss-linux-x64-musl: "npm:1.33.0"
+ lightningcss-win32-arm64-msvc: "npm:1.33.0"
+ lightningcss-win32-x64-msvc: "npm:1.33.0"
+ dependenciesMeta:
+ lightningcss-android-arm64:
+ optional: true
+ lightningcss-darwin-arm64:
+ optional: true
+ lightningcss-darwin-x64:
+ optional: true
+ lightningcss-freebsd-x64:
+ optional: true
+ lightningcss-linux-arm-gnueabihf:
+ optional: true
+ lightningcss-linux-arm64-gnu:
+ optional: true
+ lightningcss-linux-arm64-musl:
+ optional: true
+ lightningcss-linux-x64-gnu:
+ optional: true
+ lightningcss-linux-x64-musl:
+ optional: true
+ lightningcss-win32-arm64-msvc:
+ optional: true
+ lightningcss-win32-x64-msvc:
+ optional: true
+ checksum: 10c0/ce1f8279fbae636dbf37fa6e7385d5f98ed881d72af3362f24afbd4685e19c1fcdfecf17e5dd77f2ebee3d0c23ade276230d85842d07292229a2cffba8ff20a3
+ languageName: node
+ linkType: hard
+
"lilconfig@npm:^3.1.1, lilconfig@npm:^3.1.3":
version: 3.1.3
resolution: "lilconfig@npm:3.1.3"
@@ -3693,6 +4087,15 @@ __metadata:
languageName: node
linkType: hard
+"magic-string@npm:^0.30.21":
+ version: 0.30.21
+ resolution: "magic-string@npm:0.30.21"
+ dependencies:
+ "@jridgewell/sourcemap-codec": "npm:^1.5.5"
+ checksum: 10c0/299378e38f9a270069fc62358522ddfb44e94244baa0d6a8980ab2a9b2490a1d03b236b447eee309e17eb3bddfa482c61259d47960eb018a904f0ded52780c4a
+ languageName: node
+ linkType: hard
+
"markdown-table@npm:^3.0.0":
version: 3.0.4
resolution: "markdown-table@npm:3.0.4"
@@ -4850,6 +5253,13 @@ __metadata:
languageName: node
linkType: hard
+"obug@npm:^2.1.1":
+ version: 2.1.4
+ resolution: "obug@npm:2.1.4"
+ checksum: 10c0/34a0ee97cd88573cfd97d384c2a79f07118ae5680d7e45d1de6e99c74eddefe145e8ca27a2db02195a1ee5fded5aa22b924869c842728c201b9f109a27d0ef19
+ languageName: node
+ linkType: hard
+
"openprinting.github.io@workspace:.":
version: 0.0.0-use.local
resolution: "openprinting.github.io@workspace:."
@@ -4863,6 +5273,7 @@ __metadata:
"@types/react-dom": "npm:^19"
class-variance-authority: "npm:^0.7.1"
clsx: "npm:^2.1.1"
+ dompurify: "npm:^3.4.13"
eslint: "npm:^9"
eslint-config-next: "npm:15.1.6"
fast-xml-parser: "npm:^5.7.0"
@@ -4893,6 +5304,7 @@ __metadata:
typescript: "npm:^5"
unified: "npm:^10.1.0"
unist-util-visit: "npm:^4.1.0"
+ vitest: "npm:^4.1.10"
languageName: unknown
linkType: soft
@@ -5000,6 +5412,13 @@ __metadata:
languageName: node
linkType: hard
+"pathe@npm:^2.0.3":
+ version: 2.0.3
+ resolution: "pathe@npm:2.0.3"
+ checksum: 10c0/c118dc5a8b5c4166011b2b70608762e260085180bb9e33e80a50dcdb1e78c010b1624f4280c492c92b05fc276715a4c357d1f9edc570f8f1b3d90b6839ebaca1
+ languageName: node
+ linkType: hard
+
"picocolors@npm:^1.0.0, picocolors@npm:^1.1.1":
version: 1.1.1
resolution: "picocolors@npm:1.1.1"
@@ -5021,6 +5440,13 @@ __metadata:
languageName: node
linkType: hard
+"picomatch@npm:^4.0.5":
+ version: 4.0.5
+ resolution: "picomatch@npm:4.0.5"
+ checksum: 10c0/947bc6b6e1ff1e6c5aaf95b107a0839d12802f4f7b867663f67d47accba939ca1cb582cf99dfc30438efa1c4648ac5990967e783e8929c36b03e8440704ef1bd
+ languageName: node
+ linkType: hard
+
"pify@npm:^2.3.0":
version: 2.3.0
resolution: "pify@npm:2.3.0"
@@ -5138,7 +5564,7 @@ __metadata:
languageName: node
linkType: hard
-"postcss@npm:^8":
+"postcss@npm:^8, postcss@npm:^8.5.25":
version: 8.5.26
resolution: "postcss@npm:8.5.26"
dependencies:
@@ -5499,6 +5925,61 @@ __metadata:
languageName: node
linkType: hard
+"rolldown@npm:~1.2.1":
+ version: 1.2.4
+ resolution: "rolldown@npm:1.2.4"
+ dependencies:
+ "@oxc-project/types": "npm:=0.144.0"
+ "@rolldown/binding-android-arm64": "npm:1.2.4"
+ "@rolldown/binding-darwin-arm64": "npm:1.2.4"
+ "@rolldown/binding-darwin-x64": "npm:1.2.4"
+ "@rolldown/binding-freebsd-x64": "npm:1.2.4"
+ "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.4"
+ "@rolldown/binding-linux-arm64-gnu": "npm:1.2.4"
+ "@rolldown/binding-linux-arm64-musl": "npm:1.2.4"
+ "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.4"
+ "@rolldown/binding-linux-s390x-gnu": "npm:1.2.4"
+ "@rolldown/binding-linux-x64-gnu": "npm:1.2.4"
+ "@rolldown/binding-linux-x64-musl": "npm:1.2.4"
+ "@rolldown/binding-openharmony-arm64": "npm:1.2.4"
+ "@rolldown/binding-win32-arm64-msvc": "npm:1.2.4"
+ "@rolldown/binding-win32-x64-msvc": "npm:1.2.4"
+ "@rolldown/pluginutils": "npm:^1.0.0"
+ dependenciesMeta:
+ "@rolldown/binding-android-arm64":
+ optional: true
+ "@rolldown/binding-darwin-arm64":
+ optional: true
+ "@rolldown/binding-darwin-x64":
+ optional: true
+ "@rolldown/binding-freebsd-x64":
+ optional: true
+ "@rolldown/binding-linux-arm-gnueabihf":
+ optional: true
+ "@rolldown/binding-linux-arm64-gnu":
+ optional: true
+ "@rolldown/binding-linux-arm64-musl":
+ optional: true
+ "@rolldown/binding-linux-ppc64-gnu":
+ optional: true
+ "@rolldown/binding-linux-s390x-gnu":
+ optional: true
+ "@rolldown/binding-linux-x64-gnu":
+ optional: true
+ "@rolldown/binding-linux-x64-musl":
+ optional: true
+ "@rolldown/binding-openharmony-arm64":
+ optional: true
+ "@rolldown/binding-win32-arm64-msvc":
+ optional: true
+ "@rolldown/binding-win32-x64-msvc":
+ optional: true
+ bin:
+ rolldown: ./bin/cli.mjs
+ checksum: 10c0/438c2222db940fab6e2db1f1cf84ab27c83310959e056fa389d7e2208bcbf58929f970f4dca5bd9c255f319bf657ef1a4a7ea6725213491d28764d867e5407a4
+ languageName: node
+ linkType: hard
+
"run-parallel@npm:^1.1.9":
version: 1.2.0
resolution: "run-parallel@npm:1.2.0"
@@ -5780,6 +6261,13 @@ __metadata:
languageName: node
linkType: hard
+"siginfo@npm:^2.0.0":
+ version: 2.0.0
+ resolution: "siginfo@npm:2.0.0"
+ checksum: 10c0/3def8f8e516fbb34cb6ae415b07ccc5d9c018d85b4b8611e3dc6f8be6d1899f693a4382913c9ed51a06babb5201639d76453ab297d1c54a456544acf5c892e34
+ languageName: node
+ linkType: hard
+
"source-map-js@npm:^1.0.2, source-map-js@npm:^1.2.1":
version: 1.2.1
resolution: "source-map-js@npm:1.2.1"
@@ -5808,6 +6296,20 @@ __metadata:
languageName: node
linkType: hard
+"stackback@npm:0.0.2":
+ version: 0.0.2
+ resolution: "stackback@npm:0.0.2"
+ checksum: 10c0/89a1416668f950236dd5ac9f9a6b2588e1b9b62b1b6ad8dff1bfc5d1a15dbf0aafc9b52d2226d00c28dffff212da464eaeebfc6b7578b9d180cef3e3782c5983
+ languageName: node
+ linkType: hard
+
+"std-env@npm:^4.0.0-rc.1":
+ version: 4.2.0
+ resolution: "std-env@npm:4.2.0"
+ checksum: 10c0/40ac525ce7b7c556abc332a7376f14356eeb1a7f17f6ff9a003eb9f52326ff1f3745d3e1b43452675b1ec6fcc319f1b1d6f3b0d386cf3f91058479ad883cff69
+ languageName: node
+ linkType: hard
+
"stop-iteration-iterator@npm:^1.1.0":
version: 1.1.0
resolution: "stop-iteration-iterator@npm:1.1.0"
@@ -6086,6 +6588,20 @@ __metadata:
languageName: node
linkType: hard
+"tinybench@npm:^2.9.0":
+ version: 2.9.0
+ resolution: "tinybench@npm:2.9.0"
+ checksum: 10c0/c3500b0f60d2eb8db65250afe750b66d51623057ee88720b7f064894a6cb7eb93360ca824a60a31ab16dab30c7b1f06efe0795b352e37914a9d4bad86386a20c
+ languageName: node
+ linkType: hard
+
+"tinyexec@npm:^1.0.2":
+ version: 1.3.0
+ resolution: "tinyexec@npm:1.3.0"
+ checksum: 10c0/e9b89f97489d2aab2cef408da279e6b32547e738d1275032ccb8fd0028a006d93eb70fc51c6cffd9fc2f5aca6c2a273d8b6f73b52d46ee5116da6b94969ef958
+ languageName: node
+ linkType: hard
+
"tinyglobby@npm:^0.2.11, tinyglobby@npm:^0.2.13, tinyglobby@npm:^0.2.15":
version: 0.2.15
resolution: "tinyglobby@npm:0.2.15"
@@ -6096,7 +6612,7 @@ __metadata:
languageName: node
linkType: hard
-"tinyglobby@npm:^0.2.12":
+"tinyglobby@npm:^0.2.12, tinyglobby@npm:^0.2.17":
version: 0.2.17
resolution: "tinyglobby@npm:0.2.17"
dependencies:
@@ -6106,6 +6622,13 @@ __metadata:
languageName: node
linkType: hard
+"tinyrainbow@npm:^3.1.0":
+ version: 3.1.1
+ resolution: "tinyrainbow@npm:3.1.1"
+ checksum: 10c0/f9d2743832c6191f753408f36224fe817620b8abcef572b2e570204c673a901d753ff84ca8e7b88f9c79e934295b3ffc6fcbc56a06f126e24e1ec6186dcad40d
+ languageName: node
+ linkType: hard
+
"to-regex-range@npm:^5.0.1":
version: 5.0.1
resolution: "to-regex-range@npm:5.0.1"
@@ -6564,6 +7087,131 @@ __metadata:
languageName: node
linkType: hard
+"vite@npm:^6.0.0 || ^7.0.0 || ^8.0.0":
+ version: 8.2.1
+ resolution: "vite@npm:8.2.1"
+ dependencies:
+ fsevents: "npm:~2.3.3"
+ lightningcss: "npm:^1.33.0"
+ picomatch: "npm:^4.0.5"
+ postcss: "npm:^8.5.25"
+ rolldown: "npm:~1.2.1"
+ tinyglobby: "npm:^0.2.17"
+ peerDependencies:
+ "@types/node": ^20.19.0 || >=22.12.0
+ "@vitejs/devtools": ^0.4.0
+ esbuild: ^0.27.0 || ^0.28.0
+ jiti: ">=1.21.0"
+ less: ^4.0.0
+ sass: ^1.70.0
+ sass-embedded: ^1.70.0
+ stylus: ">=0.54.8"
+ sugarss: ^5.0.0
+ terser: ^5.16.0
+ tsx: ^4.8.1
+ yaml: ^2.4.2
+ dependenciesMeta:
+ fsevents:
+ optional: true
+ peerDependenciesMeta:
+ "@types/node":
+ optional: true
+ "@vitejs/devtools":
+ optional: true
+ esbuild:
+ optional: true
+ jiti:
+ optional: true
+ less:
+ optional: true
+ sass:
+ optional: true
+ sass-embedded:
+ optional: true
+ stylus:
+ optional: true
+ sugarss:
+ optional: true
+ terser:
+ optional: true
+ tsx:
+ optional: true
+ yaml:
+ optional: true
+ bin:
+ vite: bin/vite.js
+ checksum: 10c0/e958dd07502deeb552f04dba59418ff1eb63183add416fd7a3e3badabf5d36edc6834b94c916f9f9233f976bb1671e3e9989d90e869059af07b0d43f7fc078c2
+ languageName: node
+ linkType: hard
+
+"vitest@npm:^4.1.10":
+ version: 4.1.10
+ resolution: "vitest@npm:4.1.10"
+ dependencies:
+ "@vitest/expect": "npm:4.1.10"
+ "@vitest/mocker": "npm:4.1.10"
+ "@vitest/pretty-format": "npm:4.1.10"
+ "@vitest/runner": "npm:4.1.10"
+ "@vitest/snapshot": "npm:4.1.10"
+ "@vitest/spy": "npm:4.1.10"
+ "@vitest/utils": "npm:4.1.10"
+ es-module-lexer: "npm:^2.0.0"
+ expect-type: "npm:^1.3.0"
+ magic-string: "npm:^0.30.21"
+ obug: "npm:^2.1.1"
+ pathe: "npm:^2.0.3"
+ picomatch: "npm:^4.0.3"
+ std-env: "npm:^4.0.0-rc.1"
+ tinybench: "npm:^2.9.0"
+ tinyexec: "npm:^1.0.2"
+ tinyglobby: "npm:^0.2.15"
+ tinyrainbow: "npm:^3.1.0"
+ vite: "npm:^6.0.0 || ^7.0.0 || ^8.0.0"
+ why-is-node-running: "npm:^2.3.0"
+ peerDependencies:
+ "@edge-runtime/vm": "*"
+ "@opentelemetry/api": ^1.9.0
+ "@types/node": ^20.0.0 || ^22.0.0 || >=24.0.0
+ "@vitest/browser-playwright": 4.1.10
+ "@vitest/browser-preview": 4.1.10
+ "@vitest/browser-webdriverio": 4.1.10
+ "@vitest/coverage-istanbul": 4.1.10
+ "@vitest/coverage-v8": 4.1.10
+ "@vitest/ui": 4.1.10
+ happy-dom: "*"
+ jsdom: "*"
+ vite: ^6.0.0 || ^7.0.0 || ^8.0.0
+ peerDependenciesMeta:
+ "@edge-runtime/vm":
+ optional: true
+ "@opentelemetry/api":
+ optional: true
+ "@types/node":
+ optional: true
+ "@vitest/browser-playwright":
+ optional: true
+ "@vitest/browser-preview":
+ optional: true
+ "@vitest/browser-webdriverio":
+ optional: true
+ "@vitest/coverage-istanbul":
+ optional: true
+ "@vitest/coverage-v8":
+ optional: true
+ "@vitest/ui":
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+ vite:
+ optional: false
+ bin:
+ vitest: ./vitest.mjs
+ checksum: 10c0/ff07294a57f9c62f3b503f7cf88a52ee0753ed26389a49cda430387a3898f39d80af47180b0af19e27acab5bd11ae95706bd4b44ce8befc97d3ae49af6ca4fc1
+ languageName: node
+ linkType: hard
+
"web-namespaces@npm:^2.0.0":
version: 2.0.1
resolution: "web-namespaces@npm:2.0.1"
@@ -6654,6 +7302,18 @@ __metadata:
languageName: node
linkType: hard
+"why-is-node-running@npm:^2.3.0":
+ version: 2.3.0
+ resolution: "why-is-node-running@npm:2.3.0"
+ dependencies:
+ siginfo: "npm:^2.0.0"
+ stackback: "npm:0.0.2"
+ bin:
+ why-is-node-running: cli.js
+ checksum: 10c0/1cde0b01b827d2cf4cb11db962f3958b9175d5d9e7ac7361d1a7b0e2dc6069a263e69118bd974c4f6d0a890ef4eedfe34cf3d5167ec14203dbc9a18620537054
+ languageName: node
+ linkType: hard
+
"word-wrap@npm:^1.2.5":
version: 1.2.5
resolution: "word-wrap@npm:1.2.5"