From df882e2db04e5cc47830224478f016665838d048 Mon Sep 17 00:00:00 2001 From: gativarshney Date: Tue, 23 Jun 2026 22:34:07 +0530 Subject: [PATCH 01/40] feat(pipeline): add printer feature vectorization script --- scripts/foomatic/vectorize.ts | 179 ++++++++++++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 scripts/foomatic/vectorize.ts diff --git a/scripts/foomatic/vectorize.ts b/scripts/foomatic/vectorize.ts new file mode 100644 index 00000000..68c41080 --- /dev/null +++ b/scripts/foomatic/vectorize.ts @@ -0,0 +1,179 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import type { Printer } from "../../lib/foomatic/types"; + +const ROOT_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); +const INPUT_FILE = path.join( + ROOT_DIR, + "public", + "foomatic-db", + "printers.json", +); +const OUTPUT_FILE = path.join( + ROOT_DIR, + "public", + "foomatic-db", + "feature-matrix.json", +); + +interface Vocabulary { + manufacturers: string[]; + types: string[]; + connectivity: string[]; +} + +interface FeatureMatrix { + printerCount: number; + featureCount: number; + featureNames: string[]; + vocab: Vocabulary; + ids: string[]; + matrix: number[][]; +} + +function encodeBool(value: boolean | string | undefined): number { + if (value === true) return 1.0; + if (value === false) return 0.0; + + // Unknown values stay neutral instead of biasing similarity. + return 0.5; +} + +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; + } +} + +function trim(value: string | undefined): string { + return (value ?? "").trim(); +} + +function buildVocabularies(printers: Printer[]): Vocabulary { + const manufacturers = new Set(); + const types = new Set(); + const connectivityOpts = new Set(); + + for (const p of printers) { + const make = trim(p.manufacturer); + if (make) manufacturers.add(make); + + const type = trim(p.type); + if (type && type !== "unknown") types.add(type); + + for (const c of p.connectivity ?? []) { + const conn = trim(c); + if (conn) connectivityOpts.add(conn); + } + } + + return { + manufacturers: [...manufacturers].sort(), + types: [...types].sort(), + connectivity: [...connectivityOpts].sort(), + }; +} + +function buildFeatureNames(vocab: Vocabulary): string[] { + return [ + ...vocab.manufacturers.map((m) => `manufacturer:${m}`), + ...vocab.types.map((t) => `type:${t}`), + ...vocab.connectivity.map((c) => `connectivity:${c}`), + "color", + "duplex", + "functionality", + ]; +} + +/* + * Feature extraction intentionally stays unweighted. + * Similarity weighting belongs in compute-similarity.ts. + */ +function encodePrinter(printer: Printer, vocab: Vocabulary): number[] { + const make = trim(printer.manufacturer); + const type = trim(printer.type); + const conns = new Set((printer.connectivity ?? []).map(trim)); + + return [ + ...vocab.manufacturers.map((m) => (m === make ? 1.0 : 0.0)), + ...vocab.types.map((t) => (t === type ? 1.0 : 0.0)), + ...vocab.connectivity.map((c) => (conns.has(c) ? 1.0 : 0.0)), + encodeBool(printer.color), + encodeBool(printer.duplex), + encodeFunctionality(printer.functionality), + ]; +} + +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 → ${OUTPUT_FILE}`); +} + +main(); From a3708693f2e911e038a854b3478ca25df7977dd3 Mon Sep 17 00:00:00 2001 From: gativarshney Date: Tue, 23 Jun 2026 22:42:59 +0530 Subject: [PATCH 02/40] feat(recommendations): implement printer compatibility similarity pipeline --- scripts/foomatic/compute-similarity.ts | 412 +++++++++++++++++++++++++ scripts/foomatic/vectorize.ts | 156 +++++++--- 2 files changed, 528 insertions(+), 40 deletions(-) create mode 100644 scripts/foomatic/compute-similarity.ts diff --git a/scripts/foomatic/compute-similarity.ts b/scripts/foomatic/compute-similarity.ts new file mode 100644 index 00000000..b9e0391c --- /dev/null +++ b/scripts/foomatic/compute-similarity.ts @@ -0,0 +1,412 @@ +import fs from "fs"; +import path from "path"; +import { fileURLToPath } from "url"; +import type { Printer } from "../../lib/foomatic/types"; + +const ROOT_DIR = path.join( + path.dirname(fileURLToPath(import.meta.url)), + "..", + "..", +); + +const MATRIX_FILE = path.join( + ROOT_DIR, + "public", + "foomatic-db", + "feature-matrix.json", +); + +const PRINTERS_FILE = path.join( + ROOT_DIR, + "public", + "foomatic-db", + "printers.json", +); + +const OUTPUT_FILE = path.join( + ROOT_DIR, + "public", + "foomatic-db", + "recommendations.json", +); + +const TOP_K = 10; +const MIN_SIMILARITY_SCORE = 0.25; + +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; +} + +interface Candidate { + index: number; + score: number; +} + +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"], +]; + +function trim(value: string | undefined): string { + return (value ?? "").trim(); +} + +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(); +} + +function getRecommendedDriverFamily(printer: Printer): string | null { + const driver = trim(printer.recommended_driver); + + if (!driver) { + return null; + } + + return normalizeDriverFamily(driver); +} + +function getSupportedDriverFamilies(printer: Printer): string[] { + const families = new Set(); + + for (const driver of printer.drivers ?? []) { + const family = normalizeDriverFamily(driver.name); + + if (family) { + families.add(family); + } + } + + return [...families]; +} + +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; +} + +function magnitude(vec: number[]): number { + return Math.sqrt(dotProduct(vec, vec)); +} + +function cosineSimilarity( + a: number[], + b: number[], + magA: number, + magB: number, +): number { + if (magA === 0 || magB === 0) { + return 0; + } + + return dotProduct(a, b) / (magA * magB); +} + +function insertTopK(topK: Candidate[], candidate: Candidate): void { + if (topK.length < TOP_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); + } +} + +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.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"}`, + ); + } + } +} + +function loadFeatureMatrix(): FeatureMatrix { + if (!fs.existsSync(MATRIX_FILE)) { + throw new Error(`Missing feature matrix: ${MATRIX_FILE}`); + } + + 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}`); + } + + 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: Candidate[] = []; + + for (let j = 0; j < matrixData.printerCount; j++) { + if (i === j) { + continue; + } + + const vecB = matrixData.matrix[j]; + const magB = magnitudes[j]; + + const score = cosineSimilarity(vecA, vecB, magA, magB); + + if (score < MIN_SIMILARITY_SCORE) { + continue; + } + + insertTopK(topK, { + index: j, + score, + }); + } + + topK.sort((a, b) => b.score - a.score); + + 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)); + + 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/vectorize.ts b/scripts/foomatic/vectorize.ts index 68c41080..74207b4a 100644 --- a/scripts/foomatic/vectorize.ts +++ b/scripts/foomatic/vectorize.ts @@ -8,12 +8,14 @@ const ROOT_DIR = path.join( "..", "..", ); + const INPUT_FILE = path.join( ROOT_DIR, "public", "foomatic-db", "printers.json", ); + const OUTPUT_FILE = path.join( ROOT_DIR, "public", @@ -22,9 +24,9 @@ const OUTPUT_FILE = path.join( ); interface Vocabulary { - manufacturers: string[]; + recommendedDrivers: string[]; + supportedDrivers: string[]; types: string[]; - connectivity: string[]; } interface FeatureMatrix { @@ -36,12 +38,29 @@ interface FeatureMatrix { matrix: number[][]; } -function encodeBool(value: boolean | string | undefined): number { - if (value === true) return 1.0; - if (value === false) return 0.0; +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"], +]; + +const RECOMMENDED_DRIVER_WEIGHT = 3.0; +const SUPPORTED_DRIVER_WEIGHT = 1.0; +const TYPE_WEIGHT = 0.5; +const FUNCTIONALITY_WEIGHT = 0.25; - // Unknown values stay neutral instead of biasing similarity. - return 0.5; +function trim(value: string | undefined): string { + return (value ?? "").trim(); } function encodeFunctionality(value: string | undefined): number { @@ -57,62 +76,105 @@ function encodeFunctionality(value: string | undefined): number { } } -function trim(value: string | undefined): string { - return (value ?? "").trim(); +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(); +} + +function getSupportedDriverFamilies(printer: Printer): string[] { + const families = new Set(); + + for (const driver of printer.drivers ?? []) { + const family = normalizeDriverFamily(driver.name); + + if (family) { + families.add(family); + } + } + + return [...families]; +} + +function getRecommendedDriverFamily(printer: Printer): string | null { + const driver = trim(printer.recommended_driver); + + if (!driver) { + return null; + } + + return normalizeDriverFamily(driver); } function buildVocabularies(printers: Printer[]): Vocabulary { - const manufacturers = new Set(); + const recommendedDrivers = new Set(); + + const supportedDrivers = new Set(); + const types = new Set(); - const connectivityOpts = new Set(); - for (const p of printers) { - const make = trim(p.manufacturer); - if (make) manufacturers.add(make); + for (const printer of printers) { + const recommended = getRecommendedDriverFamily(printer); + + if (recommended) { + recommendedDrivers.add(recommended); + } + + for (const family of getSupportedDriverFamilies(printer)) { + supportedDrivers.add(family); + } - const type = trim(p.type); - if (type && type !== "unknown") types.add(type); + const type = trim(printer.type); - for (const c of p.connectivity ?? []) { - const conn = trim(c); - if (conn) connectivityOpts.add(conn); + if (type && type !== "unknown") { + types.add(type); } } return { - manufacturers: [...manufacturers].sort(), + recommendedDrivers: [...recommendedDrivers].sort(), + supportedDrivers: [...supportedDrivers].sort(), types: [...types].sort(), - connectivity: [...connectivityOpts].sort(), }; } function buildFeatureNames(vocab: Vocabulary): string[] { return [ - ...vocab.manufacturers.map((m) => `manufacturer:${m}`), - ...vocab.types.map((t) => `type:${t}`), - ...vocab.connectivity.map((c) => `connectivity:${c}`), - "color", - "duplex", + ...vocab.recommendedDrivers.map((driver) => `recommended_driver:${driver}`), + + ...vocab.supportedDrivers.map((driver) => `supported_driver:${driver}`), + + ...vocab.types.map((type) => `type:${type}`), + "functionality", ]; } -/* - * Feature extraction intentionally stays unweighted. - * Similarity weighting belongs in compute-similarity.ts. - */ function encodePrinter(printer: Printer, vocab: Vocabulary): number[] { - const make = trim(printer.manufacturer); + const recommended = getRecommendedDriverFamily(printer); + + const supported = new Set(getSupportedDriverFamilies(printer)); + const type = trim(printer.type); - const conns = new Set((printer.connectivity ?? []).map(trim)); return [ - ...vocab.manufacturers.map((m) => (m === make ? 1.0 : 0.0)), - ...vocab.types.map((t) => (t === type ? 1.0 : 0.0)), - ...vocab.connectivity.map((c) => (conns.has(c) ? 1.0 : 0.0)), - encodeBool(printer.color), - encodeBool(printer.duplex), - encodeFunctionality(printer.functionality), + ...vocab.recommendedDrivers.map((driver) => + driver === recommended ? RECOMMENDED_DRIVER_WEIGHT : 0, + ), + + ...vocab.supportedDrivers.map((driver) => + supported.has(driver) ? SUPPORTED_DRIVER_WEIGHT : 0, + ), + + ...vocab.types.map((t) => (t === type ? TYPE_WEIGHT : 0)), + + encodeFunctionality(printer.functionality) * FUNCTIONALITY_WEIGHT, ]; } @@ -151,13 +213,16 @@ function main(): void { 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)); } @@ -170,10 +235,21 @@ function main(): void { matrix, }; - fs.mkdirSync(path.dirname(OUTPUT_FILE), { recursive: true }); + fs.mkdirSync(path.dirname(OUTPUT_FILE), { + recursive: true, + }); + fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2)); - console.log(`✓ Feature matrix → ${OUTPUT_FILE}`); + 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}`); } main(); From b1abc786ed879b6a570f7f94553030384f476efd Mon Sep 17 00:00:00 2001 From: gativarshney Date: Tue, 23 Jun 2026 23:07:13 +0530 Subject: [PATCH 03/40] feat(recommendations): surface compatible printer alternatives on printer pages --- components/foomatic/PrinterPageClient.tsx | 2 + .../foomatic/RecommendedPrintersSection.tsx | 164 ++++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 components/foomatic/RecommendedPrintersSection.tsx diff --git a/components/foomatic/PrinterPageClient.tsx b/components/foomatic/PrinterPageClient.tsx index 21d53e1f..57be51d0 100644 --- a/components/foomatic/PrinterPageClient.tsx +++ b/components/foomatic/PrinterPageClient.tsx @@ -23,6 +23,7 @@ import { withBasePath } from "@/lib/foomatic/base-path" import { driverHref, ppdViewHref } from "@/lib/foomatic/routes" import type { Printer } from "@/lib/foomatic/types" import { calculateAccurateStatus } from "@/lib/foomatic/utils" +import RecommendedPrintersSection from "@/components/foomatic/RecommendedPrintersSection" interface PrinterPageClientProps { printerId: string @@ -410,6 +411,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..62cf39fe --- /dev/null +++ b/components/foomatic/RecommendedPrintersSection.tsx @@ -0,0 +1,164 @@ +"use client" + +import { useEffect, useState } from "react" +import Link from "next/link" + +import { + FoomaticBadge, + FoomaticCard, + FoomaticStatusBadge, +} from "@/components/foomatic/shared" +import { withBasePath } from "@/lib/foomatic/base-path" +import { printerHref } from "@/lib/foomatic/routes" + +interface Recommendation { + id: string + score: number + sharedFeatures: string[] +} + +interface RecommendationsData { + recommendations: Record +} + +interface PrinterSummary { + id: string + manufacturer: string + model: string + status: string + type: string + functionality: string + driverCount: number +} + +interface PrintersMapData { + printers: PrinterSummary[] +} + +interface RecommendedPrintersSectionProps { + printerId: string +} + +export default function RecommendedPrintersSection({ + printerId, +}: RecommendedPrintersSectionProps) { + const [recommendations, setRecommendations] = useState([]) + const [printerMap, setPrinterMap] = useState>(new Map()) + + useEffect(() => { + async function loadData() { + try { + const [recommendationsResponse, printersMapResponse] = + await Promise.all([ + fetch(withBasePath("/foomatic-db/recommendations.json")), + fetch(withBasePath("/foomatic-db/printersMap.json")), + ]) + + if (!recommendationsResponse.ok || !printersMapResponse.ok) { + return + } + + const recommendationsData: RecommendationsData = + await recommendationsResponse.json() + + const printersMapData: PrintersMapData = + await printersMapResponse.json() + + setRecommendations( + recommendationsData.recommendations?.[printerId]?.slice(0, 3) ?? [] + ) + + setPrinterMap( + new Map( + printersMapData.printers.map((printer) => [ + printer.id, + printer, + ]) + ) + ) + } catch (error) { + console.error("Failed to load recommendations:", error) + } + } + + loadData() + }, [printerId]) + + if (recommendations.length === 0) { + return null + } + + return ( +
+
+

+ Recommended alternative printers +

+

+ Similar printers based on Linux driver compatibility and shared + capabilities. +

+
+ +
+ {recommendations.map((recommendation) => { + const printer = printerMap.get(recommendation.id) + + if (!printer) { + return null + } + + return ( + +
+
+
+

+ {printer.manufacturer} +

+

+ {printer.model} +

+
+ +
+ + + + {printer.driverCount} driver + {printer.driverCount === 1 ? "" : "s"} + + + {printer.type !== "unknown" ? ( + + {printer.type} + + ) : null} +
+ +
+ {recommendation.sharedFeatures.map((feature) => ( + + {feature} + + ))} +
+
+ + + View printer + +
+
+ ) + })} +
+
+ ) +} From 6a937809c6943e39b6d7f063ac55c5849e37bb9c Mon Sep 17 00:00:00 2001 From: gativarshney Date: Tue, 23 Jun 2026 23:31:37 +0530 Subject: [PATCH 04/40] feat(recommendations): add color-aware printer similarity features --- scripts/foomatic/combine-data.ts | 8 ++++++++ scripts/foomatic/compute-similarity.ts | 4 ++++ scripts/foomatic/vectorize.ts | 5 +++++ 3 files changed, 17 insertions(+) diff --git a/scripts/foomatic/combine-data.ts b/scripts/foomatic/combine-data.ts index e614baab..3974a27b 100644 --- a/scripts/foomatic/combine-data.ts +++ b/scripts/foomatic/combine-data.ts @@ -293,6 +293,14 @@ function getBooleanCapability(value) { } function getColorCapability(printer) { + 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 ?? diff --git a/scripts/foomatic/compute-similarity.ts b/scripts/foomatic/compute-similarity.ts index b9e0391c..b91f2bd3 100644 --- a/scripts/foomatic/compute-similarity.ts +++ b/scripts/foomatic/compute-similarity.ts @@ -191,6 +191,10 @@ function computeSharedFeatures(a: Printer, b: Printer): string[] { shared.push(label[a.type] ?? a.type); } + if (a.color === true && b.color === true) { + shared.push("Color printing"); + } + if ( a.functionality && b.functionality && diff --git a/scripts/foomatic/vectorize.ts b/scripts/foomatic/vectorize.ts index 74207b4a..8f81df34 100644 --- a/scripts/foomatic/vectorize.ts +++ b/scripts/foomatic/vectorize.ts @@ -58,6 +58,7 @@ 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; function trim(value: string | undefined): string { return (value ?? "").trim(); @@ -153,6 +154,8 @@ function buildFeatureNames(vocab: Vocabulary): string[] { ...vocab.types.map((type) => `type:${type}`), "functionality", + + "color", ]; } @@ -175,6 +178,8 @@ function encodePrinter(printer: Printer, vocab: Vocabulary): number[] { ...vocab.types.map((t) => (t === type ? TYPE_WEIGHT : 0)), encodeFunctionality(printer.functionality) * FUNCTIONALITY_WEIGHT, + + printer.color === true ? COLOR_WEIGHT : 0, ]; } From f3aab1f1588ffd566a5c6a113ff626a8d0f16e9a Mon Sep 17 00:00:00 2001 From: gativarshney Date: Tue, 23 Jun 2026 23:54:16 +0530 Subject: [PATCH 05/40] feat(recommendations): add commandset-aware printer similarity --- lib/foomatic/types.ts | 1 + scripts/foomatic/combine-data.ts | 50 ++++++++++++++++++++++++++ scripts/foomatic/compute-similarity.ts | 22 ++++++++++++ scripts/foomatic/vectorize.ts | 23 ++++++++++++ 4 files changed, 96 insertions(+) diff --git a/lib/foomatic/types.ts b/lib/foomatic/types.ts index 8c50991e..33684a6d 100644 --- a/lib/foomatic/types.ts +++ b/lib/foomatic/types.ts @@ -45,6 +45,7 @@ export interface Printer { ppdPath?: string supportContacts?: SupportContact[] commandsets?: string[] + commandsetTokens?: string[] ppdOptions?: PpdOption[] color?: boolean | "unknown" duplex?: boolean | "unknown" diff --git a/scripts/foomatic/combine-data.ts b/scripts/foomatic/combine-data.ts index 3974a27b..f753d9a7 100644 --- a/scripts/foomatic/combine-data.ts +++ b/scripts/foomatic/combine-data.ts @@ -199,6 +199,55 @@ function getCommandsets(printer) { return Array.from(new Set(sets)); } +function normalizeCommandsetToken(raw) { + 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; +} + +/* + * Distinct from getCommandsets(), which produces human-readable labels for + * display (e.g. "PostScript 3"). This produces canonical short tokens + * (e.g. "POSTSCRIPT") from autodetect data, used as similarity features. + */ +function getCommandsetTokens(printer) { + const a = printer.autodetect; + if (!a) return []; + + const rawTokens = []; + + const pushCommaSplit = (val) => { + 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 = []; + for (const raw of rawTokens) { + const norm = normalizeCommandsetToken(raw); + if (norm && !seen.has(norm)) { + seen.add(norm); + result.push(norm); + } + } + return result.sort(); +} + function getPpdOptions(printer) { const ppdNode = printer.ppdOptions || @@ -523,6 +572,7 @@ 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), diff --git a/scripts/foomatic/compute-similarity.ts b/scripts/foomatic/compute-similarity.ts index b91f2bd3..04546396 100644 --- a/scripts/foomatic/compute-similarity.ts +++ b/scripts/foomatic/compute-similarity.ts @@ -195,6 +195,28 @@ function computeSharedFeatures(a: Printer, b: Printer): string[] { 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.functionality && b.functionality && diff --git a/scripts/foomatic/vectorize.ts b/scripts/foomatic/vectorize.ts index 8f81df34..dc2d2fb2 100644 --- a/scripts/foomatic/vectorize.ts +++ b/scripts/foomatic/vectorize.ts @@ -27,6 +27,7 @@ interface Vocabulary { recommendedDrivers: string[]; supportedDrivers: string[]; types: string[]; + commandsets: string[]; } interface FeatureMatrix { @@ -59,6 +60,8 @@ 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; function trim(value: string | undefined): string { return (value ?? "").trim(); @@ -120,6 +123,8 @@ function buildVocabularies(printers: Printer[]): Vocabulary { const types = new Set(); + const commandsetFreq = new Map(); + for (const printer of printers) { const recommended = getRecommendedDriverFamily(printer); @@ -136,12 +141,22 @@ function buildVocabularies(printers: Printer[]): Vocabulary { 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(); + return { recommendedDrivers: [...recommendedDrivers].sort(), supportedDrivers: [...supportedDrivers].sort(), types: [...types].sort(), + commandsets, }; } @@ -156,6 +171,8 @@ function buildFeatureNames(vocab: Vocabulary): string[] { "functionality", "color", + + ...vocab.commandsets.map((cs) => `commandset:${cs}`), ]; } @@ -180,6 +197,10 @@ function encodePrinter(printer: Printer, vocab: Vocabulary): number[] { encodeFunctionality(printer.functionality) * FUNCTIONALITY_WEIGHT, printer.color === true ? COLOR_WEIGHT : 0, + + ...vocab.commandsets.map((cs) => + (printer.commandsetTokens ?? []).includes(cs) ? COMMANDSET_WEIGHT : 0, + ), ]; } @@ -255,6 +276,8 @@ function main(): void { console.log(`Supported drivers: ${vocab.supportedDrivers.length}`); console.log(`Printer types: ${vocab.types.length}`); + + console.log(`Commandset tokens: ${vocab.commandsets.length}`); } main(); From 73a91614a261ed86a6804c1d5e58fcbac3d5ed5e Mon Sep 17 00:00:00 2001 From: gativarshney Date: Wed, 24 Jun 2026 00:03:21 +0530 Subject: [PATCH 06/40] feat(recommendations): add PostScript and PCL language-aware similarity --- lib/foomatic/types.ts | 2 ++ scripts/foomatic/combine-data.ts | 27 ++++++++++++++++++++++++++ scripts/foomatic/compute-similarity.ts | 10 ++++++++++ scripts/foomatic/vectorize.ts | 12 ++++++++++++ 4 files changed, 51 insertions(+) diff --git a/lib/foomatic/types.ts b/lib/foomatic/types.ts index 33684a6d..e82b6d47 100644 --- a/lib/foomatic/types.ts +++ b/lib/foomatic/types.ts @@ -51,6 +51,8 @@ export interface Printer { duplex?: boolean | "unknown" recommended?: boolean hasOwnEntry?: boolean + psLevel?: number | null + pclLevel?: number | null } export type PrinterStatus = 'Perfect' | 'Mostly' | 'Unsupported' | 'Unknown' diff --git a/scripts/foomatic/combine-data.ts b/scripts/foomatic/combine-data.ts index f753d9a7..5948f762 100644 --- a/scripts/foomatic/combine-data.ts +++ b/scripts/foomatic/combine-data.ts @@ -358,6 +358,31 @@ function getColorCapability(printer) { ); } +function getPSLevel(printer) { + 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; +} + +function getPCLLevel(printer) { + 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; +} + function getDuplexCapability(printer) { return getBooleanCapability( printer.duplex ?? @@ -576,6 +601,8 @@ async function combineData() { ppdOptions: getPpdOptions(printer), color: getColorCapability(printer), duplex: getDuplexCapability(printer), + psLevel: getPSLevel(printer), + pclLevel: getPCLLevel(printer), recommended: Boolean(printer.driver || recommendedDriverId), hasOwnEntry: printersWithOwnEntry.has(printerId), }); diff --git a/scripts/foomatic/compute-similarity.ts b/scripts/foomatic/compute-similarity.ts index 04546396..3eb1e04e 100644 --- a/scripts/foomatic/compute-similarity.ts +++ b/scripts/foomatic/compute-similarity.ts @@ -217,6 +217,16 @@ function computeSharedFeatures(a: Printer, b: Printer): string[] { } } + 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"); + } + if ( a.functionality && b.functionality && diff --git a/scripts/foomatic/vectorize.ts b/scripts/foomatic/vectorize.ts index dc2d2fb2..214ece16 100644 --- a/scripts/foomatic/vectorize.ts +++ b/scripts/foomatic/vectorize.ts @@ -62,6 +62,8 @@ 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; function trim(value: string | undefined): string { return (value ?? "").trim(); @@ -173,6 +175,11 @@ function buildFeatureNames(vocab: Vocabulary): string[] { "color", ...vocab.commandsets.map((cs) => `commandset:${cs}`), + + "lang:postscript", + "lang:postscript_3", + "lang:pcl", + "lang:pcl_6", ]; } @@ -201,6 +208,11 @@ function encodePrinter(printer: Printer, vocab: Vocabulary): number[] { ...vocab.commandsets.map((cs) => (printer.commandsetTokens ?? []).includes(cs) ? COMMANDSET_WEIGHT : 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, ]; } From 2d6f3b7d13b7fe52c54a63ff2a78f9101a0788c6 Mon Sep 17 00:00:00 2001 From: gativarshney Date: Wed, 24 Jun 2026 00:11:24 +0530 Subject: [PATCH 07/40] feat(recommendations): add resolution-aware printer similarity --- lib/foomatic/types.ts | 1 + scripts/foomatic/combine-data.ts | 10 ++++++++++ scripts/foomatic/compute-similarity.ts | 13 +++++++++++++ scripts/foomatic/vectorize.ts | 11 +++++++++++ 4 files changed, 35 insertions(+) diff --git a/lib/foomatic/types.ts b/lib/foomatic/types.ts index e82b6d47..c06f29a1 100644 --- a/lib/foomatic/types.ts +++ b/lib/foomatic/types.ts @@ -53,6 +53,7 @@ export interface Printer { hasOwnEntry?: boolean psLevel?: number | null pclLevel?: number | null + maxDpi?: number | null } export type PrinterStatus = 'Perfect' | 'Mostly' | 'Unsupported' | 'Unknown' diff --git a/scripts/foomatic/combine-data.ts b/scripts/foomatic/combine-data.ts index 5948f762..d9e90813 100644 --- a/scripts/foomatic/combine-data.ts +++ b/scripts/foomatic/combine-data.ts @@ -341,6 +341,15 @@ function getBooleanCapability(value) { return "unknown"; } +function getMaxDpi(printer) { + 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; +} + function getColorCapability(printer) { if (printer.mechanism && "color" in printer.mechanism) { return true; @@ -603,6 +612,7 @@ async function combineData() { 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 index 3eb1e04e..38c9c4c5 100644 --- a/scripts/foomatic/compute-similarity.ts +++ b/scripts/foomatic/compute-similarity.ts @@ -227,6 +227,19 @@ function computeSharedFeatures(a: Printer, b: Printer): string[] { shared.push(pclLabel[a.pclLevel] ?? "PCL"); } + const resTier = (dpi: number | null | undefined): string | null => { + if (dpi == null) return null; + if (dpi <= 300) return "300 dpi"; + if (dpi <= 600) return "600 dpi"; + if (dpi <= 1200) return "1200 dpi"; + return "2400+ dpi"; + }; + const aTier = resTier(a.maxDpi); + const bTier = resTier(b.maxDpi); + if (aTier != null && aTier === bTier) { + shared.push(`${aTier} resolution`); + } + if ( a.functionality && b.functionality && diff --git a/scripts/foomatic/vectorize.ts b/scripts/foomatic/vectorize.ts index 214ece16..4f235bb9 100644 --- a/scripts/foomatic/vectorize.ts +++ b/scripts/foomatic/vectorize.ts @@ -64,6 +64,7 @@ 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 trim(value: string | undefined): string { return (value ?? "").trim(); @@ -180,6 +181,11 @@ function buildFeatureNames(vocab: Vocabulary): string[] { "lang:postscript_3", "lang:pcl", "lang:pcl_6", + + "res:300", + "res:600", + "res:1200", + "res:2400plus", ]; } @@ -213,6 +219,11 @@ function encodePrinter(printer: Printer, vocab: Vocabulary): number[] { 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, ]; } From 983f915c2b166457371ca09cac220e1cd5db50fc Mon Sep 17 00:00:00 2001 From: Gati Varshney <171050892+gativarshney@users.noreply.github.com> Date: Sat, 1 Aug 2026 19:51:24 +0530 Subject: [PATCH 08/40] perf(recommendations): split recommendation data per printer --- .../foomatic/RecommendedPrintersSection.tsx | 20 ++++++++----------- scripts/foomatic/compute-similarity.ts | 16 +++++++++++++++ 2 files changed, 24 insertions(+), 12 deletions(-) diff --git a/components/foomatic/RecommendedPrintersSection.tsx b/components/foomatic/RecommendedPrintersSection.tsx index 62cf39fe..632effdb 100644 --- a/components/foomatic/RecommendedPrintersSection.tsx +++ b/components/foomatic/RecommendedPrintersSection.tsx @@ -17,10 +17,6 @@ interface Recommendation { sharedFeatures: string[] } -interface RecommendationsData { - recommendations: Record -} - interface PrinterSummary { id: string manufacturer: string @@ -50,23 +46,23 @@ export default function RecommendedPrintersSection({ try { const [recommendationsResponse, printersMapResponse] = await Promise.all([ - fetch(withBasePath("/foomatic-db/recommendations.json")), + fetch( + withBasePath(`/foomatic-db/recommendations/${printerId}.json`) + ), fetch(withBasePath("/foomatic-db/printersMap.json")), ]) - if (!recommendationsResponse.ok || !printersMapResponse.ok) { + if (!printersMapResponse.ok) { return } - const recommendationsData: RecommendationsData = - await recommendationsResponse.json() - const printersMapData: PrintersMapData = await printersMapResponse.json() - setRecommendations( - recommendationsData.recommendations?.[printerId]?.slice(0, 3) ?? [] - ) + if (recommendationsResponse.ok) { + const recs: Recommendation[] = await recommendationsResponse.json() + setRecommendations(recs.slice(0, 3)) + } setPrinterMap( new Map( diff --git a/scripts/foomatic/compute-similarity.ts b/scripts/foomatic/compute-similarity.ts index 38c9c4c5..d042cfc6 100644 --- a/scripts/foomatic/compute-similarity.ts +++ b/scripts/foomatic/compute-similarity.ts @@ -30,6 +30,13 @@ const OUTPUT_FILE = path.join( "recommendations.json", ); +const RECOMMENDATIONS_DIR = path.join( + ROOT_DIR, + "public", + "foomatic-db", + "recommendations", +); + const TOP_K = 10; const MIN_SIMILARITY_SCORE = 0.25; @@ -441,6 +448,15 @@ function main(): void { fs.writeFileSync(OUTPUT_FILE, JSON.stringify(output, null, 2)); + fs.mkdirSync(RECOMMENDATIONS_DIR, { recursive: true }); + + for (const [printerId, recs] of Object.entries(output.recommendations)) { + fs.writeFileSync( + path.join(RECOMMENDATIONS_DIR, `${printerId}.json`), + JSON.stringify(recs), + ); + } + logScoreDistribution(recommendations); logSpotCheck(recommendations, printerMap); From 403ba1521db080fd8e9d6cbec12559f6bf82f708 Mon Sep 17 00:00:00 2001 From: Gati Varshney <171050892+gativarshney@users.noreply.github.com> Date: Sat, 1 Aug 2026 20:19:44 +0530 Subject: [PATCH 09/40] feat(ui): surface printer capabilities and recommendation confidence --- app/foomatic/printers/page.tsx | 9 ++++----- components/foomatic/PrinterPageClient.tsx | 8 ++++++++ .../foomatic/RecommendedPrintersSection.tsx | 19 +++++++++++++------ lib/foomatic/types.ts | 1 + scripts/foomatic/split-printers.ts | 4 +++- 5 files changed, 29 insertions(+), 12 deletions(-) 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/PrinterPageClient.tsx b/components/foomatic/PrinterPageClient.tsx index 57be51d0..6918e8ff 100644 --- a/components/foomatic/PrinterPageClient.tsx +++ b/components/foomatic/PrinterPageClient.tsx @@ -246,6 +246,14 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps) ) : null} + {printer.maxDpi != null ? ( +
+
+ Max resolution +
+
{printer.maxDpi} dpi
+
+ ) : null} {printer.connectivity && printer.connectivity.length > 0 ? (
diff --git a/components/foomatic/RecommendedPrintersSection.tsx b/components/foomatic/RecommendedPrintersSection.tsx index 632effdb..6604af8e 100644 --- a/components/foomatic/RecommendedPrintersSection.tsx +++ b/components/foomatic/RecommendedPrintersSection.tsx @@ -144,12 +144,19 @@ export default function RecommendedPrintersSection({
- - View printer - +
+ {recommendation.score < 0.9995 ? ( + + {Math.round(recommendation.score * 100)}% match + + ) : null} + + View printer + +
) diff --git a/lib/foomatic/types.ts b/lib/foomatic/types.ts index c06f29a1..3e039122 100644 --- a/lib/foomatic/types.ts +++ b/lib/foomatic/types.ts @@ -70,6 +70,7 @@ export interface PrinterSummary { status?: string driverCount?: number functionality?: string + color?: boolean | "unknown" } export interface DriverPrinterRef { 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') From fa7939329bb6e3ff178fe4c5d3b0b42bc0ea2161 Mon Sep 17 00:00:00 2001 From: Gati Varshney <171050892+gativarshney@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:26:13 +0530 Subject: [PATCH 10/40] build(foomatic): automate recommendation generation in CI --- .github/workflows/build.yml | 2 ++ .github/workflows/deploy.yml | 4 ++++ package.json | 5 ++++- scripts/foomatic/data-generate.ts | 21 ++++++++++++++++++++- 4 files changed, 30 insertions(+), 2 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index da3b067c..d953a5e5 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -78,3 +78,5 @@ jobs: - name: Build Next.js run: ${{ steps.pm.outputs.runner }} build + env: + FOOMATIC_SKIP_SIMILARITY: "1" diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 52019076..0af45c8f 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/package.json b/package.json index aed9ddeb..6566eda8 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,10 @@ "generate": "tsx scripts/foomatic/data-generate.ts && tsx scripts/foomatic/generate-query-api.ts && tsx scripts/search/build-index.ts && tsx scripts/search/build-foomatic-index.ts && tsx scripts/generate-rss.ts", "build": "yarn generate && next build && tsx scripts/foomatic/generate-legacy-redirects.ts", "start": "next start", - "lint": "next lint" + "lint": "next lint", + "foomatic:pipeline": "tsx scripts/foomatic/data-generate.ts", + "foomatic:data:vectorize": "tsx scripts/foomatic/vectorize.ts", + "foomatic:data:similarity": "tsx scripts/foomatic/compute-similarity.ts" }, "dependencies": { "@giscus/react": "^3.1.0", 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) { From e28f6609f1ff7b90a8cfc264c4a769f7b5ac2ede Mon Sep 17 00:00:00 2001 From: Gati Varshney <171050892+gativarshney@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:32:41 +0530 Subject: [PATCH 11/40] feat(recommendations): improve recommendation UX and accessibility --- components/foomatic/PrinterPageClient.tsx | 165 ++++++----- .../foomatic/RecommendedPrintersSection.tsx | 274 ++++++++++++------ 2 files changed, 283 insertions(+), 156 deletions(-) diff --git a/components/foomatic/PrinterPageClient.tsx b/components/foomatic/PrinterPageClient.tsx index 6918e8ff..f52ee45f 100644 --- a/components/foomatic/PrinterPageClient.tsx +++ b/components/foomatic/PrinterPageClient.tsx @@ -31,19 +31,40 @@ interface PrinterPageClientProps { function LoadingState() { return ( -
- -
-
-
- +
+
+ +
+
+
+ -
- {Array.from({ length: 2 }).map((_, index) => ( - -
-
-
+
+ {Array.from({ length: 2 }).map((_, index) => ( + +
+
+
+ + ))} +
+
+ +
+
+ {Array.from({ length: 3 }).map((_, index) => ( + +
+
+
+
+
+
+
+
+
+
+
))}
@@ -121,6 +142,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 @@ -226,64 +253,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.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} + {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 ? (

diff --git a/components/foomatic/RecommendedPrintersSection.tsx b/components/foomatic/RecommendedPrintersSection.tsx index 6604af8e..19a843a0 100644 --- a/components/foomatic/RecommendedPrintersSection.tsx +++ b/components/foomatic/RecommendedPrintersSection.tsx @@ -2,6 +2,7 @@ import { useEffect, useState } from "react" import Link from "next/link" +import { CheckCircle2 } from "lucide-react" import { FoomaticBadge, @@ -35,132 +36,229 @@ interface RecommendedPrintersSectionProps { printerId: string } +// printersMap.json is ~1.5 MB and shared by every printer page, so the parsed +// map is memoised for the lifetime of the tab. A failed fetch clears the cache +// so the next mount retries instead of reusing a rejected promise. +let printersMapCache: Promise> | null = null + +function getPrintersMap(): Promise> { + if (!printersMapCache) { + printersMapCache = fetch(withBasePath("/foomatic-db/printersMap.json")) + .then((response) => { + if (!response.ok) { + throw new Error("printersMap fetch failed") + } + return response.json() as Promise + }) + .then((data) => new Map(data.printers.map((printer) => [printer.id, printer]))) + .catch((error) => { + printersMapCache = null + throw error + }) + } + + return printersMapCache +} + +function ConfidenceBadge({ score }: { score: number }) { + if (score >= 0.9995) { + return ( + + + ) + } + + const percentage = Math.round(score * 100) + const toneClass = + percentage >= 85 + ? "text-emerald-600 dark:text-emerald-400" + : percentage >= 70 + ? "text-amber-600 dark:text-amber-400" + : "text-muted-foreground" + + return {percentage}% match +} + +function RecommendationSkeleton() { + return ( +

) : null} @@ -420,7 +421,9 @@ export default function PrinterPageClient({ printerId }: PrinterPageClientProps)
diff --git a/lib/foomatic/sanitize.ts b/lib/foomatic/sanitize.ts new file mode 100644 index 00000000..7c97ff5f --- /dev/null +++ b/lib/foomatic/sanitize.ts @@ -0,0 +1,31 @@ +"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", +] + +const ALLOWED_ATTR = ["href", "title", "target", "rel"] + +export function sanitizeFoomaticHtml(html: string): string { + return DOMPurify.sanitize(html, { + ALLOWED_TAGS, + ALLOWED_ATTR, + ALLOW_DATA_ATTR: false, + }) +} diff --git a/package.json b/package.json index 6566eda8..f5817b9c 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,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", diff --git a/yarn.lock b/yarn.lock index e627a31e..770255b3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -943,7 +943,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 @@ -1904,6 +1904,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" @@ -4872,6 +4884,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" From 9be7c7d79a9a35aec0b5a7dac4ba0c470e6f6876 Mon Sep 17 00:00:00 2001 From: Gati Varshney <171050892+gativarshney@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:50:59 +0530 Subject: [PATCH 13/40] test(foomatic): add unit tests for recommendation pipeline --- .github/workflows/build.yml | 3 + lib/foomatic/__tests__/driver-family.test.ts | 53 ++ .../__tests__/printer-attributes.test.ts | 213 ++++++ .../__tests__/similarity-math.test.ts | 73 ++ lib/foomatic/__tests__/utils.test.ts | 59 ++ lib/foomatic/driver-family.ts | 62 ++ lib/foomatic/printer-attributes.ts | 246 +++++++ lib/foomatic/similarity-math.ts | 54 ++ package.json | 4 +- scripts/foomatic/combine-data.ts | 223 +----- scripts/foomatic/compute-similarity.ts | 125 +--- scripts/foomatic/vectorize.ts | 75 +- tsconfig.json | 1 + vitest.config.mts | 17 + yarn.lock | 673 +++++++++++++++++- 15 files changed, 1491 insertions(+), 390 deletions(-) create mode 100644 lib/foomatic/__tests__/driver-family.test.ts create mode 100644 lib/foomatic/__tests__/printer-attributes.test.ts create mode 100644 lib/foomatic/__tests__/similarity-math.test.ts create mode 100644 lib/foomatic/__tests__/utils.test.ts create mode 100644 lib/foomatic/driver-family.ts create mode 100644 lib/foomatic/printer-attributes.ts create mode 100644 lib/foomatic/similarity-math.ts create mode 100644 vitest.config.mts diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d953a5e5..c5dc71f9 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -76,6 +76,9 @@ jobs: - name: Run linter run: ${{ steps.pm.outputs.runner }} lint + - name: Run unit tests + run: ${{ steps.pm.outputs.runner }} test + - name: Build Next.js run: ${{ steps.pm.outputs.runner }} build env: diff --git a/lib/foomatic/__tests__/driver-family.test.ts b/lib/foomatic/__tests__/driver-family.test.ts new file mode 100644 index 00000000..a47e1905 --- /dev/null +++ b/lib/foomatic/__tests__/driver-family.test.ts @@ -0,0 +1,53 @@ +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("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() + }) +}) + +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("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__/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..d72900cb --- /dev/null +++ b/lib/foomatic/__tests__/utils.test.ts @@ -0,0 +1,59 @@ +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") + }) +}) diff --git a/lib/foomatic/driver-family.ts b/lib/foomatic/driver-family.ts new file mode 100644 index 00000000..2e384567 --- /dev/null +++ b/lib/foomatic/driver-family.ts @@ -0,0 +1,62 @@ +// 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. + +import type { Printer } from "./types" + +export 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() +} + +export function getRecommendedDriverFamily(printer: Printer): string | null { + const driver = trim(printer.recommended_driver) + + if (!driver) { + return null + } + + return normalizeDriverFamily(driver) +} + +export function getSupportedDriverFamilies(printer: Printer): string[] { + const families = new Set() + + for (const driver of printer.drivers ?? []) { + 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/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/package.json b/package.json index f5817b9c..35cf8e46 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "build": "yarn generate && next build && tsx scripts/foomatic/generate-legacy-redirects.ts", "start": "next start", "lint": "next lint", + "test": "vitest run", "foomatic:pipeline": "tsx scripts/foomatic/data-generate.ts", "foomatic:data:vectorize": "tsx scripts/foomatic/vectorize.ts", "foomatic:data:similarity": "tsx scripts/foomatic/compute-similarity.ts" @@ -55,6 +56,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 d9e90813..0d5f4365 100644 --- a/scripts/foomatic/combine-data.ts +++ b/scripts/foomatic/combine-data.ts @@ -2,6 +2,18 @@ import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; +import { + getText, + getFunctionalityStatus, + getPrinterType, + getCommandsetTokens, + getBooleanCapability, + getColorCapability, + getDuplexCapability, + getMaxDpi, + getPSLevel, + getPCLLevel, +} from "../../lib/foomatic/printer-attributes"; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -20,90 +32,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) { @@ -199,54 +127,11 @@ function getCommandsets(printer) { return Array.from(new Set(sets)); } -function normalizeCommandsetToken(raw) { - 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; -} - /* * Distinct from getCommandsets(), which produces human-readable labels for * display (e.g. "PostScript 3"). This produces canonical short tokens * (e.g. "POSTSCRIPT") from autodetect data, used as similarity features. */ -function getCommandsetTokens(printer) { - const a = printer.autodetect; - if (!a) return []; - - const rawTokens = []; - - const pushCommaSplit = (val) => { - 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 = []; - for (const raw of rawTokens) { - const norm = normalizeCommandsetToken(raw); - if (norm && !seen.has(norm)) { - seen.add(norm); - result.push(norm); - } - } - return result.sort(); -} function getPpdOptions(printer) { const ppdNode = @@ -316,90 +201,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 getMaxDpi(printer) { - 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; -} - -function getColorCapability(printer) { - 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 - ); -} - -function getPSLevel(printer) { - 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; -} - -function getPCLLevel(printer) { - 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; -} - -function getDuplexCapability(printer) { - return getBooleanCapability( - printer.duplex ?? - printer.duplexer ?? - printer.capabilities?.duplex - ); -} - function buildPpdFileName(printerId, driverId) { return `${normalizePrinterId(printerId)}-${driverId.replace(/^driver\//, "")}.ppd`; } diff --git a/scripts/foomatic/compute-similarity.ts b/scripts/foomatic/compute-similarity.ts index d042cfc6..4c55f749 100644 --- a/scripts/foomatic/compute-similarity.ts +++ b/scripts/foomatic/compute-similarity.ts @@ -2,6 +2,16 @@ 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"; const ROOT_DIR = path.join( path.dirname(fileURLToPath(import.meta.url)), @@ -65,107 +75,6 @@ interface Output { recommendations: RecommendationMap; } -interface Candidate { - index: number; - score: number; -} - -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"], -]; - -function trim(value: string | undefined): string { - return (value ?? "").trim(); -} - -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(); -} - -function getRecommendedDriverFamily(printer: Printer): string | null { - const driver = trim(printer.recommended_driver); - - if (!driver) { - return null; - } - - return normalizeDriverFamily(driver); -} - -function getSupportedDriverFamilies(printer: Printer): string[] { - const families = new Set(); - - for (const driver of printer.drivers ?? []) { - const family = normalizeDriverFamily(driver.name); - - if (family) { - families.add(family); - } - } - - return [...families]; -} - -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; -} - -function magnitude(vec: number[]): number { - return Math.sqrt(dotProduct(vec, vec)); -} - -function cosineSimilarity( - a: number[], - b: number[], - magA: number, - magB: number, -): number { - if (magA === 0 || magB === 0) { - return 0; - } - - return dotProduct(a, b) / (magA * magB); -} - -function insertTopK(topK: Candidate[], candidate: Candidate): void { - if (topK.length < TOP_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); - } -} - function computeSharedFeatures(a: Printer, b: Printer): string[] { const shared: string[] = []; @@ -386,7 +295,7 @@ function main(): void { const vecA = matrixData.matrix[i]; const magA = magnitudes[i]; - const topK: Candidate[] = []; + const topK: ScoredCandidate[] = []; for (let j = 0; j < matrixData.printerCount; j++) { if (i === j) { @@ -402,10 +311,14 @@ function main(): void { continue; } - insertTopK(topK, { - index: j, - score, - }); + insertTopK( + topK, + { + index: j, + score, + }, + TOP_K, + ); } topK.sort((a, b) => b.score - a.score); diff --git a/scripts/foomatic/vectorize.ts b/scripts/foomatic/vectorize.ts index 4f235bb9..a2bb6362 100644 --- a/scripts/foomatic/vectorize.ts +++ b/scripts/foomatic/vectorize.ts @@ -2,6 +2,12 @@ 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)), @@ -39,22 +45,6 @@ interface FeatureMatrix { matrix: number[][]; } -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"], -]; - const RECOMMENDED_DRIVER_WEIGHT = 3.0; const SUPPORTED_DRIVER_WEIGHT = 1.0; const TYPE_WEIGHT = 0.5; @@ -66,59 +56,6 @@ const LANG_WEIGHT = 1.0; const LANG_LEVEL_WEIGHT = 0.5; const RESOLUTION_WEIGHT = 0.75; -function trim(value: string | undefined): string { - return (value ?? "").trim(); -} - -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; - } -} - -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(); -} - -function getSupportedDriverFamilies(printer: Printer): string[] { - const families = new Set(); - - for (const driver of printer.drivers ?? []) { - const family = normalizeDriverFamily(driver.name); - - if (family) { - families.add(family); - } - } - - return [...families]; -} - -function getRecommendedDriverFamily(printer: Printer): string | null { - const driver = trim(printer.recommended_driver); - - if (!driver) { - return null; - } - - return normalizeDriverFamily(driver); -} - function buildVocabularies(printers: Printer[]): Vocabulary { const recommendedDrivers = new Set(); 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 770255b3..c6b081c3 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.143.0": + version: 0.143.0 + resolution: "@oxc-project/types@npm:0.143.0" + checksum: 10c0/f450bdc6ebd69b09b5d77c1ca45369f9e1a2b69d21f9dfcdaaa2379e8d5228bfdd014e77bef9bc21ca5fd118fb9e2213d30d8eb70299f03b1aac5ee1ab2802ba + 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.3": + version: 1.2.3 + resolution: "@rolldown/binding-android-arm64@npm:1.2.3" + conditions: os=android & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-arm64@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-darwin-arm64@npm:1.2.3" + conditions: os=darwin & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-darwin-x64@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-darwin-x64@npm:1.2.3" + conditions: os=darwin & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-freebsd-x64@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-freebsd-x64@npm:1.2.3" + conditions: os=freebsd & cpu=x64 + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm-gnueabihf@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-arm-gnueabihf@npm:1.2.3" + conditions: os=linux & cpu=arm + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-gnu@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-arm64-gnu@npm:1.2.3" + conditions: os=linux & cpu=arm64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-arm64-musl@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-arm64-musl@npm:1.2.3" + conditions: os=linux & cpu=arm64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-linux-ppc64-gnu@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-ppc64-gnu@npm:1.2.3" + conditions: os=linux & cpu=ppc64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-s390x-gnu@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-s390x-gnu@npm:1.2.3" + conditions: os=linux & cpu=s390x & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-gnu@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-x64-gnu@npm:1.2.3" + conditions: os=linux & cpu=x64 & libc=glibc + languageName: node + linkType: hard + +"@rolldown/binding-linux-x64-musl@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-linux-x64-musl@npm:1.2.3" + conditions: os=linux & cpu=x64 & libc=musl + languageName: node + linkType: hard + +"@rolldown/binding-openharmony-arm64@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-openharmony-arm64@npm:1.2.3" + conditions: os=openharmony & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-arm64-msvc@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-win32-arm64-msvc@npm:1.2.3" + conditions: os=win32 & cpu=arm64 + languageName: node + linkType: hard + +"@rolldown/binding-win32-x64-msvc@npm:1.2.3": + version: 1.2.3 + resolution: "@rolldown/binding-win32-x64-msvc@npm:1.2.3" + 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" @@ -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 @@ -2048,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" @@ -2487,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" @@ -2494,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" @@ -3606,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" @@ -3705,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" @@ -4638,6 +5029,15 @@ __metadata: languageName: node linkType: hard +"nanoid@npm:^3.3.17": + version: 3.3.17 + resolution: "nanoid@npm:3.3.17" + bin: + nanoid: bin/nanoid.cjs + checksum: 10c0/06f7949c7cce5c92c6aea66022f29a1eae7f56e05acbfddf1e049e819b4bf234c44a765cc44da7163482b111677948514012e27acdfa56f95309295768bdae32 + languageName: node + linkType: hard + "nanoid@npm:^3.3.6": version: 3.3.11 resolution: "nanoid@npm:3.3.11" @@ -4871,6 +5271,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:." @@ -4915,6 +5322,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 @@ -5022,6 +5430,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" @@ -5043,6 +5458,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" @@ -5171,6 +5593,17 @@ __metadata: languageName: node linkType: hard +"postcss@npm:^8.5.25": + version: 8.5.26 + resolution: "postcss@npm:8.5.26" + dependencies: + nanoid: "npm:^3.3.17" + picocolors: "npm:^1.1.1" + source-map-js: "npm:^1.2.1" + checksum: 10c0/2bdafc00d96bd57b6649a52e458864a4bf58ee56cfdbe4aea1472b5cccc127e6c1ad653bd0bec50d211e650eb0b9270c80e1e72aff2e2fa40d9e7363234d6e43 + languageName: node + linkType: hard + "prelude-ls@npm:^1.2.1": version: 1.2.1 resolution: "prelude-ls@npm:1.2.1" @@ -5510,6 +5943,61 @@ __metadata: languageName: node linkType: hard +"rolldown@npm:~1.2.1": + version: 1.2.3 + resolution: "rolldown@npm:1.2.3" + dependencies: + "@oxc-project/types": "npm:=0.143.0" + "@rolldown/binding-android-arm64": "npm:1.2.3" + "@rolldown/binding-darwin-arm64": "npm:1.2.3" + "@rolldown/binding-darwin-x64": "npm:1.2.3" + "@rolldown/binding-freebsd-x64": "npm:1.2.3" + "@rolldown/binding-linux-arm-gnueabihf": "npm:1.2.3" + "@rolldown/binding-linux-arm64-gnu": "npm:1.2.3" + "@rolldown/binding-linux-arm64-musl": "npm:1.2.3" + "@rolldown/binding-linux-ppc64-gnu": "npm:1.2.3" + "@rolldown/binding-linux-s390x-gnu": "npm:1.2.3" + "@rolldown/binding-linux-x64-gnu": "npm:1.2.3" + "@rolldown/binding-linux-x64-musl": "npm:1.2.3" + "@rolldown/binding-openharmony-arm64": "npm:1.2.3" + "@rolldown/binding-win32-arm64-msvc": "npm:1.2.3" + "@rolldown/binding-win32-x64-msvc": "npm:1.2.3" + "@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/4dbeabc826e59877c7520b2ae1f48d0111e1d21865f5931ff3d184d33f02683e943e65eb24932128fc03701bbcde1b341f313b3fa7f8007368bd1b5e993265f0 + languageName: node + linkType: hard + "run-parallel@npm:^1.1.9": version: 1.2.0 resolution: "run-parallel@npm:1.2.0" @@ -5791,6 +6279,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" @@ -5819,6 +6314,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" @@ -6097,6 +6606,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" @@ -6107,7 +6630,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: @@ -6117,6 +6640,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" @@ -6575,6 +7105,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" @@ -6665,6 +7320,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" From 856d7803b325c7ff18bf5bc40548f92fec4556ad Mon Sep 17 00:00:00 2001 From: Gati Varshney <171050892+gativarshney@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:54:25 +0530 Subject: [PATCH 14/40] docs(foomatic): add pipeline architecture and regeneration guides --- docs/foomatic-data-formats.md | 165 +++++++++++++++++++++++ docs/foomatic-pipeline-architecture.md | 173 +++++++++++++++++++++++++ docs/foomatic-retraining.md | 111 ++++++++++++++++ package.json | 4 + scripts/foomatic/compute-similarity.ts | 10 +- 5 files changed, 461 insertions(+), 2 deletions(-) create mode 100644 docs/foomatic-data-formats.md create mode 100644 docs/foomatic-pipeline-architecture.md create mode 100644 docs/foomatic-retraining.md diff --git a/docs/foomatic-data-formats.md b/docs/foomatic-data-formats.md new file mode 100644 index 00000000..d5bc8520 --- /dev/null +++ b/docs/foomatic-data-formats.md @@ -0,0 +1,165 @@ +# Foomatic Pipeline — Data Formats + +This document is the schema reference for every JSON artifact produced by the pipeline described in [foomatic-pipeline-architecture.md](./foomatic-pipeline-architecture.md). All artifacts live under `public/foomatic-db/` and are served as static files — none require a backend to read. + +The canonical TypeScript types are defined in [`lib/foomatic/types.ts`](../lib/foomatic/types.ts); this document explains what each field means and which pipeline stage produces it. + +--- + +## `public/foomatic-db/printer/*.json`, `driver/*.json` + +**Produced by:** `generate-from-xml.ts` +**Consumed by:** `combine-data.ts` + +A 1:1 JSON mirror of the upstream Foomatic XML, one file per `` or `` element. Shape is whatever `fast-xml-parser` produces from the source XML (attributes prefixed with `@`, text nodes under `#text`), with one normalization: the `` tag is renamed to `driverPrototype`. These are intermediate artifacts — nothing in the frontend reads them directly. + +--- + +## `public/foomatic-db/printers.json` + +**Produced by:** `combine-data.ts` +**Consumed by:** `split-printers.ts`, `vectorize.ts`, `compute-similarity.ts` + +The unified, normalized dataset — one record per printer, each conforming to the `Printer` interface: + +```ts +interface Printer { + id: string // normalized id, e.g. "HP-LaserJet-4" + manufacturer: string + model: string + series?: string + connectivity?: string[] // ["USB", "Network", ...] + recommended_driver?: string // e.g. "driver/Postscript-hp" + drivers?: Driver[] + type?: string // "inkjet" | "laser" | "dot-matrix" | "unknown" + status?: string // "Perfect" | "Mostly" | "Unsupported" | "Unknown" + notes?: string // HTML from upstream XML — sanitize before rendering + functionality?: string // raw Foomatic grade: "A" | "B" | "C" | "?" + commandsets?: string[] // normalized PDL tokens, e.g. ["PCLXL", "POSTSCRIPT"] + ppdOptions?: PpdOption[] + color?: boolean | "unknown" + duplex?: boolean | "unknown" + recommended?: boolean + psLevel?: number | null // 0–3 + pclLevel?: number | null // 0, 3, 4, 5, or 6 + maxDpi?: number | null +} + +interface Driver { + id: string + name: string + url?: string + comments?: string // HTML from upstream XML — sanitize before rendering + hasPpd?: boolean + ppdPath?: string + execution?: { ghostscript?: string | null; filter?: string | null; prototype: string } +} +``` + +`notes` and `driver.comments` are sourced from upstream contributor-editable XML and **must** be passed through `sanitizeFoomaticHtml()` (`lib/foomatic/sanitize.ts`) before being rendered with `dangerouslySetInnerHTML` — see that file's usage in `components/foomatic/PrinterPageClient.tsx`. + +--- + +## `public/foomatic-db/printersMap.json` + +**Produced by:** `split-printers.ts` +**Consumed by:** the directory listing page (`app/foomatic/printers/page.tsx`) and `generateStaticParams()` in `app/foomatic/printer/[make]/[id]/page.tsx` + +A lightweight projection of `printers.json`, one entry per printer, used so the directory page doesn't need to download every printer's full driver/PPD detail just to render a list: + +```ts +interface PrinterSummary { + id: string + manufacturer: string + model: string + type?: string + status?: string + driverCount?: number + functionality?: string +} +``` + +`driverCount` is `drivers.length` from the full record; `type`/`status`/`functionality` default to `"unknown"`/`"Unknown"`/`"?"` respectively if absent. + +--- + +## `public/foomatic-db/printers/.json` + +**Produced by:** `split-printers.ts` +**Consumed by:** the printer detail page (`PrinterPageClient.tsx`) + +One full `Printer` record per printer (same shape as an entry in `printers.json`), written individually so a detail-page visit only fetches data for that one printer. + +--- + +## `public/foomatic-db/feature-matrix.json` + +**Produced by:** `vectorize.ts` +**Consumed by:** `compute-similarity.ts` + +```ts +interface FeatureMatrix { + printerCount: number + featureCount: number + featureNames: string[] // e.g. ["recommended_driver:postscript", ..., "res:2400plus"] + vocab: { + recommendedDrivers: string[] + supportedDrivers: string[] + types: string[] + commandsets: string[] // only commandsets meeting MIN_COMMANDSET_FREQUENCY (20) + } + ids: string[] // printer ids, parallel-indexed with matrix rows + matrix: number[][] // one weighted feature vector per printer +} +``` + +`matrix[i]` corresponds to `ids[i]` and is encoded in the exact order of `featureNames`. See [foomatic-recommendation-quality.md](./foomatic-recommendation-quality.md) for what each feature means and its weight. + +--- + +## `public/foomatic-db/recommendations.json` + +**Produced by:** `compute-similarity.ts` +**Consumed by:** dev/debug tooling and as the source for the per-printer split below (not fetched directly by the frontend — see `recommendations/.json`) + +```ts +interface Output { + version: string // "2.0.0" + printerCount: number + topK: number // 10 + recommendations: { + [printerId: string]: Array<{ + id: string // recommended printer's id + score: number // cosine similarity, rounded to 3 decimals + sharedFeatures: string[] // human-readable explanation strings + }> + } +} +``` + +`sharedFeatures` entries are generated by `computeSharedFeatures()` and look like `"Shared command set: PostScript"`, `"Color printing"`, `"1200 dpi resolution"`, `"Excellent Linux driver support"` — these are rendered directly as the "why this printer?" list on the printer detail page. + +--- + +## `public/foomatic-db/recommendations/.json` + +**Produced by:** `compute-similarity.ts` (same run as above, written per-printer for fetch efficiency) +**Consumed by:** `RecommendedPrintersSection.tsx` + +A single printer's recommendation array — i.e. `recommendations.json`'s `recommendations[id]` value in isolation: + +```ts +type RecommendationsForPrinter = Array<{ + id: string + score: number + sharedFeatures: string[] +}> +``` + +The detail page only ever needs the current printer's own recommendations, so fetching this file instead of the ~20+ MB combined `recommendations.json` is what keeps the printer detail page's initial load small (see commit `perf(recommendations): split recommendation data per printer`). + +--- + +## Versioning + +`recommendations.json` carries an explicit `version` field (`"2.0.0"` as of the resolution-aware similarity feature addition). There is no compatibility-checking consumer of this field today — it exists as a marker for pipeline output changes across the feature-addition history documented in [foomatic-recommendation-quality.md](./foomatic-recommendation-quality.md). If the feature vector schema changes again (a new weighted dimension, a changed weight, or a different scoring formula), bump this version so it's traceable in the artifact itself. diff --git a/docs/foomatic-pipeline-architecture.md b/docs/foomatic-pipeline-architecture.md new file mode 100644 index 00000000..e8143d6e --- /dev/null +++ b/docs/foomatic-pipeline-architecture.md @@ -0,0 +1,173 @@ +# Foomatic Recommendation Pipeline — Architecture + +## Overview + +This document describes the offline, reproducible machine-learning pipeline that turns the upstream [OpenPrinting/foomatic-db](https://github.com/OpenPrinting/foomatic-db) XML dataset into the static printer directory, printer detail pages, and "similar printers" recommendations served from `openprinting.github.io/foomatic`. + +The pipeline is designed to: + +- Run entirely offline, with no server-side inference at request time +- Produce static, versioned JSON artifacts consumable by a static-exported Next.js site (no backend) +- Be fully reproducible from upstream Foomatic XML on every run +- Refresh automatically via GitHub Actions as the upstream database changes + +This is the GSoC "Track A" deliverable: *an offline ML pipeline for printer similarity and compatibility analysis, exported as static artifacts for static-site consumption.* + +--- + +## High-Level Architecture + +The pipeline is divided into seven sequential stages, each implemented as an independent `tsx` script under `scripts/foomatic/`, orchestrated by `scripts/foomatic/data-generate.ts`: + +``` +1. generate-from-xml.ts XML → JSON ingestion +2. generate-ppds.sh PPD compilation (Linux only) +3. combine-data.ts Normalization + enrichment +4. split-printers.ts Performance: per-printer artifact split +5. split-drivers.ts Performance: per-driver artifact split +6. vectorize.ts Feature engineering +7. compute-similarity.ts Similarity computation + recommendations +``` + +Each stage reads the previous stage's output from `public/foomatic-db/` and writes its own output back to the same directory, so the pipeline can be re-run incrementally or in full. Stage failure is fail-fast: if any step exits non-zero, the orchestrator stops immediately rather than continuing with stale or partial data. + +--- + +## Pipeline Flow + +``` + OpenPrinting/foomatic-db (git, upstream) + │ + │ git clone / pull + ▼ + cache/foomatic-db/db/source/{printer,driver}/*.xml + │ + ┌─────────────┴─────────────┐ + │ generate-from-xml.ts │ fast-xml-parser + └─────────────┬─────────────┘ + ▼ + public/foomatic-db/printer/*.json driver/*.json + │ + ┌─────────────┴─────────────┐ + │ generate-ppds.sh │ foomatic-compiledb + │ (skipped on Windows / │ (cached by git revision) + │ --skip-ppd) │ + └─────────────┬─────────────┘ + ▼ + public/ppds/*.ppd + │ + ┌─────────────┴─────────────┐ + │ combine-data.ts │ joins printer ⇄ driver + │ │ graphs, derives all + │ │ normalized attributes + └─────────────┬─────────────┘ + ▼ + public/foomatic-db/printers.json + (the "unified and normalized printer + metadata dataset" deliverable) + │ + ┌─────────────┴─────────────┐ + │ split-printers.ts │ performance split + └─────────────┬─────────────┘ + ▼ + public/foomatic-db/printersMap.json printers/.json + │ + ┌─────────────┴─────────────┐ + │ vectorize.ts │ builds vocabularies, + │ │ encodes weighted + │ │ feature vectors + └─────────────┬─────────────┘ + ▼ + public/foomatic-db/feature-matrix.json + │ + ┌─────────────┴─────────────┐ + │ compute-similarity.ts │ pairwise cosine + │ │ similarity, top-10, + │ │ explanations + └─────────────┬─────────────┘ + ▼ + public/foomatic-db/recommendations.json (full map) + public/foomatic-db/recommendations/.json (per-printer) + │ + ▼ + Next.js static export (output: "export") + app/foomatic/printers/page.tsx · app/foomatic/printer/[make]/[id]/page.tsx + (fetched client-side, zero backend, per the + Track B static-site requirement) +``` + +--- + +## Stage Details + +### 1. XML Ingestion — `generate-from-xml.ts` + +- Clones `OpenPrinting/foomatic-db` into `cache/foomatic-db` on first run, or `git pull`s it on subsequent runs (a failed pull is logged but non-fatal, so a stale local cache doesn't block the rest of the pipeline). +- Parses every `printer/*.xml` and `driver/*.xml` file with `fast-xml-parser`, configured to preserve XML attributes (`@`-prefixed) and rename the `` tag to `driverPrototype` to avoid colliding with the reserved JavaScript identifier. +- Normalizes driver `` references (which appear as either bare strings or `{id, ...}` objects in the source XML) into a single object shape with a `.id` field, so downstream code does not need to special-case both forms. +- Output: one JSON file per printer/driver, mirroring the XML structure, under `public/foomatic-db/printer/` and `public/foomatic-db/driver/`. + +### 2. PPD Compilation — `generate-ppds.sh` + +- Invokes `foomatic-compiledb` (from the `foomatic-db-engine` system package) to generate one `.ppd` file per supported printer/driver pair, used for the "Download PPD" / "Preview PPD" features on the printer detail page. +- **Linux/CI only.** On Windows, or when `--skip-ppd` / `SKIP_PPD_GEN=true` is set, this stage is skipped entirely and the pipeline continues without PPDs — `combine-data.ts` simply records `hasPpd: false` for every driver in that case. +- Caches by the foomatic-db git revision: it records the upstream HEAD SHA in `public/ppds/.foomatic-db-revision` and skips regeneration if the SHA hasn't changed and PPDs already exist (unless `--force`/`FORCE_PPD_GEN=true`), avoiding an expensive full recompilation on every CI run when the upstream database hasn't moved. + +### 3. Normalization + Enrichment — `combine-data.ts` + +This is the core data-unification stage. For every printer it: + +- Joins the printer and driver JSON graphs bidirectionally — a printer's `drivers` list and a driver's `printers` list are cross-referenced so each side knows about the other, even when only one side declares the relationship in the source XML. If a driver references a printer ID that has no printer JSON file of its own, a minimal stub printer record is synthesized from the ID (split on `-` into manufacturer/model) rather than silently dropping the driver. +- Derives normalized attributes via pure helper functions (now shared with the test suite in `lib/foomatic/printer-attributes.ts`): + - `getFunctionalityStatus` — maps the raw Foomatic functionality grade (`A`/`B`/`C`/`?`) to a human-readable status (`Perfect`/`Mostly`/`Unknown`/`Unsupported`). + - `getPrinterType` — inspects `` to classify a printer as `inkjet`, `laser`, `dot-matrix`, or `unknown`. + - `getCommandsetTokens` / `normalizeCommandsetToken` — extracts page-description-language tokens from `` (including IEEE1284 `CMD:` device-ID strings) and folds dozens of upstream spelling variants (`PostScript`, `PS2`, `Adobe PostScript`, …) into a small set of canonical tokens (`POSTSCRIPT`, `PCLXL`, `PCL5E`, `PCL`, …). + - `getColorCapability` / `getBooleanCapability` — resolves color support from `` first, falling back to free-text capability fields. + - `getPSLevel` / `getPCLLevel` — parses PostScript/PCL language-level strings into normalized integer tiers. + - `getMaxDpi` — resolves maximum print resolution from ``. +- Resolves each printer's `recommended_driver` (the printer's own declared driver if present, otherwise the first known supporting driver) and builds full driver detail records, including whether a compiled PPD exists for that driver (`hasPpd`/`ppdPath`). +- Output: `public/foomatic-db/printers.json` — the single unified, normalized dataset that every later stage and the frontend both read from. + +### 4. Performance Split — `split-printers.ts` + +`printers.json` contains every printer's full driver/PPD/option detail and is too large to ship to every page. This stage splits it into: + +- `printersMap.json` — a lightweight per-printer projection (`id`, `manufacturer`, `model`, `type`, `status`, `functionality`, `driverCount`, `color`) used by the directory listing page and to drive Next.js's `generateStaticParams()` for static export. +- `printers/.json` — one full-detail file per printer, fetched only when a user visits that printer's detail page. + +### 5. Performance Split — `split-drivers.ts` + +Applies the same treatment to the driver graph, producing `drivers.json`, a lightweight `driversMap.json` index, and one `drivers/.json` per driver for the driver detail pages. + +### 6. Feature Engineering — `vectorize.ts` + +Builds a weighted numeric feature vector for every printer. See [foomatic-recommendation-quality.md](./foomatic-recommendation-quality.md) for the full feature list, weights, and the rationale/timeline behind each one. Output: `public/foomatic-db/feature-matrix.json`. + +### 7. Similarity Computation — `compute-similarity.ts` + +For every printer, computes cosine similarity against every other printer's feature vector, keeps the top 10 candidates scoring at or above a minimum threshold, and generates human-readable "why this printer?" explanations. See [foomatic-data-formats.md](./foomatic-data-formats.md) for the exact output schema and methodology details. + +--- + +## Similarity Methodology Summary + +The recommendation engine is a **hand-rolled weighted cosine similarity** over engineered features — not a trained/learned model and not an embedding model. This was a deliberate scope choice: the feature space (driver compatibility, page-description-language support, color, resolution tier) is small, well-understood, and directly interpretable, which is what makes the "why this printer?" explanation feature possible. There is no `scikit-learn`-equivalent or embedding library dependency in this pipeline; the only data-processing dependency is `fast-xml-parser` for stage 1. + +Computation is brute-force O(n²) — every printer is compared against every other printer. At the current scale of the Foomatic database (several thousand printers), this completes in well under a minute (see `compute-similarity.ts` runtime logging). This is a known, accepted scaling limit; see the **Production Readiness** notes in the project's GSoC midterm audit for the documented ceiling and mitigation options if the dataset grows substantially. + +--- + +## CI/CD Automation + +Two GitHub Actions workflows run the pipeline: + +| Workflow | Trigger | Similarity computation | +|---|---|---| +| `.github/workflows/build.yml` | `pull_request`, `push` to `master`, `workflow_dispatch` | **Skipped** (`FOOMATIC_SKIP_SIMILARITY=1`) — keeps PR checks fast | +| `.github/workflows/deploy.yml` | `push` to `master`, weekly `schedule` (`0 2 * * 1`, Mondays 02:00 UTC), `workflow_dispatch` | **Runs in full** | + +The full pipeline runs automatically as the first half of the `build` script (`yarn generate && next build && ...`), where `yarn generate` invokes `scripts/foomatic/data-generate.ts` before Next.js compiles. Both workflows reach it by running `yarn build`. The weekly cron in `deploy.yml` is what keeps recommendations fresh as the upstream `foomatic-db` repository evolves, without requiring a manual trigger — this satisfies the proposal's "automated GitHub Actions workflows for scheduled data refresh and retraining" deliverable. + +Both workflows install the same system dependencies needed for XML/PPD processing: `foomatic-db-engine`, `foomatic-db-compressed-ppds`, `cups-filters`, `ghostscript`, `bsdmainutils`, `libxml2-utils`, `xsltproc`. + +For the exact commands to reproduce this locally, see [foomatic-retraining.md](./foomatic-retraining.md). diff --git a/docs/foomatic-retraining.md b/docs/foomatic-retraining.md new file mode 100644 index 00000000..be78a459 --- /dev/null +++ b/docs/foomatic-retraining.md @@ -0,0 +1,111 @@ +# Foomatic Pipeline — Regeneration Guide + +This is the operational reference for re-running the printer recommendation pipeline described in [foomatic-pipeline-architecture.md](./foomatic-pipeline-architecture.md). There is no "training" in the machine-learning-model sense — the pipeline deterministically regenerates static artifacts from upstream `foomatic-db` XML and engineered feature weights. "Retraining" here means re-running the pipeline end-to-end after either the upstream database or the feature/weight logic has changed. + +--- + +## Prerequisites + +The full pipeline (including PPD compilation) requires the same system packages installed in CI (`.github/workflows/build.yml` / `deploy.yml`): + +``` +foomatic-db-engine foomatic-db-compressed-ppds cups-filters ghostscript bsdmainutils libxml2-utils xsltproc +``` + +On Windows (or any environment without `foomatic-compiledb`), PPD compilation is automatically skipped — `data-generate.ts` skips it whenever `process.platform === "win32"`, and `generate-ppds.sh` itself exits early if the `foomatic-compiledb` binary isn't found. The rest of the pipeline runs identically either way; printers simply get `hasPpd: false`. + +Node dependencies are managed with `yarn` (`yarn.lock` is the lockfile of record). + +--- + +## Running the full pipeline + +```bash +yarn foomatic:pipeline +``` + +This runs `scripts/foomatic/data-generate.ts`, which executes every stage in order and stops immediately if any stage fails: + +1. `generate-from-xml.ts` — clones/pulls `OpenPrinting/foomatic-db` into `cache/foomatic-db`, converts XML to JSON +2. `generate-ppds.sh` — compiles PPDs (skipped on Windows or with `--skip-ppd`) +3. `combine-data.ts` — produces `printers.json` +4. `split-printers.ts` — produces `printersMap.json` and `printers/.json` +5. `vectorize.ts` — produces `feature-matrix.json` (skipped with `--skip-similarity`) +6. `compute-similarity.ts` — produces `recommendations.json` and `recommendations/.json` (skipped with `--skip-similarity`) + +This also runs automatically before every `next build`: the `build` script is `yarn generate && next build && ...`, and `yarn generate` invokes this pipeline first. + +### Flags / environment variables + +| Flag | Env var equivalent | Effect | +|---|---|---| +| `--skip-ppd` | n/a (Windows always skips) | Skip PPD compilation (stage 2) | +| `--skip-similarity` | `FOOMATIC_SKIP_SIMILARITY=1` | Skip stages 5–6 (vectorize + compute-similarity) | +| `--force` (forwarded to `generate-ppds.sh` only) | `FORCE_PPD_GEN=true` | Force PPD recompilation even if the cached `foomatic-db` git revision hasn't changed | + +Example — fast local iteration on UI changes without recomputing recommendations: + +```bash +yarn foomatic:pipeline --skip-similarity +``` + +--- + +## Running individual stages + +Each stage can be run independently via its own `package.json` script — useful when iterating on one stage without re-running the whole pipeline: + +```bash +yarn foomatic:generate:xml # stage 1: XML → JSON +yarn foomatic:generate:ppds # stage 2: PPD compilation (Linux/macOS only) +yarn foomatic:data:combine # stage 3: normalization → printers.json +yarn foomatic:data:split # stages 4-5: printersMap.json + printers/.json, + # driversMap.json + drivers/.json +yarn foomatic:data:vectorize # stage 6: feature-matrix.json +yarn foomatic:data:similarity # stage 7: recommendations.json + recommendations/.json +``` + +Stages 3–7 each read their input from a fixed path under `public/foomatic-db/` (see [foomatic-data-formats.md](./foomatic-data-formats.md)) rather than from in-memory state, so they can be re-run independently as long as the upstream artifact they depend on already exists. Running `yarn foomatic:data:vectorize` without first running `yarn foomatic:data:combine` will fail fast with a message telling you which earlier command to run. + +--- + +## When to re-run which stage + +| Change | Stages to re-run | +|---|---| +| Upstream `foomatic-db` content changed (new printers/drivers) | All stages (`yarn foomatic:pipeline`) | +| Editing normalization logic in `lib/foomatic/printer-attributes.ts` (e.g. a new commandset token mapping) | `combine-data` onward (3–6) | +| Editing feature weights or adding a new feature in `vectorize.ts` | `vectorize` onward (5–6) — `combine-data`/`split-printers` output is unaffected | +| Editing similarity scoring/explanation logic in `compute-similarity.ts` | `compute-similarity` only (6) | +| UI-only changes (components, pages) | No pipeline re-run needed — just `next dev` / `next build` against existing `public/foomatic-db/` artifacts | + +--- + +## CI automation + +You do not need to run the pipeline manually for production — it runs automatically: + +- **Every PR / push to `master`:** `build.yml` runs the full pipeline with `FOOMATIC_SKIP_SIMILARITY=1`, so PR checks validate ingestion/normalization/UI without paying the cost of the O(n²) similarity computation. +- **Every push to `master`, plus a weekly cron (`0 2 * * 1`, Mondays 02:00 UTC):** `deploy.yml` runs the full pipeline including similarity computation, then deploys the static export to GitHub Pages. This is what keeps recommendations fresh as `foomatic-db` evolves upstream, without anyone needing to remember to trigger it. + +To manually trigger a full regeneration in CI without waiting for the weekly cron, use the `workflow_dispatch` trigger on `deploy.yml` from the Actions tab. + +--- + +## Verifying a regeneration was a no-op (regression check) + +If you've changed code but expect output to be unchanged (e.g. a refactor with no behavior change), diff the artifacts before and after: + +```bash +cp public/foomatic-db/printers.json /tmp/printers.before.json +yarn foomatic:data:combine +diff -q /tmp/printers.before.json public/foomatic-db/printers.json +``` + +Repeat the same pattern for `feature-matrix.json` (after `yarn foomatic:data:vectorize`) and `recommendations.json` (after `yarn foomatic:data:similarity`). This is how the `lib/foomatic/*` extraction refactor (see the test suite under `lib/foomatic/__tests__/`) was verified to be behavior-preserving before being merged. + +--- + +## Adding a new similarity feature + +See [foomatic-ui-extending.md](./foomatic-ui-extending.md#adding-a-new-recommendation-signal) for the step-by-step process — it touches `combine-data.ts` (derive the attribute), `vectorize.ts` (encode it with a weight), and `compute-similarity.ts` (optionally add it to `computeSharedFeatures()` for the "why this printer?" explanation). diff --git a/package.json b/package.json index 35cf8e46..7ab74094 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "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" }, diff --git a/scripts/foomatic/compute-similarity.ts b/scripts/foomatic/compute-similarity.ts index 4c55f749..33646dc3 100644 --- a/scripts/foomatic/compute-similarity.ts +++ b/scripts/foomatic/compute-similarity.ts @@ -250,7 +250,10 @@ function logSpotCheck( function loadFeatureMatrix(): FeatureMatrix { if (!fs.existsSync(MATRIX_FILE)) { - throw new Error(`Missing feature matrix: ${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")); @@ -258,7 +261,10 @@ function loadFeatureMatrix(): FeatureMatrix { function loadPrinters(): Printer[] { if (!fs.existsSync(PRINTERS_FILE)) { - throw new Error(`Missing printers.json: ${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")); From bac57a486e218022904804e7648a1fc5afa255de Mon Sep 17 00:00:00 2001 From: Gati Varshney <171050892+gativarshney@users.noreply.github.com> Date: Fri, 7 Aug 2026 12:55:44 +0530 Subject: [PATCH 15/40] docs(foomatic): add contributor guide for extending the UI --- docs/foomatic-ui-extending.md | 122 ++++++++++++++++++++++++++++++++++ 1 file changed, 122 insertions(+) create mode 100644 docs/foomatic-ui-extending.md diff --git a/docs/foomatic-ui-extending.md b/docs/foomatic-ui-extending.md new file mode 100644 index 00000000..016b7a38 --- /dev/null +++ b/docs/foomatic-ui-extending.md @@ -0,0 +1,122 @@ +# Foomatic Web Interface — Extending Guide + +This document is for contributors working on the printer discovery and recommendation interface at `/foomatic`. It assumes you've read [foomatic-pipeline-architecture.md](./foomatic-pipeline-architecture.md) and [foomatic-data-formats.md](./foomatic-data-formats.md) for how the data this UI consumes is produced. + +This is the GSoC "Track B" deliverable: *a client-side-only interface, with no backend, consuming the static ML artifacts generated by Track A.* + +--- + +## Component Structure + +``` +app/foomatic/ + printers/page.tsx Directory/listing page (client component) + printer/[make]/[id]/page.tsx Printer detail route — server component, + generateStaticParams() pre-renders one + page per printer from printersMap.json + drivers/page.tsx Driver directory listing + driver/[id]/page.tsx Driver detail route + ppd-o-matic/page.tsx PPD preview route + +components/foomatic/ + Printers.tsx Grid wrapper, renders PrinterCard per item + PrinterCard.tsx Single printer summary card (directory page) + PrinterSearch.tsx Search input + filter controls + PrinterPageClient.tsx Printer detail page body (client component) + RecommendedPrintersSection.tsx "Similar printers" section on the detail page + DriverListClient.tsx Driver directory body + DriverPageClient.tsx Driver detail page body + PpdViewerClient.tsx PPD text viewer (ppd-o-matic route) + shared.tsx Shared primitives: FoomaticCard, FoomaticBadge, + FoomaticStatusBadge, FoomaticSelect, etc. + +lib/foomatic/ + types.ts Printer / Driver / PrinterSummary types + utils.ts calculateAccurateStatus() + sanitize.ts sanitizeFoomaticHtml() — DOMPurify wrapper + base-path.ts withBasePath() — GitHub Pages basePath helper + driver-family.ts normalizeDriverFamily() and friends (also used + by the pipeline scripts — see below) + printer-attributes.ts Pipeline-side attribute derivation (also used + by the pipeline scripts) + similarity-math.ts cosineSimilarity() and friends (pipeline-side) + hooks/use-debounce.ts useDebounce() — used by PrinterSearch +``` + +Everything under `app/foomatic` and `components/foomatic` is a Next.js **client component** (`"use client"`) except `app/foomatic/printer/[make]/[id]/page.tsx`, which is a server component whose only job is to call `generateStaticParams()` (reading `printersMap.json` from disk at build time) and hand off to `PrinterPageClient`. This split exists because static export needs `generateStaticParams()` to run at build time in Node, while the actual data-fetching and interactivity happens in the browser against the static JSON artifacts. + +`lib/foomatic/driver-family.ts`, `printer-attributes.ts`, and `similarity-math.ts` are shared between the **build-time pipeline** (`scripts/foomatic/*.ts`) and its **test suite** (`lib/foomatic/__tests__/`) — they are not UI code, but they live in `lib/foomatic` because that's the one place both the pipeline scripts and (potentially) the frontend can import from without a circular path back into `scripts/`. + +--- + +## Printer Page Architecture + +`app/foomatic/printer/[make]/[id]/page.tsx` → `PrinterPageClient.tsx`: + +1. On mount, fetches `public/foomatic-db/printers/${printerId}.json` (the per-printer split file — see data formats doc). +2. Renders a loading skeleton while the fetch is in flight, and a "Printer not found" card if the fetch fails or 404s — never a hard error. +3. Recomputes `status` via `calculateAccurateStatus()` rather than trusting the printer's raw `status` field directly, because `status` in the data can be stale relative to `functionality`/`driverCount` depending on how the record was synthesized upstream (see `lib/foomatic/utils.ts`). +4. Renders a grouped **Capabilities** block (color, duplex, max resolution, connectivity, page description languages) and the driver list, sorted so `recommended_driver` appears first. PostScript/PCL levels are surfaced through the page-description-language labels rather than as separate rows, since the `commandsets` labels already carry the level (`PCL 5e`, `PostScript 3`). +5. Renders `driver.comments` and `printer.notes` through `sanitizeFoomaticHtml()` before `dangerouslySetInnerHTML` — **never bypass this**; that content originates from upstream contributor-editable XML (see [foomatic-data-formats.md](./foomatic-data-formats.md#publicfoomatic-dbprintersjson)). +6. Renders `` at the bottom. + +--- + +## Recommendation UI + +`RecommendedPrintersSection.tsx`: + +1. Fetches `public/foomatic-db/recommendations/${printerId}.json` (this printer's top-10 list) and `public/foomatic-db/printersMap.json` (for rendering manufacturer/model/status of each recommended printer) in parallel. +2. The `printersMap.json` fetch is cached at module scope (`printersMapCache`) so navigating between printer detail pages in the same session doesn't refetch it every time; a failed fetch resets the cache to `null` so the next mount retries instead of permanently caching a failure. +3. Renders only the **top 3** recommendations (`recs.slice(0, 3)`) — the underlying data has up to 10, the UI deliberately shows fewer to keep the section compact. +4. `ConfidenceBadge` maps a raw cosine score to a label: `score >= 0.9995` → "Exact match"; otherwise a `${percent}% match`, colored by threshold (≥85% emerald, ≥70% amber, below muted). +5. `sharedFeatures` (the explanation strings produced by `computeSharedFeatures()` in the pipeline) are rendered as a labeled chip list under "Why this printer?". +6. Uses a `cancelled` flag in the `useEffect` cleanup to avoid calling `setState` after unmount (relevant if a user navigates away mid-fetch). + +--- + +## Filter System + +`app/foomatic/printers/page.tsx` owns all filter/search/pagination state for the directory listing: + +- State is persisted to `localStorage` under the `STORAGE_KEYS` constants (search query, manufacturer, driver type, mechanism type, support level, color capability, current page, items-per-page) — so filters survive a reload or back-navigation. +- `PrinterSearch.tsx` is a presentational component: it owns the debounced text input (`useDebounce`, 300ms) and renders the filter `