diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 000000000..48b889858
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,4 @@
+# Changelog
+
+## Unreleased
+- Add filament label printing with separate presets, QR codes, and filament QR scanning support.
diff --git a/client/package-lock.json b/client/package-lock.json
index b19647a12..4efe04a31 100644
--- a/client/package-lock.json
+++ b/client/package-lock.json
@@ -24,6 +24,7 @@
"i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
+ "jszip": "3.10.1",
"react": "^19.2.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@@ -6206,6 +6207,12 @@
"url": "https://opencollective.com/core-js"
}
},
+ "node_modules/core-util-is": {
+ "version": "1.0.3",
+ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.3.tgz",
+ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==",
+ "license": "MIT"
+ },
"node_modules/cosmiconfig": {
"version": "8.3.6",
"resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-8.3.6.tgz",
@@ -8656,6 +8663,12 @@
"node": ">= 4"
}
},
+ "node_modules/immediate": {
+ "version": "3.0.6",
+ "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz",
+ "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==",
+ "license": "MIT"
+ },
"node_modules/import-fresh": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
@@ -8698,7 +8711,6 @@
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz",
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
- "dev": true,
"license": "ISC"
},
"node_modules/ini": {
@@ -9626,6 +9638,54 @@
"node": ">=4.0"
}
},
+ "node_modules/jszip": {
+ "version": "3.10.1",
+ "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz",
+ "integrity": "sha512-xXDvecyTpGLrqFrvkrUSoxxfJI5AH7U8zxxtVclpsUtMCq4JQ290LY8AW5c7Ggnr/Y/oK+bQMbqK2qmtk3pN4g==",
+ "license": "(MIT OR GPL-3.0-or-later)",
+ "dependencies": {
+ "lie": "~3.3.0",
+ "pako": "~1.0.2",
+ "readable-stream": "~2.3.6",
+ "setimmediate": "^1.0.5"
+ }
+ },
+ "node_modules/jszip/node_modules/isarray": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
+ "integrity": "sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ==",
+ "license": "MIT"
+ },
+ "node_modules/jszip/node_modules/readable-stream": {
+ "version": "2.3.8",
+ "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-2.3.8.tgz",
+ "integrity": "sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA==",
+ "license": "MIT",
+ "dependencies": {
+ "core-util-is": "~1.0.0",
+ "inherits": "~2.0.3",
+ "isarray": "~1.0.0",
+ "process-nextick-args": "~2.0.0",
+ "safe-buffer": "~5.1.1",
+ "string_decoder": "~1.1.1",
+ "util-deprecate": "~1.0.1"
+ }
+ },
+ "node_modules/jszip/node_modules/safe-buffer": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.1.2.tgz",
+ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==",
+ "license": "MIT"
+ },
+ "node_modules/jszip/node_modules/string_decoder": {
+ "version": "1.1.1",
+ "resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-1.1.1.tgz",
+ "integrity": "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg==",
+ "license": "MIT",
+ "dependencies": {
+ "safe-buffer": "~5.1.0"
+ }
+ },
"node_modules/kbar": {
"version": "0.1.0-beta.40",
"resolved": "https://registry.npmjs.org/kbar/-/kbar-0.1.0-beta.40.tgz",
@@ -9687,6 +9747,15 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/lie": {
+ "version": "3.3.0",
+ "resolved": "https://registry.npmjs.org/lie/-/lie-3.3.0.tgz",
+ "integrity": "sha512-UaiMJzeWRlEujzAuw5LokY1L5ecNQYZKfmyZ9L7wDHb/p5etKaxXhohBcrw0EYby+G/NA52vRSN4N39dxHAIwQ==",
+ "license": "MIT",
+ "dependencies": {
+ "immediate": "~3.0.5"
+ }
+ },
"node_modules/lines-and-columns": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
@@ -10857,6 +10926,12 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/pako": {
+ "version": "1.0.11",
+ "resolved": "https://registry.npmjs.org/pako/-/pako-1.0.11.tgz",
+ "integrity": "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw==",
+ "license": "(MIT AND Zlib)"
+ },
"node_modules/papaparse": {
"version": "5.5.3",
"resolved": "https://registry.npmjs.org/papaparse/-/papaparse-5.5.3.tgz",
@@ -11316,6 +11391,12 @@
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
}
},
+ "node_modules/process-nextick-args": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz",
+ "integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
+ "license": "MIT"
+ },
"node_modules/promise-inflight": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz",
@@ -13036,6 +13117,12 @@
"node": ">= 0.4"
}
},
+ "node_modules/setimmediate": {
+ "version": "1.0.5",
+ "resolved": "https://registry.npmjs.org/setimmediate/-/setimmediate-1.0.5.tgz",
+ "integrity": "sha512-MATJdZp8sLqDl/68LfQmbP8zKPLQNV6BIZoIgrscFDQ+RsvK/BxeDQOgyxKKoh0y/8h3BqVFnCqQ/gd+reiIXA==",
+ "license": "MIT"
+ },
"node_modules/setprototypeof": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/setprototypeof/-/setprototypeof-1.2.0.tgz",
@@ -14352,7 +14439,6 @@
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
- "dev": true,
"license": "MIT"
},
"node_modules/utils-merge": {
diff --git a/client/package.json b/client/package.json
index 1603ff246..4088c8b9e 100644
--- a/client/package.json
+++ b/client/package.json
@@ -23,6 +23,7 @@
"i18next": "^25.7.3",
"i18next-browser-languagedetector": "^8.2.0",
"i18next-http-backend": "^3.0.2",
+ "jszip": "3.10.1",
"react": "^19.2.3",
"react-dnd": "^16.0.1",
"react-dnd-html5-backend": "^16.0.1",
@@ -39,19 +40,19 @@
"@refinedev/cli": "^2.16.50",
"@types/loadable__component": "^5.13.10",
"@types/node": "^25.0.3",
- "@types/react-dom": "^19.2.3",
"@types/react": "^19.2.7",
+ "@types/react-dom": "^19.2.3",
"@types/uuid": "^10.0.0",
"@vitejs/plugin-react": "^5.1.2",
+ "eslint": "^9.39.2",
"eslint-config-prettier": "^10.1.8",
+ "eslint-plugin-react": "^7.37.5",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.4.26",
- "eslint-plugin-react": "^7.37.5",
- "eslint": "^9.39.2",
"globals": "^17.0.0",
"prettier": "3.7.4",
- "typescript-eslint": "^8.52.0",
"typescript": "^5.9.3",
+ "typescript-eslint": "^8.52.0",
"vite": "^7.3.0",
"vite-plugin-mkcert": "^1.17.9",
"vite-plugin-pwa": "^1.2.0"
diff --git a/client/public/locales/en/common.json b/client/public/locales/en/common.json
index 88ff2ae85..b14318acc 100644
--- a/client/public/locales/en/common.json
+++ b/client/public/locales/en/common.json
@@ -65,11 +65,21 @@
"helpMargin": "Margins should be configured to match your label paper and printer, changing these will affect the size of the entire grid.",
"helpPrinterMargin": "Safe-Zone should be set to how close to the paper edge your printer can print, changing these will not affect the entire grid.",
"print": "Print",
+ "exportLabels": "Export Labels",
"columns": "Columns",
"rows": "Rows",
"paperSize": "Paper Size",
"customSize": "Custom",
"dimensions": "Dimensions",
+ "amlLabelSize": "AML Label Size",
+ "exportFormat": "Export Format",
+ "exportFormatOptions": {
+ "png": "PNG",
+ "aml": "AML"
+ },
+ "exportAsZip": "Export as .zip",
+ "exportDpi": "Export DPI",
+ "exportDpiHelp": "Higher DPI makes sharper PNG/AML exports but increases file size and export time.",
"showBorder": "Show Border",
"previewScale": "Preview Scale",
"skipItems": "Skip Items",
@@ -92,6 +102,10 @@
"grid": "Grid"
},
"settings": "Presets",
+ "spoolPrintPresets": "Spool Print Presets",
+ "filamentPrintPresets": "Filament Print Presets",
+ "spoolImagePresets": "Spool Image Presets",
+ "filamentImagePresets": "Filament Image Presets",
"defaultSettings": "Default",
"addSettings": "Add New Preset",
"newSetting": "New",
@@ -100,13 +114,25 @@
"deleteSettingsConfirm": "Are you sure you want to delete this preset?",
"settingsName": "Preset Name",
"saveSetting": "Save Presets",
- "saveAsImage": "Save as Image"
+ "saveAsImage": "Save as Image",
+ "saveAsAmlLabels": "Save as AML (Labels)"
},
"qrcode": {
"button": "Print Labels",
+ "exportButton": "Export Labels",
+ "exportFilamentTitle": "Export Filament Labels",
+ "exportSpoolTitle": "Export Spool Labels",
+ "printFilamentTitle": "Print Filament Labels",
+ "printSpoolTitle": "Print Spool Labels",
+ "selectButton": "Export/Print",
+ "selectTitle": "Export / Print Labels",
"title": "Label Printing",
"template": "Label Template",
+ "filenameTemplate": "Filename Template",
+ "filenameTemplateTooltipSpool": "Use {} to insert values of the spool object as text. Refer to the label template rules and available tags for more details.",
+ "filenameTemplateTooltipFilament": "Use {} to insert values of the filament object as text. Refer to the label template rules and available tags for more details.",
"templateHelp": "Use {} to insert values of the spool object as text. For example, {id} will be replaced with the spool id, or {filament.material} will be replaced with the material of the spool. if a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Lot Nr: {lot_nr}} will only show the label if the spool has a lot number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.",
+ "templateHelpFilament": "Use {} to insert values of the filament object as text. For example, {id} will be replaced with the filament id, or {vendor.name} will be replaced with the vendor name. If a value is missing it will be replaced with \"?\". A second set of {} can be used to remove this. In addition, any text between the sets of {} will be removed if the value is missing. For example, {Article: {article_number}} will only show the label if a filament has an article number. Enclose text with double asterix ** to make it bold. Click the button to view a list of all available tags.",
"textSize": "Label Text Size",
"showContent": "Print Label",
"useHTTPUrl": {
@@ -127,12 +153,22 @@
},
"spoolSelect": {
"title": "Select Spools",
- "description": "Select spools to print labels for.",
+ "description": "Select spools to export or print labels for.",
+ "searchPlaceholder": "Search vendor, filament, material, location, lot #",
"showArchived": "Show Archived",
"noSpoolsSelected": "You have not selected any spools.",
"selectAll": "Select/Unselect All",
"selectedTotal_one": "{{count}} spool selected",
"selectedTotal_other": "{{count}} spools selected"
+ },
+ "filamentSelect": {
+ "title": "Select Filaments",
+ "description": "Select filaments to export or print labels for.",
+ "searchPlaceholder": "Search vendor, name, material, article #",
+ "noFilamentsSelected": "You have not selected any filaments.",
+ "selectAll": "Select/Unselect All",
+ "selectedTotal_one": "{{count}} filament selected",
+ "selectedTotal_other": "{{count}} filaments selected"
}
},
"scanner": {
diff --git a/client/src/App.tsx b/client/src/App.tsx
index d907b8ee1..24d3ef298 100644
--- a/client/src/App.tsx
+++ b/client/src/App.tsx
@@ -194,7 +194,9 @@ function App() {
/>
} />
} />
+ } />
} />
+ } />
} />
@@ -208,6 +210,9 @@ function App() {
/>
} />
} />
+ } />
+ } />
+ } />
} />
diff --git a/client/src/components/otherModels.tsx b/client/src/components/otherModels.tsx
index 0adabd80c..6255d00bf 100644
--- a/client/src/components/otherModels.tsx
+++ b/client/src/components/otherModels.tsx
@@ -5,6 +5,32 @@ import { IFilament } from "../pages/filaments/model";
import { IVendor } from "../pages/vendors/model";
import { getAPIURL } from "../utils/url";
+/**
+ * Factory function to create a reusable query hook for fetching and sorting string arrays from API endpoints.
+ * @param queryKey - Unique cache key for react-query
+ * @param endpoint - API endpoint to fetch from
+ * @param enabled - Whether the query should be enabled
+ */
+function useSimpleSortedArrayQuery(queryKey: string[], endpoint: string, enabled: boolean = false) {
+ return useQuery({
+ enabled,
+ queryKey,
+ queryFn: async () => {
+ const response = await fetch(getAPIURL() + endpoint);
+ if (!response.ok) {
+ throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`);
+ }
+ return response.json();
+ },
+ select: (data) => {
+ if (Array.isArray(data)) {
+ return [...data].sort();
+ }
+ return [];
+ },
+ });
+}
+
export function useSpoolmanFilamentFilter(enabled: boolean = false) {
return useQuery({
enabled: enabled,
@@ -135,69 +161,17 @@ export function useSpoolmanVendors(enabled: boolean = false) {
}
export function useSpoolmanMaterials(enabled: boolean = false) {
- return useQuery({
- enabled: enabled,
- queryKey: ["materials"],
- queryFn: async () => {
- const response = await fetch(getAPIURL() + "/material");
- if (!response.ok) {
- throw new Error("Network response was not ok");
- }
- return response.json();
- },
- select: (data) => {
- return data.sort();
- },
- });
+ return useSimpleSortedArrayQuery(["materials"], "/material", enabled);
}
export function useSpoolmanArticleNumbers(enabled: boolean = false) {
- return useQuery({
- enabled: enabled,
- queryKey: ["articleNumbers"],
- queryFn: async () => {
- const response = await fetch(getAPIURL() + "/article-number");
- if (!response.ok) {
- throw new Error("Network response was not ok");
- }
- return response.json();
- },
- select: (data) => {
- return data.sort();
- },
- });
+ return useSimpleSortedArrayQuery(["articleNumbers"], "/article-number", enabled);
}
export function useSpoolmanLotNumbers(enabled: boolean = false) {
- return useQuery({
- enabled: enabled,
- queryKey: ["lotNumbers"],
- queryFn: async () => {
- const response = await fetch(getAPIURL() + "/lot-number");
- if (!response.ok) {
- throw new Error("Network response was not ok");
- }
- return response.json();
- },
- select: (data) => {
- return data.sort();
- },
- });
+ return useSimpleSortedArrayQuery(["lotNumbers"], "/lot-number", enabled);
}
export function useSpoolmanLocations(enabled: boolean = false) {
- return useQuery({
- enabled: enabled,
- queryKey: ["locations"],
- queryFn: async () => {
- const response = await fetch(getAPIURL() + "/location");
- if (!response.ok) {
- throw new Error("Network response was not ok");
- }
- return response.json();
- },
- select: (data) => {
- return data.sort();
- },
- });
+ return useSimpleSortedArrayQuery(["locations"], "/location", enabled);
}
diff --git a/client/src/components/qrCodeScanner.tsx b/client/src/components/qrCodeScanner.tsx
index ecf97eafb..4aaa92525 100644
--- a/client/src/components/qrCodeScanner.tsx
+++ b/client/src/components/qrCodeScanner.tsx
@@ -17,16 +17,30 @@ const QRCodeScannerModal = () => {
}
const result = detectedCodes[0].rawValue;
- // Check for the spoolman ID format
- const match = result.match(/^web\+spoolman:s-(?[0-9]+)$/i);
- if (match && match.groups) {
+ // Accept both compact WEB+SPOOLMAN payloads and full show-page URLs so
+ // exported and printed labels keep scanning after base URL changes.
+ const spoolMatch = result.match(/^web\+spoolman:s-(?[0-9]+)$/i);
+ if (spoolMatch && spoolMatch.groups) {
setVisible(false);
- navigate(`/spool/show/${match.groups.id}`);
+ navigate(`/spool/show/${spoolMatch.groups.id}`);
+ return;
+ }
+ const filamentMatch = result.match(/^web\+spoolman:f-(?[0-9]+)$/i);
+ if (filamentMatch && filamentMatch.groups) {
+ setVisible(false);
+ navigate(`/filament/show/${filamentMatch.groups.id}`);
+ return;
+ }
+ const spoolURLmatch = result.match(/^https?:\/\/[^/]+(?:\/[^/]+)*\/spool\/show\/(?[0-9]+)$/i);
+ if (spoolURLmatch && spoolURLmatch.groups) {
+ setVisible(false);
+ navigate(`/spool/show/${spoolURLmatch.groups.id}`);
+ return;
}
- const fullURLmatch = result.match(/^https?:\/\/[^/]+\/spool\/show\/(?[0-9]+)$/i);
- if (fullURLmatch && fullURLmatch.groups) {
+ const filamentURLmatch = result.match(/^https?:\/\/[^/]+(?:\/[^/]+)*\/filament\/show\/(?[0-9]+)$/i);
+ if (filamentURLmatch && filamentURLmatch.groups) {
setVisible(false);
- navigate(`/spool/show/${fullURLmatch.groups.id}`);
+ navigate(`/filament/show/${filamentURLmatch.groups.id}`);
}
};
@@ -43,6 +57,8 @@ const QRCodeScannerModal = () => {
onScan={onScan}
formats={["qr_code"]}
onError={(err: unknown) => {
+ // Map browser/scanner-library failures onto translated messages instead of
+ // exposing raw exception names in the modal.
const error = err as Error;
console.error(error);
if (error.name === "NotAllowedError") {
diff --git a/client/src/pages/filamentExport/index.tsx b/client/src/pages/filamentExport/index.tsx
new file mode 100644
index 000000000..c12fbfd59
--- /dev/null
+++ b/client/src/pages/filamentExport/index.tsx
@@ -0,0 +1,69 @@
+import { PageHeader } from "@refinedev/antd";
+import { useTranslate } from "@refinedev/core";
+import { theme } from "antd";
+import { Content } from "antd/es/layout/layout";
+import { useEffect, useMemo } from "react";
+import { useNavigate, useSearchParams } from "react-router";
+import FilamentQRCodeExportDialog from "../printing/filamentQrCodeExportDialog";
+
+const { useToken } = theme;
+
+export const FilamentExport = () => {
+ const { token } = useToken();
+ const t = useTranslate();
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ const filamentIds = searchParams.getAll("filaments").map(Number);
+ const returnUrl = searchParams.get("return");
+ const selectionPath = useMemo(() => {
+ const params = new URLSearchParams();
+ if (returnUrl) {
+ params.set("return", returnUrl);
+ }
+ const query = params.toString();
+ return `/filament/labels${query ? `?${query}` : ""}`;
+ }, [returnUrl]);
+
+ useEffect(() => {
+ if (filamentIds.length === 0) {
+ navigate(selectionPath, { replace: true });
+ }
+ }, [filamentIds.length, navigate, selectionPath]);
+
+ return (
+ <>
+ {
+ const returnUrl = searchParams.get("return");
+ if (returnUrl) {
+ navigate(returnUrl, { relative: "path" });
+ } else {
+ navigate("/filament");
+ }
+ }}
+ >
+
+ {filamentIds.length > 0 && }
+
+
+ >
+ );
+};
+
+export default FilamentExport;
diff --git a/client/src/pages/filamentLabels/index.tsx b/client/src/pages/filamentLabels/index.tsx
new file mode 100644
index 000000000..e86f1daf3
--- /dev/null
+++ b/client/src/pages/filamentLabels/index.tsx
@@ -0,0 +1,80 @@
+import { PageHeader } from "@refinedev/antd";
+import { useTranslate } from "@refinedev/core";
+import { theme } from "antd";
+import { Content } from "antd/es/layout/layout";
+import { useMemo } from "react";
+import { useNavigate, useSearchParams } from "react-router";
+import FilamentSelectModal from "../printing/filamentSelectModal";
+
+const { useToken } = theme;
+
+export const FilamentLabels = () => {
+ const { token } = useToken();
+ const t = useTranslate();
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ const returnUrl = searchParams.get("return");
+ const initialSelectedIds = searchParams
+ .getAll("filaments")
+ .map(Number)
+ .filter((id) => !Number.isNaN(id));
+
+ const selectionPath = useMemo(() => {
+ const params = new URLSearchParams();
+ if (returnUrl) {
+ params.set("return", returnUrl);
+ }
+ const query = params.toString();
+ return `/filament/labels${query ? `?${query}` : ""}`;
+ }, [returnUrl]);
+
+ const handleNavigate = (mode: "print" | "export", ids: number[]) => {
+ const params = new URLSearchParams();
+ ids.forEach((id) => params.append("filaments", id.toString()));
+ params.set("return", selectionPath);
+ navigate(`/filament/${mode}?${params.toString()}`);
+ };
+
+ return (
+ <>
+ {
+ if (returnUrl) {
+ navigate(returnUrl, { relative: "path" });
+ } else {
+ navigate("/filament");
+ }
+ }}
+ >
+
+ handleNavigate("print", ids)}
+ onExport={(ids) => handleNavigate("export", ids)}
+ />
+
+
+ >
+ );
+};
+
+export default FilamentLabels;
diff --git a/client/src/pages/filamentPrinting/index.tsx b/client/src/pages/filamentPrinting/index.tsx
new file mode 100644
index 000000000..f9a88e6f9
--- /dev/null
+++ b/client/src/pages/filamentPrinting/index.tsx
@@ -0,0 +1,67 @@
+import { PageHeader } from "@refinedev/antd";
+import { useTranslate } from "@refinedev/core";
+import { theme } from "antd";
+import { Content } from "antd/es/layout/layout";
+import { useEffect, useMemo } from "react";
+import { useNavigate, useSearchParams } from "react-router";
+import FilamentQRCodePrintingDialog from "../printing/filamentQrCodePrintingDialog";
+
+const { useToken } = theme;
+
+export const FilamentPrinting = () => {
+ const { token } = useToken();
+ const t = useTranslate();
+ const [searchParams] = useSearchParams();
+ const navigate = useNavigate();
+
+ const filamentIds = searchParams.getAll("filaments").map(Number);
+ const returnUrl = searchParams.get("return");
+ const selectionPath = useMemo(() => {
+ const params = new URLSearchParams();
+ if (returnUrl) {
+ params.set("return", returnUrl);
+ }
+ const query = params.toString();
+ return `/filament/labels${query ? `?${query}` : ""}`;
+ }, [returnUrl]);
+
+ useEffect(() => {
+ if (filamentIds.length === 0) {
+ navigate(selectionPath, { replace: true });
+ }
+ }, [filamentIds.length, navigate, selectionPath]);
+
+ return (
+ <>
+ {
+ const returnUrl = searchParams.get("return");
+ if (returnUrl) {
+ navigate(returnUrl, { relative: "path" });
+ } else {
+ navigate("/filament");
+ }
+ }}
+ >
+
+ {filamentIds.length > 0 && }
+
+
+ >
+ );
+};
+
+export default FilamentPrinting;
diff --git a/client/src/pages/filaments/functions.ts b/client/src/pages/filaments/functions.ts
index e19d5b3db..2187e0c7b 100644
--- a/client/src/pages/filaments/functions.ts
+++ b/client/src/pages/filaments/functions.ts
@@ -1,3 +1,4 @@
+import { useQueries } from "@tanstack/react-query";
import { ExternalFilament } from "../../utils/queryExternalDB";
import { getAPIURL } from "../../utils/url";
import { getOrCreateVendorFromExternal } from "../vendors/functions";
@@ -48,3 +49,24 @@ export async function createFilamentFromExternal(externalFilament: ExternalFilam
}
return response.json();
}
+
+/**
+ * Returns an array of queries using the useQueries hook from @tanstack/react-query.
+ * Each query fetches a filament by its ID from the server.
+ *
+ * @param {number[]} ids - An array of filament IDs to fetch.
+ * @return An array of query results, each containing the fetched filament data.
+ */
+export function useGetFilamentsByIds(ids: number[]) {
+ return useQueries({
+ queries: ids.map((id) => {
+ return {
+ queryKey: ["filament", id],
+ queryFn: async () => {
+ const res = await fetch(getAPIURL() + "/filament/" + id);
+ return (await res.json()) as IFilament;
+ },
+ };
+ }),
+ });
+}
diff --git a/client/src/pages/filaments/list.tsx b/client/src/pages/filaments/list.tsx
index 2d42198ce..a44f8ecb6 100644
--- a/client/src/pages/filaments/list.tsx
+++ b/client/src/pages/filaments/list.tsx
@@ -1,4 +1,11 @@
-import { EditOutlined, EyeOutlined, FileOutlined, FilterOutlined, PlusSquareOutlined } from "@ant-design/icons";
+import {
+ EditOutlined,
+ EyeOutlined,
+ FileOutlined,
+ FilterOutlined,
+ PlusSquareOutlined,
+ PrinterOutlined,
+} from "@ant-design/icons";
import { List, useTable } from "@refinedev/antd";
import { useInvalidate, useNavigation, useTranslate } from "@refinedev/core";
import { Button, Dropdown, Table } from "antd";
@@ -169,6 +176,15 @@ export const FilamentList = () => {
(
<>
+ }
+ onClick={() => {
+ navigate("labels");
+ }}
+ >
+ {t("printing.qrcode.selectButton")}
+
}
diff --git a/client/src/pages/filaments/show.tsx b/client/src/pages/filaments/show.tsx
index 4bc6c66be..1db2ce073 100644
--- a/client/src/pages/filaments/show.tsx
+++ b/client/src/pages/filaments/show.tsx
@@ -1,5 +1,6 @@
import { DateField, NumberField, Show, TextField } from "@refinedev/antd";
import { useShow, useTranslate } from "@refinedev/core";
+import { PrinterOutlined } from "@ant-design/icons";
import { Button, Typography } from "antd";
import dayjs from "dayjs";
import utc from "dayjs/plugin/utc";
@@ -10,6 +11,7 @@ import SpoolIcon from "../../components/spoolIcon";
import { enrichText } from "../../utils/parsing";
import { EntityType, useGetFields } from "../../utils/queryFields";
import { useCurrencyFormatter } from "../../utils/settings";
+import { getBasePath, stripBasePath } from "../../utils/url";
import { IFilament } from "./model";
dayjs.extend(utc);
@@ -65,6 +67,19 @@ export const FilamentShow = () => {
+ }
+ href={
+ getBasePath() +
+ "/filament/labels?filaments=" +
+ record?.id +
+ "&return=" +
+ encodeURIComponent(stripBasePath(window.location.pathname))
+ }
+ >
+ {t("printing.qrcode.selectButton")}
+
{defaultButtons}
>
)}
diff --git a/client/src/pages/printing/exportDialog.tsx b/client/src/pages/printing/exportDialog.tsx
new file mode 100644
index 000000000..3bfc248e5
--- /dev/null
+++ b/client/src/pages/printing/exportDialog.tsx
@@ -0,0 +1,1010 @@
+import { DownloadOutlined } from "@ant-design/icons";
+import { useTranslate } from "@refinedev/core";
+import { Button, Checkbox, Col, Collapse, Divider, Form, InputNumber, Radio, Row, Slider, Space } from "antd";
+import * as htmlToImage from "html-to-image";
+import JSZip from "jszip";
+import { ReactElement, useContext, useRef } from "react";
+import { ColorModeContext } from "../../contexts/color-mode";
+import { useSavedState } from "../../utils/saveload";
+import { PrintSettings } from "./printing";
+
+interface ExportDialogProps {
+ items: ReactElement[];
+ printSettings: PrintSettings;
+ setPrintSettings: (setPrintSettings: PrintSettings) => void;
+ style?: string;
+ extraSettings?: ReactElement;
+ extraSettingsStart?: ReactElement;
+ extraFormatSettings?: ReactElement;
+ extraButtons?: ReactElement;
+ zipFileTypeName: string;
+}
+
+// PNG binary utilities — module-level so they are not recreated on every render.
+// Used exclusively by setPngDpiMetadata to inject a pHYs DPI chunk into exported PNGs.
+
+// Standard PNG file signature (first 8 bytes of every valid PNG file).
+const pngSignature = new Uint8Array([137, 80, 78, 71, 13, 10, 26, 10]);
+
+const readUint32BE = (bytes: Uint8Array, offset: number) => {
+ return (
+ ((bytes[offset] << 24) >>> 0) |
+ ((bytes[offset + 1] << 16) >>> 0) |
+ ((bytes[offset + 2] << 8) >>> 0) |
+ (bytes[offset + 3] >>> 0)
+ );
+};
+
+const writeUint32BE = (target: Uint8Array, offset: number, value: number) => {
+ target[offset] = (value >>> 24) & 0xff;
+ target[offset + 1] = (value >>> 16) & 0xff;
+ target[offset + 2] = (value >>> 8) & 0xff;
+ target[offset + 3] = value & 0xff;
+};
+
+const isPng = (bytes: Uint8Array) => {
+ if (bytes.length < pngSignature.length) {
+ return false;
+ }
+ for (let i = 0; i < pngSignature.length; i += 1) {
+ if (bytes[i] !== pngSignature[i]) {
+ return false;
+ }
+ }
+ return true;
+};
+
+const getChunkType = (bytes: Uint8Array, offset: number) => {
+ return String.fromCharCode(bytes[offset], bytes[offset + 1], bytes[offset + 2], bytes[offset + 3]);
+};
+
+// Standard CRC-32 lookup table (IEEE 802.3 polynomial 0xEDB88320, reflected).
+// Computed once at module scope; each pHYs chunk write reuses this table.
+const CRC32_TABLE = (() => {
+ const table = new Uint32Array(256);
+ for (let n = 0; n < 256; n += 1) {
+ let c = n;
+ for (let k = 0; k < 8; k += 1) {
+ c = (c & 1) !== 0 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
+ }
+ table[n] = c >>> 0;
+ }
+ return table;
+})();
+
+const crc32 = (bytes: Uint8Array) => {
+ let crc = 0xffffffff;
+ for (let i = 0; i < bytes.length; i += 1) {
+ crc = CRC32_TABLE[(crc ^ bytes[i]) & 0xff] ^ (crc >>> 8);
+ }
+ return (crc ^ 0xffffffff) >>> 0;
+};
+
+const createPngChunk = (chunkType: string, data: Uint8Array) => {
+ const typeBytes = new Uint8Array([
+ chunkType.charCodeAt(0),
+ chunkType.charCodeAt(1),
+ chunkType.charCodeAt(2),
+ chunkType.charCodeAt(3),
+ ]);
+ const crcInput = new Uint8Array(typeBytes.length + data.length);
+ crcInput.set(typeBytes, 0);
+ crcInput.set(data, typeBytes.length);
+
+ const chunk = new Uint8Array(12 + data.length);
+ writeUint32BE(chunk, 0, data.length);
+ chunk.set(typeBytes, 4);
+ chunk.set(data, 8);
+ writeUint32BE(chunk, 8 + data.length, crc32(crcInput));
+ return chunk;
+};
+
+// Rewrites the exported PNG blob to inject a pHYs chunk with the target DPI.
+// html-to-image produces correct pixel dimensions but no DPI metadata; this
+// chunk tells image viewers and print software the intended resolution.
+const setPngDpiMetadata = async (pngBlob: Blob, dpi: number) => {
+ const bytes = new Uint8Array(await pngBlob.arrayBuffer());
+ if (!isPng(bytes)) {
+ return pngBlob;
+ }
+
+ // html-to-image gives us correct pixel dimensions, but many image viewers still report
+ // 72 DPI unless we write an explicit pHYs chunk into the final PNG bytes.
+ const pixelsPerMeter = Math.max(1, Math.round(dpi / 0.0254));
+ const physData = new Uint8Array(9);
+ writeUint32BE(physData, 0, pixelsPerMeter);
+ writeUint32BE(physData, 4, pixelsPerMeter);
+ physData[8] = 1;
+ const physChunk = createPngChunk("pHYs", physData);
+
+ const chunks: Uint8Array[] = [bytes.slice(0, 8)];
+ let offset = 8;
+ let insertedPhys = false;
+ let removedExistingPhys = false;
+
+ while (offset + 8 <= bytes.length) {
+ const chunkLength = readUint32BE(bytes, offset);
+ const chunkTotalSize = 12 + chunkLength;
+ if (offset + chunkTotalSize > bytes.length) {
+ return pngBlob;
+ }
+
+ const chunkType = getChunkType(bytes, offset + 4);
+ const fullChunk = bytes.slice(offset, offset + chunkTotalSize);
+
+ if (chunkType === "pHYs") {
+ removedExistingPhys = true;
+ offset += chunkTotalSize;
+ continue;
+ }
+
+ if (!insertedPhys && chunkType === "IHDR") {
+ chunks.push(fullChunk);
+ chunks.push(physChunk);
+ insertedPhys = true;
+ } else if (!insertedPhys && chunkType === "IDAT") {
+ chunks.push(physChunk);
+ chunks.push(fullChunk);
+ insertedPhys = true;
+ } else if (!insertedPhys && chunkType === "IEND") {
+ chunks.push(physChunk);
+ chunks.push(fullChunk);
+ insertedPhys = true;
+ } else {
+ chunks.push(fullChunk);
+ }
+
+ offset += chunkTotalSize;
+ }
+
+ if (!insertedPhys && !removedExistingPhys) {
+ return pngBlob;
+ }
+
+ const blobParts: BlobPart[] = chunks.map((chunk) => chunk as unknown as BlobPart);
+ return new Blob(blobParts, { type: "image/png" });
+};
+
+// Render one preview page per exported label and reuse that DOM for PNG/AML generation
+// so the preview stays the source of truth for both single-file and ZIP exports.
+const ExportDialog = ({
+ items,
+ printSettings,
+ setPrintSettings,
+ style,
+ extraSettings,
+ extraSettingsStart,
+ extraFormatSettings,
+ extraButtons,
+ zipFileTypeName,
+}: ExportDialogProps) => {
+ const t = useTranslate();
+ const { mode } = useContext(ColorModeContext);
+
+ const [collapseState, setCollapseState] = useSavedState("export-collapseState", []);
+ const [previewScale, setPreviewScale] = useSavedState("export-previewScale", 0.7);
+
+ const margin = printSettings?.margin || { top: 0, bottom: 0, left: 0, right: 0 };
+ const printerMargin = printSettings?.printerMargin || { top: 0, bottom: 0, left: 0, right: 0 };
+ const customPaperSize = printSettings?.customPaperSize || { width: 40, height: 30 };
+ const exportDpi = printSettings?.exportDpi || 300;
+ const exportFormat = printSettings?.exportFormat || "aml";
+ const exportAsZip = printSettings?.exportAsZip ?? false;
+ const zipPreviewName = `${exportFormat.toUpperCase()} ${zipFileTypeName} labels.zip`;
+ const previewMetaColor = mode === "dark" ? "#bfbfbf" : "#333";
+
+ const paperWidth = customPaperSize.width;
+ const paperHeight = customPaperSize.height;
+ const itemWidth = Math.max(paperWidth - margin.left - margin.right, 0);
+ const itemHeight = Math.max(paperHeight - margin.top - margin.bottom, 0);
+
+ const contentRef = useRef(null);
+
+ const sanitizeFilename = (value: string) => {
+ const trimmed = value.trim();
+ if (trimmed === "") {
+ return "";
+ }
+ return (
+ trimmed
+ // eslint-disable-next-line no-control-regex
+ .replace(/[<>:"/\\|?*\x00-\x1F]/g, "-")
+ .replace(/\s+/g, " ")
+ .replace(/\.+$/g, "")
+ );
+ };
+
+ // Exports deliberately stay one-label-per-page so preview names, AML payloads,
+ // and downloaded files all map 1:1 to a logical label.
+ const pageBlocks: ReactElement[][] = [];
+ for (const item of items) {
+ pageBlocks.push([item]);
+ }
+
+ const pages = pageBlocks.map(function (pageItems, pageIdx) {
+ const pagePreviewSource = pageItems[0];
+ const pageRawPreviewName =
+ (pagePreviewSource?.props as { "data-aml-name"?: string } | undefined)?.["data-aml-name"] ??
+ `label-${pageIdx + 1}`;
+ const pagePreviewName = sanitizeFilename(pageRawPreviewName) || `label-${pageIdx + 1}`;
+
+ const itemDivs = pageItems.map((item, itemIdx) => {
+ return (
+
+ {item}
+
+ );
+ });
+
+ return (
+
+
+
+
+ {itemDivs}
+
+
+
+ {/* Keep preview filenames outside the white label canvas so they stay readable in the
+ UI without becoming part of the exported PNG/AML image content. */}
+