Build a Garment Artwork Exporter
Build a browser editor that exports validated production artwork packages.
Introduction
30 Second Summary
A garment design can look perfect on screen. The print can still come out cropped or soft when the file ignores a machine's real dimensions.
In this project, you will build a browser-based garment editor with a transparent Fabric.js canvas. You will turn each design into an exact-size production package for a local inspection receiver.
What You'll Build
You will demo a garment customizer that transforms an on-screen layout into a validated production package accepted by a local receiver.
By the end of this project, you'll have:
- An interactive garment editor where you can change the garment style. You can also set its color. You can choose the print side. You can position editable text or uploaded artwork inside a machine-specific print area.
- A preflight gate that identifies empty designs. It catches artwork outside the printable area. It also reports uploaded images below the required effective DPI.
- A production handoff that reveals the gap between preview pixels and production dimensions. You can download a transparent artwork.png file at the configured size. You can send it with design.json, manifest.json, plus the original assets to a local receiver.
- Secret Mission: Qualify the exporter with a real preset from your own printer or RIP documentation.
Are there any prerequisites?
Your printer or RIP documentation must provide the usable print-area width plus height.
It must also provide the target DPI or exact pixel dimensions.
Before We Start
A production artwork exporter is trustworthy only when its dimensions come from your printer or RIP documentation. This checkpoint commits you to a garment workflow with a documented printable width, printable height, plus target DPI or exact target pixel dimensions.
Set Up the Frontend and Inspection Receiver
The garment editor depends on a compatible Node.js runtime. Its local inspection receiver also needs a pinned project before multipart behavior can be tested reliably.
In this step, you will verify your local tools. You will then install a pinned React, Vite, and Fabric.js project before launching its frontend and receiver.
In this step, get ready to:
- Verify Node.js, Visual Studio Code, and Google Chrome.
- Create the project files and install their pinned dependencies.
- Run the frontend and inspection receiver in separate terminals.
Check your local tools
Vite requires Node.js 20.19+ or 22.12+. Node.js v24.21.0 LTS is the recommended runtime for this project.
- Press Cmd+Space on macOS or the Windows key on Windows to open system search.
- Type Terminal on macOS or Command Prompt on Windows.
- Press Enter to open the terminal.
- Check your Node.js version by running:
node --version
What does this command do?
The command prints the Node.js version available in this terminal. The result determines whether Vite can run the project.
Compare the printed version with the supported ranges below.
✔️ I see a compatible version
Your Node.js runtime meets Vite's requirement. Keep this terminal available for the project setup.
ⓧ I see an incompatible version
Your current runtime falls outside Vite's supported ranges. Install Node.js v24.21.0 LTS before continuing.
- Open the official Node.js download page.
- Download the Node.js v24.21.0 LTS installer for your operating system.
- Run the installer using its default options.
- Close the current terminal after installation.
- Open a new terminal through system search.
- Repeat the version check above.
ⓧ Command not found
Node.js is missing from your terminal. Install Node.js v24.21.0 LTS before continuing.
- Open the official Node.js download page.
- Download the Node.js v24.21.0 LTS installer for your operating system.
- Run the installer using its default options.
- Close the current terminal after installation.
- Open a new terminal through system search.
- Repeat the version check above.
- Press Cmd+Space on macOS or the Windows key on Windows to return to system search.
- Type Visual Studio Code.
- Press Enter to confirm the editor opens.
- Return to system search.
- Type Google Chrome.
- Press Enter to confirm the browser opens.
Missing an app?
Install Visual Studio Code or Google Chrome from its official download page. Repeat the search check after installation.
If an installed app still does not appear, help me find an installed app that is missing from system search.
Create the pinned project
The project files define one fixed dependency set. They also establish the frontend and multipart receiver that later steps exercise.
- Switch back to Visual Studio Code.
- Click File in the top menu bar.
- Click Open Folder.
- Select your Desktop in the folder picker.
- Create a folder named garment-print-exporter.
- Select the garment-print-exporter folder.
- Click Open.
- Create package.json inside garment-print-exporter using the New File control in the Explorer sidebar.
- Create vite.config.js inside garment-print-exporter.
- Create index.html inside garment-print-exporter.
- Create server.mjs inside garment-print-exporter.
- Create a folder named src inside garment-print-exporter using the New Folder control.
Your project boundary
The top-level files configure Vite and run the inspection receiver. The src folder contains the browser application.
- Create main.jsx inside src.
- Create App.jsx inside src.
- Create printPackage.js inside src.
- Create index.css inside src.
- Use the full-code tab below to fill every file.
- Save all eight files.
✔️ Awesome, I've got everything!
Great. Confirm that the Explorer sidebar lists the four top-level files plus the four files inside src.
ⓧ I'd like to double check the full code
Compare each file with the matching reference below. Preserve every filename and value exactly.
{
"name": "garment-print-exporter",
"private": true,
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "vite build",
"backend": "node server.mjs"
},
"dependencies": {
"fabric": "7.4.0",
"react": "19.3.0",
"react-dom": "19.3.0"
},
"devDependencies": {
"@vitejs/plugin-react": "6.1.1",
"vite": "8.3.0"
}
}
What does this file do?
This file pins every package version. It also defines the commands that start the frontend, build the app, and run the receiver.
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
export default defineConfig({
plugins: [react()],
server: {
port: 5173,
strictPort: true,
proxy: {
'/api': 'http://127.0.0.1:8787',
},
},
})
What does this file do?
This configuration enables React and fixes the development port. It sends relative API requests to the local inspection receiver.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Garment Print Exporter</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
What does this file do?
This document provides the browser root element. It loads the React entry point as a module.
import { Buffer } from 'node:buffer'
import { createServer } from 'node:http'
const port = 8787
const requiredParts = ['artwork', 'design', 'manifest']
const server = createServer((request, response) => {
if (request.method !== 'POST' || request.url !== '/api/print-jobs') {
response.writeHead(404, { 'Content-Type': 'application/json' })
response.end(JSON.stringify({ ok: false, error: 'Route not found' }))
return
}
const chunks = []
request.on('data', (chunk) => chunks.push(chunk))
request.on('end', () => {
const body = Buffer.concat(chunks)
const bodyText = body.toString('latin1')
const contentType = request.headers['content-type'] ?? ''
const isMultipart = contentType.startsWith('multipart/form-data; boundary=')
const receivedParts = requiredParts.filter((name) =>
bodyText.includes(`name="${name}"`),
)
const ok = isMultipart && receivedParts.length === requiredParts.length
const result = {
ok,
receivedBytes: body.length,
receivedParts,
hasOriginalAssets: bodyText.includes('name="assets"'),
}
console.log('Received print package:', result)
response.writeHead(ok ? 201 : 400, {
'Content-Type': 'application/json',
})
response.end(JSON.stringify(result))
})
request.on('error', (error) => {
console.error(error)
if (!response.headersSent) {
response.writeHead(500, { 'Content-Type': 'application/json' })
}
response.end(JSON.stringify({ ok: false, error: 'Upload failed' }))
})
})
server.listen(port, '127.0.0.1', () => {
console.log(`Inspection receiver listening at http://127.0.0.1:${port}`)
})
What does this file do?
This receiver checks the request route and multipart field names. It reports which required parts arrived without storing the uploaded files.
import { createRoot } from 'react-dom/client'
import App from './App.jsx'
import './index.css'
createRoot(document.getElementById('root')).render(<App />)
What does this file do?
This entry point loads the application and stylesheet. It renders the interface inside the document root.
import { FabricImage } from 'fabric'
export function preflightDesign(canvas, spec) {
const errors = []
const objects = canvas.getObjects()
if (objects.length === 0) {
errors.push('Add at least one artwork element before exporting.')
}
for (const [index, object] of objects.entries()) {
const bounds = object.getBoundingRect()
const outside =
bounds.left < 0 ||
bounds.top < 0 ||
bounds.left + bounds.width > canvas.getWidth() ||
bounds.top + bounds.height > canvas.getHeight()
if (outside) {
errors.push(`Element ${index + 1} extends outside the printable area.`)
}
if (object instanceof FabricImage) {
const physicalWidth =
(object.getScaledWidth() / canvas.getWidth()) * spec.widthInches
const physicalHeight =
(object.getScaledHeight() / canvas.getHeight()) * spec.heightInches
const effectiveDpi = Math.floor(
Math.min(
object.sourcePixelWidth / physicalWidth,
object.sourcePixelHeight / physicalHeight,
),
)
if (!Number.isFinite(effectiveDpi) || effectiveDpi < spec.dpi) {
errors.push(
`${object.assetName ?? `Image ${index + 1}`} is approximately ${effectiveDpi || 0} DPI at its placed size; ${spec.dpi} DPI is required by this preset.`,
)
}
}
}
return errors
}
function renderExactPng(canvas, spec) {
const multiplier = spec.pixelWidth / canvas.getWidth()
const renderedCanvas = canvas.toCanvasElement(multiplier)
const outputCanvas = document.createElement('canvas')
outputCanvas.width = spec.pixelWidth
outputCanvas.height = spec.pixelHeight
const context = outputCanvas.getContext('2d')
if (!context) {
throw new Error('The browser could not create the production canvas.')
}
context.clearRect(0, 0, outputCanvas.width, outputCanvas.height)
context.drawImage(
renderedCanvas,
0,
0,
outputCanvas.width,
outputCanvas.height,
)
return new Promise((resolve, reject) => {
outputCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('The browser could not encode artwork.png.'))
}
}, 'image/png')
})
}
export async function buildPrintPackage(canvas, spec, assets) {
const errors = preflightDesign(canvas, spec)
if (errors.length > 0) {
throw new Error(errors.join('\n'))
}
const artworkBlob = await renderExactPng(canvas, spec)
const designData = {
previewCanvas: {
width: canvas.getWidth(),
height: canvas.getHeight(),
},
fabric: canvas.toObject([
'assetName',
'sourcePixelWidth',
'sourcePixelHeight',
]),
}
const manifest = {
schemaVersion: '1.0',
designId: spec.designId,
productId: spec.productId,
garment: {
style: spec.garmentStyle,
color: spec.garmentColor,
},
printArea: {
side: spec.side,
widthInches: spec.widthInches,
heightInches: spec.heightInches,
intendedDpi: spec.dpi,
pixelWidth: spec.pixelWidth,
pixelHeight: spec.pixelHeight,
},
artwork: {
filename: 'artwork.png',
mimeType: 'image/png',
browserEncodedDpiMetadata: 96,
},
editableDesign: {
filename: 'design.json',
format: 'Fabric.js JSON',
},
assets: assets.map((file) => ({
filename: file.name,
mimeType: file.type,
bytes: file.size,
})),
createdAt: new Date().toISOString(),
}
const designBlob = new Blob([JSON.stringify(designData, null, 2)], {
type: 'application/json',
})
const manifestBlob = new Blob([JSON.stringify(manifest, null, 2)], {
type: 'application/json',
})
const formData = new FormData()
formData.append('artwork', artworkBlob, 'artwork.png')
formData.append('design', designBlob, 'design.json')
formData.append('manifest', manifestBlob, 'manifest.json')
for (const file of assets) {
formData.append('assets', file, file.name)
}
return { artworkBlob, designBlob, manifestBlob, formData, manifest }
}
export async function submitPrintPackage(formData) {
const response = await fetch('/api/print-jobs', {
method: 'POST',
body: formData,
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error ?? `Upload failed with status ${response.status}.`)
}
return result
}
What does this file do?
This module validates the canvas and renders exact output pixels. It also assembles the design data, production manifest, original assets, and multipart request.
import { useEffect, useRef, useState } from 'react'
import { Canvas, FabricImage, Textbox } from 'fabric'
import {
buildPrintPackage,
preflightDesign,
submitPrintPackage,
} from './printPackage.js'
const initialSpec = {
designId: 'design-001',
productId: 'garment-001',
garmentStyle: 'tshirt',
garmentColor: '#111827',
side: 'front',
widthInches: '',
heightInches: '',
dpi: '',
}
function fitPreview(width, height) {
const scale = Math.min(360 / width, 480 / height)
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('The selected image could not be read.'))
}
})
reader.addEventListener('error', () => {
reject(reader.error ?? new Error('The selected image could not be read.'))
})
reader.readAsDataURL(file)
})
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export default function App() {
const canvasElementRef = useRef(null)
const canvasRef = useRef(null)
const assetsRef = useRef([])
const [draftSpec, setDraftSpec] = useState(initialSpec)
const [activeSpec, setActiveSpec] = useState(null)
const [canvasSize, setCanvasSize] = useState({ width: 300, height: 400 })
const [canvasReady, setCanvasReady] = useState(false)
const [messages, setMessages] = useState([])
const [status, setStatus] = useState('Enter your printer settings to begin.')
const [lastManifest, setLastManifest] = useState(null)
useEffect(() => {
const canvas = new Canvas(canvasElementRef.current, {
width: 300,
height: 400,
preserveObjectStacking: true,
})
canvasRef.current = canvas
setCanvasReady(true)
return () => {
canvasRef.current = null
canvas.dispose()
}
}, [])
function updateSpec(event) {
const { name, value } = event.target
setDraftSpec((current) => ({ ...current, [name]: value }))
}
function applyPrintSpec() {
const canvas = canvasRef.current
const widthInches = Number(draftSpec.widthInches)
const heightInches = Number(draftSpec.heightInches)
const dpi = Number(draftSpec.dpi)
if (
!canvas ||
!draftSpec.designId.trim() ||
!draftSpec.productId.trim() ||
!Number.isFinite(widthInches) ||
!Number.isFinite(heightInches) ||
!Number.isFinite(dpi) ||
widthInches <= 0 ||
heightInches <= 0 ||
dpi <= 0
) {
setMessages(['Enter IDs plus positive width, height, and DPI values.'])
return
}
const pixelWidth = Math.round(widthInches * dpi)
const pixelHeight = Math.round(heightInches * dpi)
if (pixelWidth < 1 || pixelHeight < 1) {
setMessages(['The production pixel dimensions must be at least 1 by 1.'])
return
}
const preview = fitPreview(pixelWidth, pixelHeight)
const applied = {
...draftSpec,
widthInches,
heightInches,
dpi,
pixelWidth,
pixelHeight,
}
canvas.clear()
canvas.setDimensions(preview)
const starterText = new Textbox('YOUR DESIGN', {
left: preview.width / 2,
top: preview.height / 2,
originX: 'center',
originY: 'center',
width: preview.width * 0.72,
fill: '#ffffff',
fontFamily: 'sans-serif',
fontSize: Math.max(22, preview.width * 0.09),
fontWeight: 700,
textAlign: 'center',
})
canvas.add(starterText)
canvas.setActiveObject(starterText)
canvas.requestRenderAll()
assetsRef.current = []
setActiveSpec(applied)
setCanvasSize(preview)
setMessages([])
setLastManifest(null)
setStatus(
`Print spec applied: ${pixelWidth} × ${pixelHeight} target pixels.`,
)
}
function addText() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
const text = new Textbox('EDIT ME', {
left: canvas.getWidth() / 2,
top: canvas.getHeight() / 2,
originX: 'center',
originY: 'center',
width: canvas.getWidth() * 0.6,
fill: '#ffffff',
fontFamily: 'sans-serif',
fontSize: Math.max(20, canvas.getWidth() * 0.08),
fontWeight: 700,
textAlign: 'center',
})
canvas.add(text)
canvas.setActiveObject(text)
canvas.requestRenderAll()
setStatus('Editable text added. Double-click it to change the wording.')
}
async function addImage(event) {
const canvas = canvasRef.current
const file = event.target.files?.[0]
event.target.value = ''
if (!canvas || !activeSpec || !file) return
try {
const dataUrl = await readFileAsDataUrl(file)
const image = await FabricImage.fromURL(dataUrl)
image.set({
assetName: file.name,
sourcePixelWidth: image.width,
sourcePixelHeight: image.height,
})
const maxWidth = canvas.getWidth() * 0.68
const maxHeight = canvas.getHeight() * 0.68
if (image.getScaledWidth() > maxWidth) image.scaleToWidth(maxWidth)
if (image.getScaledHeight() > maxHeight) image.scaleToHeight(maxHeight)
canvas.add(image)
canvas.centerObject(image)
image.setCoords()
canvas.setActiveObject(image)
canvas.requestRenderAll()
assetsRef.current = [...assetsRef.current, file]
setMessages([])
setStatus(`${file.name} added with its original source dimensions.`)
} catch (error) {
setMessages([error.message])
}
}
function deleteSelected() {
const canvas = canvasRef.current
if (!canvas) return
const selected = canvas.getActiveObjects()
canvas.remove(...selected)
canvas.discardActiveObject()
canvas.requestRenderAll()
setStatus(`${selected.length} selected element(s) removed.`)
}
function checkPreflight() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
const errors = preflightDesign(canvas, activeSpec)
setMessages(errors)
setStatus(errors.length === 0 ? 'Preflight passed.' : 'Preflight failed.')
}
async function downloadPreview() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
const blob = await canvas.toBlob({ format: 'png' })
if (!blob) {
setMessages(['The browser could not create the preview PNG.'])
return
}
downloadBlob(blob, `${activeSpec.designId}-preview-not-production.png`)
setStatus(
`Preview-only export: ${canvas.getWidth()} × ${canvas.getHeight()} pixels.`,
)
}
async function downloadProduction() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
try {
const printPackage = await buildPrintPackage(
canvas,
activeSpec,
assetsRef.current,
)
downloadBlob(
printPackage.artworkBlob,
`${activeSpec.designId}-artwork.png`,
)
setLastManifest(printPackage.manifest)
setMessages([])
setStatus(
`Production PNG created at ${activeSpec.pixelWidth} × ${activeSpec.pixelHeight} pixels.`,
)
} catch (error) {
setMessages(error.message.split('\n'))
setStatus('Production export blocked by preflight.')
}
}
async function sendPackage() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
try {
setStatus('Building and sending the print package...')
const printPackage = await buildPrintPackage(
canvas,
activeSpec,
assetsRef.current,
)
const result = await submitPrintPackage(printPackage.formData)
setLastManifest(printPackage.manifest)
setMessages([])
setStatus(
`Upload accepted: ${result.receivedParts.join(', ')}; ${result.receivedBytes} bytes received.`,
)
} catch (error) {
setMessages(error.message.split('\n'))
setStatus('Package upload failed.')
}
}
const controlsDisabled = !canvasReady || !activeSpec
return (
<main className="app-shell">
<header>
<p className="eyebrow">Production artwork workflow</p>
<h1>Garment Print Exporter</h1>
<p>
Design over a garment preview, validate the artwork, and send a
reproducible production package to your backend.
</p>
</header>
<section className="workspace">
<aside className="panel">
<h2>Printer preset</h2>
<div className="form-grid">
<label>
Design ID
<input name="designId" value={draftSpec.designId} onChange={updateSpec} />
</label>
<label>
Product ID
<input name="productId" value={draftSpec.productId} onChange={updateSpec} />
</label>
<label>
Garment style
<select name="garmentStyle" value={draftSpec.garmentStyle} onChange={updateSpec}>
<option value="tshirt">T-shirt</option>
<option value="hoodie">Hoodie</option>
</select>
</label>
<label>
Print side
<select name="side" value={draftSpec.side} onChange={updateSpec}>
<option value="front">Front</option>
<option value="back">Back</option>
</select>
</label>
<label>
Garment color
<input type="color" name="garmentColor" value={draftSpec.garmentColor} onChange={updateSpec} />
</label>
<label>
Width in inches
<input type="number" min="0.1" step="0.1" name="widthInches" value={draftSpec.widthInches} onChange={updateSpec} />
</label>
<label>
Height in inches
<input type="number" min="0.1" step="0.1" name="heightInches" value={draftSpec.heightInches} onChange={updateSpec} />
</label>
<label>
Target DPI
<input type="number" min="1" step="1" name="dpi" value={draftSpec.dpi} onChange={updateSpec} />
</label>
</div>
<button onClick={applyPrintSpec}>Apply Print Spec</button>
<h2>Artwork tools</h2>
<div className="button-stack">
<button disabled={controlsDisabled} onClick={addText}>Add Text</button>
<label className={`file-button ${controlsDisabled ? 'disabled' : ''}`}>
Upload PNG or JPEG
<input disabled={controlsDisabled} type="file" accept="image/png,image/jpeg" onChange={addImage} />
</label>
<button disabled={controlsDisabled} onClick={deleteSelected}>Delete Selected</button>
</div>
<h2>Production</h2>
<div className="button-stack">
<button disabled={controlsDisabled} onClick={checkPreflight}>Check Preflight</button>
<button disabled={controlsDisabled} onClick={downloadPreview}>Download Preview PNG</button>
<button disabled={controlsDisabled} onClick={downloadProduction}>Download Production PNG</button>
<button className="primary" disabled={controlsDisabled} onClick={sendPackage}>Send Package to Backend</button>
</div>
</aside>
<section className="stage-panel">
<div className={`garment ${draftSpec.garmentStyle}`} style={{ '--garment-color': draftSpec.garmentColor }}>
<span className="garment-label">{draftSpec.garmentStyle} · {draftSpec.side}</span>
<div className="print-area" style={{ width: canvasSize.width, height: canvasSize.height }}>
<canvas ref={canvasElementRef} />
</div>
</div>
<div className="status-card">
<strong>{status}</strong>
{activeSpec && (
<p>
Target: {activeSpec.pixelWidth} × {activeSpec.pixelHeight} pixels at an intended {activeSpec.dpi} DPI.
</p>
)}
<p className="warning">
Canvas PNG metadata is 96 DPI. Your backend or RIP must apply the physical dimensions and intended DPI from the manifest.
</p>
{messages.length > 0 && (
<ul className="errors">
{messages.map((message) => <li key={message}>{message}</li>)}
</ul>
)}
</div>
{lastManifest && (
<details className="manifest">
<summary>Last generated manifest</summary>
<pre>{JSON.stringify(lastManifest, null, 2)}</pre>
</details>
)}
</section>
</section>
</main>
)
}
What does this file do?
This component owns the canvas, printer form, artwork controls, export actions, status messages, and manifest preview. It connects the visible interface to the print-package module.
:root {
font-family: Inter, ui-sans-serif, system-ui, sans-serif;
color: #e5e7eb;
background: #07111f;
font-synthesis: none;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
min-width: 320px;
min-height: 100vh;
}
button,
input,
select {
font: inherit;
}
button,
.file-button {
border: 1px solid #334155;
border-radius: 10px;
padding: 0.7rem 0.9rem;
color: #e5e7eb;
background: #172033;
cursor: pointer;
}
button:hover,
.file-button:hover {
border-color: #60a5fa;
}
button:disabled,
.file-button.disabled {
cursor: not-allowed;
opacity: 0.45;
}
button.primary {
border-color: #38bdf8;
background: #0369a1;
}
.app-shell {
width: min(1180px, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 4rem;
}
header {
margin-bottom: 1.5rem;
}
header h1 {
margin: 0.25rem 0;
font-size: clamp(2rem, 5vw, 3.4rem);
}
header p {
max-width: 760px;
color: #94a3b8;
}
.eyebrow {
margin: 0;
color: #38bdf8;
font-size: 0.78rem;
font-weight: 800;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.workspace {
display: grid;
grid-template-columns: minmax(280px, 360px) minmax(0, 1fr);
gap: 1.25rem;
align-items: start;
}
.panel,
.stage-panel,
.status-card,
.manifest {
border: 1px solid #1e293b;
border-radius: 16px;
background: #0f172a;
}
.panel {
padding: 1rem;
}
.panel h2 {
margin: 1.1rem 0 0.7rem;
font-size: 1rem;
}
.panel h2:first-child {
margin-top: 0;
}
.form-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
margin-bottom: 0.8rem;
}
label {
display: grid;
gap: 0.35rem;
color: #cbd5e1;
font-size: 0.82rem;
}
input,
select {
width: 100%;
border: 1px solid #334155;
border-radius: 8px;
padding: 0.58rem;
color: #f8fafc;
background: #111827;
}
input[type='color'] {
min-height: 42px;
padding: 0.2rem;
}
.button-stack {
display: grid;
gap: 0.55rem;
}
.file-button {
display: block;
text-align: center;
}
.file-button input {
display: none;
}
.stage-panel {
padding: 1rem;
overflow: hidden;
}
.garment {
--garment-color: #111827;
position: relative;
display: grid;
place-items: center;
min-height: 610px;
overflow: hidden;
border-radius: 14px;
background: radial-gradient(circle at 50% 28%, color-mix(in srgb, var(--garment-color), white 12%), var(--garment-color) 58%);
}
.garment::before,
.garment::after {
content: '';
position: absolute;
top: 30px;
width: 140px;
height: 210px;
border-radius: 70px;
background: var(--garment-color);
}
.garment::before {
left: -55px;
transform: rotate(28deg);
}
.garment::after {
right: -55px;
transform: rotate(-28deg);
}
.garment.hoodie {
box-shadow: inset 0 48px 0 color-mix(in srgb, var(--garment-color), black 18%);
}
.garment-label {
position: absolute;
top: 1rem;
left: 1rem;
z-index: 2;
border-radius: 999px;
padding: 0.35rem 0.65rem;
color: #e2e8f0;
background: rgb(15 23 42 / 75%);
font-size: 0.75rem;
text-transform: capitalize;
}
.print-area {
position: relative;
z-index: 1;
border: 2px dashed rgb(255 255 255 / 65%);
background-image:
linear-gradient(45deg, rgb(255 255 255 / 8%) 25%, transparent 25%),
linear-gradient(-45deg, rgb(255 255 255 / 8%) 25%, transparent 25%),
linear-gradient(45deg, transparent 75%, rgb(255 255 255 / 8%) 75%),
linear-gradient(-45deg, transparent 75%, rgb(255 255 255 / 8%) 75%);
background-position: 0 0, 0 8px, 8px -8px, -8px 0;
background-size: 16px 16px;
}
.canvas-container {
width: 100% !important;
height: 100% !important;
}
.canvas-container canvas {
max-width: 100%;
}
.status-card,
.manifest {
margin-top: 1rem;
padding: 1rem;
}
.status-card p {
margin-bottom: 0;
color: #94a3b8;
}
.warning {
color: #fbbf24 !important;
}
.errors {
margin-bottom: 0;
color: #fca5a5;
}
.manifest pre {
max-height: 320px;
overflow: auto;
white-space: pre-wrap;
color: #bae6fd;
}
@media (max-width: 860px) {
.workspace {
grid-template-columns: 1fr;
}
.garment {
min-height: 540px;
}
}
@media (max-width: 520px) {
.form-grid {
grid-template-columns: 1fr;
}
.stage-panel {
overflow-x: auto;
}
}
What does this file do?
This stylesheet builds the dark responsive workspace and garment mockup. It keeps the checkerboard print area visually separate from the garment background.
The pinned versions in package.json keep the setup reproducible. Installing from that file also creates the local dependency directory used by Vite.
- Open Visual Studio Code's integrated terminal by pressing Ctrl+`.
- Install the pinned dependencies by running:
npm install
What does this command do?
The command reads package.json and installs its dependencies into the local project. The exact version pins keep the application behavior consistent.
The first installation can take a few minutes while npm downloads the packages.
A completed installation without an npm error confirms that React 19.3.0, React DOM 19.3.0, Vite 8.3.0, the React plugin 6.1.1, and Fabric.js 7.4.0 are available locally.
Dependency installation failed?
Confirm that the terminal is inside the garment-print-exporter folder. Confirm that package.json matches the reference before trying again.
If npm reports a runtime compatibility problem, repeat the Node.js version check. Help me diagnose an npm installation failure in this pinned Vite project.
Launch both local services
The browser interface and inspection receiver run as separate processes. Keeping each process in its own terminal makes their output easy to distinguish.
Before you start the services, predict which terminal will print the receiver address and which terminal will print the browser address.
- Start the inspection receiver in the current terminal by running:
npm run backend
What does this command do?
This script starts server.mjs on the local receiver port. The process stays active so it can inspect future multipart requests.
You should see Inspection receiver listening at http://127.0.0.1:8787 in the terminal.
- Open the Terminal menu in Visual Studio Code.
- Click New Terminal to create the second terminal.
- Start the Vite development server in the second terminal by running:
npm run dev
What does this command do?
This script starts Vite on the configured development port. The terminal stays active while Vite serves the React application.
You should see a local address containing http://localhost:5173.
Before you load the page, predict which parts of the garment exporter should already be visible.
- Switch back to Google Chrome.
- Enter http://localhost:5173 in the address bar.
- Press Enter to load the app.
You should see the Garment Print Exporter with a printer preset panel, artwork controls, production controls, and a garment preview. The status asks you to enter printer settings.
One of the services did not start?
Confirm that each terminal is inside garment-print-exporter. Keep the receiver running in one terminal while Vite runs in the other.
If Chrome cannot load the page, inspect the Vite terminal for the first reported error. Help me debug my local Vite frontend or Node inspection receiver.
Your pinned frontend and inspection receiver are now running together. Next, you will turn your documented printer dimensions into an editable print area.
Configure the Editable Print Area
Your local exporter is already running in Google Chrome. The frontend now needs a printer-specific scale.
A fixed browser canvas has no physical meaning until a production specification defines it. This step converts your documented dimensions into target pixels for a proportioned Fabric.js print area.
In this step, get ready to:
- Define the production preset for your garment job.
- Apply the preset to calculate the target pixel dimensions.
- Edit text with the canvas controls.
Enter your printer preset
A printer preset stores every value that defines this production job.
- Return to the garment exporter tab from earlier in Google Chrome.
- Enter design-001 in the Design ID field.
- Enter garment-001 in the Product ID field.
- Select the option matching your garment under Garment style.
- Select the documented production side under Print side.
- Choose the intended garment color in the Garment color field.
Use Documented Production Values
The usable print area comes from your printer or RIP documentation. The target DPI comes from that same workflow.
These values control the output pixels. Estimates weaken the production contract.
- Enter your documented print width in inches in the Width in inches field.
- Enter your documented print height in inches in the Height in inches field.
- Enter your documented target DPI in the Target DPI field.
Apply the print specification
Target pixels come from multiplying each physical dimension by the configured DPI. The fitPreview helper preserves that aspect ratio while fitting the artwork canvas inside the browser preview.
Before you apply the preset, make a mental prediction about which target dimension will be larger.
- Click Apply Print Spec.
You should see a status beginning with Print spec applied: followed by the calculated target pixel dimensions.
What Did Apply Print Spec Do?
- The applyPrintSpec handler validates the IDs plus each positive dimension.
- The activeSpec state stores the validated preset plus its target pixel dimensions.
- The pixelWidth and pixelHeight values represent the physical dimensions multiplied by dpi.
- The fitPreview helper scales the visible canvas while preserving the production aspect ratio.
- The handler clears the canvas before adding a selected Textbox containing YOUR DESIGN.
- Compare the displayed target width with your documented width multiplied by the target DPI.
- Compare the displayed target height with your documented height multiplied by the target DPI.
- Inspect the dashed checkerboard area over the garment preview.
That is the crucial conversion done. Your canvas now represents a documented physical area.
Why Does the Garment Stay Outside the Canvas?
The colored garment is a CSS preview around the Fabric.js canvas.
Only objects inside the dashed checkerboard become export artwork. This keeps the production canvas transparent.
Edit and transform canvas text
A Fabric.js Textbox supports direct editing through the canvas. Its transform controls change the object's placement.
- Click Add Text.
- Double-click EDIT ME to enter text-editing mode.
- Drag across EDIT ME to highlight the existing wording.
- Type SECOND LINE to replace the highlighted wording.
You should see SECOND LINE as a separate editable object inside the print area.
- Click an empty part of the checkerboard to leave text-editing mode.
- Drag SECOND LINE to a new position inside the dashed boundary.
- Drag a corner control to resize the selected text.
- Drag the rotation control to rotate the selected text.
Before the final check, make a mental prediction about where the garment color belongs relative to the transparent artwork canvas.
- Click the original YOUR DESIGN text to select it.
- Inspect the selection controls around the original text.
- Compare the checkerboard proportions with the target dimensions in the status card.
- Inspect the area around the dashed checkerboard boundary.
You should see selection controls around YOUR DESIGN inside a checkerboard whose proportions match the target dimensions. The selected garment color stays around the transparent canvas.
You now have an editable print area tied to a documented production scale.
Can't Edit or Transform the Text?
- Click the text once to show its selection handles.
- Double-click the wording after the selection handles appear.
- Keep the text inside the dashed boundary while testing its transform controls.
Still stuck? Help me troubleshoot text editing and transform controls in my Fabric.js canvas..
Your print area now carries documented physical meaning. Next, raster artwork will expose why a screen-sized preview PNG cannot serve as a production file.
Add Artwork and Test Preview Exports
Your printer specification now turns documented physical dimensions into target pixels. Your local app remains open in Google Chrome with an editable print area.
This step adds raster artwork to the canvas. You will transform each object before using the preflight check to expose placement problems.
A design can look correct in the browser while its preview export contains too few pixels for the physical print. You will put that screen-sized export to the test.
In this step, get ready to:
- Add editable text plus uploaded artwork to the print area.
- Trigger boundary failures plus effective DPI failures.
- Download a preview PNG to compare its dimensions with the production target.
Add and transform artwork
The upload control accepts PNG or JPEG artwork. Each uploaded image keeps its original source pixel dimensions while you transform its canvas copy.
The original browser File also remains available in assetsRef. Every new upload joins the files already stored there.
- Switch back to the garment exporter in Google Chrome.
- Click Upload PNG or JPEG in the Artwork tools panel.
- Choose a PNG or JPEG file from your computer.
You will see the uploaded image centered inside the print area. The status confirms that the file was added with its original source dimensions.
What Survives the Upload?
The original file stays in assetsRef for the production package. Uploading another image adds another original file without replacing the earlier one.
The canvas copy is a FabricImage with assetName, sourcePixelWidth, and sourcePixelHeight properties. Preflight uses those source dimensions after you scale the image.
- Select the uploaded image on the canvas.
- Drag the image to a new position inside the dashed print boundary.
- Drag a corner control to resize the image.
- Drag the rotation control to rotate the image.
Your uploaded artwork is now editable directly on the canvas. Its original file remains unchanged.
- Click Add Text in the Artwork tools panel.
- Double-click the new EDIT ME text object.
- Replace the wording with FRONT PRINT TEST.
- Select the extra text object.
- Click Delete Selected.
That completes the editing loop. You can upload objects before moving them, scaling them, rotating them, editing their text, or removing them.
Artwork Missing or Stuck?
- Confirm that you applied a valid print specification before using the upload control.
- Choose a file in PNG or JPEG format if the file picker does not accept your image.
- Click directly on an object before trying to transform or delete it.
Still stuck? Help me diagnose why an uploaded Fabric.js object is missing or cannot be transformed.
Force a preflight failure
Effective DPI estimates how many source pixels remain across each physical inch after scaling. Enlarging raster artwork spreads the same source pixels over more physical space.
Preflight checks three production risks. It catches an empty design, objects outside the canvas, and uploaded images below the active target DPI.
How Preflight Evaluates Artwork
- The object count proves that the design contains at least one artwork element.
- Each transformed bounding box must stay within the canvas width and height.
- Each raster image must retain enough source pixels for its placed physical width and height.
These checks use the active printer specification. Changing the physical dimensions or target DPI changes the result.
- Select the uploaded image.
- Drag part of the image across the dashed print boundary.
Before you check, predict which placement problem preflight reports first.
- Click Check Preflight in the Production panel.
The status changes to Preflight failed. You will see an error identifying an element that extends outside the printable area.
That failure is intentional. The checker has proven that a visually reachable canvas position can still violate the printable boundary.
- Move the uploaded image fully inside the dashed print boundary.
- Scale the uploaded image until it fills most of the printable area.
- Click Check Preflight again.
When the placed image falls below the preset target, you will see an error containing its approximate DPI. The error also identifies the DPI required by the active preset.
No Low-DPI Error?
Your selected image may contain enough source pixels for its current placed size. Use a smaller raster source to make the resolution loss visible.
- Click Upload PNG or JPEG.
- Choose an image with fewer source pixels.
- Enlarge the new image across most of the print area.
- Click Check Preflight.
Need help? Help me trigger and understand the effective-DPI preflight check.
Download the preview-only PNG
The preview button passes the current canvas to canvas.toBlob({ format: 'png' }). That export captures the dimensions used to display the print area in the browser.
The status card also shows the target pixel dimensions calculated from the physical width, height, and DPI. This gives you two sets of dimensions to compare.
- Read the production pixel dimensions shown after Target in the status card.
Before you export, predict whether the preview canvas dimensions will match the target dimensions.
- Click Download Preview PNG.
Your browser downloads a PNG whose filename includes preview-not-production. The status reports the canvas preview width and height.
- Compare the preview dimensions in the status with the target dimensions directly below them.
You will see that the preview export uses the smaller screen canvas dimensions. The target line shows the larger pixel dimensions required by the active print specification.
Why the Preview Falls Short
The preview canvas is scaled to fit comfortably over the garment mockup. Its size serves browser interaction.
The production target comes from physical inches multiplied by the intended DPI. A useful on-screen preview therefore carries fewer pixels than the production file requires.
You have exposed the exact gap this test was designed to reveal. The editor works on screen, while the downloaded preview remains too small for the production target.
Next, you will block invalid artwork and render a transparent PNG at the exact target pixel dimensions.
Generate the Production Package
Your preview export exposed the production gap. Its screen-sized pixels cannot describe the physical artwork that the printer must reproduce.
This step closes that gap with exact target pixels. It also packages the editable design plus production metadata so the result can be reproduced outside the browser.
In this step, get ready to:
- Render a transparent artwork PNG at the configured production dimensions.
- Package the editable design plus production metadata.
- Download the validated production artwork for inspection.
Render exact production pixels
The preview canvas exists for comfortable editing. The production renderer scales those same objects to the exact pixel width from the active print specification.
- Switch back to src/printPackage.js in Visual Studio Code.
- Add the production renderer below preflightDesign() by pasting this code:
function renderExactPng(canvas, spec) {
const multiplier = spec.pixelWidth / canvas.getWidth()
const renderedCanvas = canvas.toCanvasElement(multiplier)
const outputCanvas = document.createElement('canvas')
outputCanvas.width = spec.pixelWidth
outputCanvas.height = spec.pixelHeight
const context = outputCanvas.getContext('2d')
if (!context) {
throw new Error('The browser could not create the production canvas.')
}
context.clearRect(0, 0, outputCanvas.width, outputCanvas.height)
context.drawImage(
renderedCanvas,
0,
0,
outputCanvas.width,
outputCanvas.height,
)
return new Promise((resolve, reject) => {
outputCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('The browser could not encode artwork.png.'))
}
}, 'image/png')
})
}
How does exact rendering work?
- The multiplier converts the preview canvas width into the configured production width.
- The temporary output canvas uses spec.pixelWidth plus spec.pixelHeight as its exact dimensions.
- The cleared canvas keeps the artwork background transparent.
- The resulting Blob contains the production PNG bytes.
- Save src/printPackage.js.
- Return to Google Chrome to confirm the garment editor remains loaded.
Your running frontend reloads without a compilation error. The exact-size renderer is now ready for the package builder.
Did the frontend stop compiling?
- Check that renderExactPng() sits after the closing brace of preflightDesign().
- Check that every parenthesis in context.drawImage() has a matching closing parenthesis.
Still stuck? Help me debug the renderExactPng function in my Fabric.js production exporter.
Build the editable production contract
A production PNG preserves the final pixels. The package also needs the editable canvas state plus a manifest that explains how those pixels map to a garment and physical print area.
- Start buildPrintPackage() below renderExactPng() with this validation and serialization logic:
export async function buildPrintPackage(canvas, spec, assets) {
const errors = preflightDesign(canvas, spec)
if (errors.length > 0) {
throw new Error(errors.join('\n'))
}
const artworkBlob = await renderExactPng(canvas, spec)
const designData = {
previewCanvas: {
width: canvas.getWidth(),
height: canvas.getHeight(),
},
fabric: canvas.toObject([
'assetName',
'sourcePixelWidth',
'sourcePixelHeight',
]),
}
}
What does the package builder preserve?
- The preflight gate stops package creation while any known production error remains.
- The artworkBlob holds the exact-size transparent PNG.
- The previewCanvas values record the editing coordinate system.
- The serialized Fabric.js data retains each uploaded image name plus its source pixel dimensions.
- Save src/printPackage.js.
- Check the running Vite terminal for a successful frontend update.
The frontend compiles with the package builder in place. Its return value is added after the production files are assembled.
- Inside buildPrintPackage() add the initial manifest below designData by pasting this code:
const manifest = {
schemaVersion: '1.0',
designId: spec.designId,
productId: spec.productId,
garment: {
style: spec.garmentStyle,
color: spec.garmentColor,
},
printArea: {
side: spec.side,
widthInches: spec.widthInches,
heightInches: spec.heightInches,
intendedDpi: spec.dpi,
pixelWidth: spec.pixelWidth,
pixelHeight: spec.pixelHeight,
},
}
Why does the manifest carry dimensions?
The manifest links the output pixels to the intended garment and physical print area. It gives a backend or RIP the values needed to reproduce the requested size.
- Add the remaining metadata properties after the printArea property:
artwork: {
filename: 'artwork.png',
mimeType: 'image/png',
browserEncodedDpiMetadata: 96,
},
editableDesign: {
filename: 'design.json',
format: 'Fabric.js JSON',
},
assets: assets.map((file) => ({
filename: file.name,
mimeType: file.type,
bytes: file.size,
})),
createdAt: new Date().toISOString(),
What completes the production record?
- The artwork metadata names the generated file plus its MIME type.
- The browser metadata disclosure records the PNG encoder's 96 DPI value.
- The editable-design metadata identifies the serialized Fabric.js document.
- The asset list records the filename plus MIME type plus byte size for every original upload.
- Finish buildPrintPackage() below the manifest by adding the generated files and multipart fields:
const designBlob = new Blob([JSON.stringify(designData, null, 2)], {
type: 'application/json',
})
const manifestBlob = new Blob([JSON.stringify(manifest, null, 2)], {
type: 'application/json',
})
const formData = new FormData()
formData.append('artwork', artworkBlob, 'artwork.png')
formData.append('design', designBlob, 'design.json')
formData.append('manifest', manifestBlob, 'manifest.json')
for (const file of assets) {
formData.append('assets', file, file.name)
}
return { artworkBlob, designBlob, manifestBlob, formData, manifest }
}
How is the package assembled?
- The editable design becomes design.json.
- The production record becomes manifest.json.
- The FormData object uses the stable field names artwork plus design plus manifest.
- Every original upload uses a repeated assets entry.
- Save src/printPackage.js.
- Return to Chrome to confirm the garment editor reloads successfully.
The package module now produces all four artifact categories from one validated canvas. The frontend still needs a download action to expose the result.
Seeing a manifest syntax error?
- Place the artwork metadata before the final closing brace of manifest.
- Keep the comma after every manifest property that has another property below it.
- Place the Blob creation code after the complete manifest object.
Need another pair of eyes? Help me find the syntax problem in my buildPrintPackage function.
Wire the production download and verify it
The interface can now request one validated package. A successful request downloads the production PNG and exposes the same manifest that defines its intended physical size.
- Switch back to src/App.jsx.
- Add buildPrintPackage to the existing import from ./printPackage.js.
- Find the status state declaration.
- Add the manifest state directly below it so both lines look like this:
const [status, setStatus] = useState('Enter your printer settings to begin.')
const [lastManifest, setLastManifest] = useState(null)
Why store the latest manifest?
The latest manifest gives the learner a visible production record after a successful export. It also keeps the displayed metadata tied to the exact package that was generated.
- Add downloadProduction() below downloadPreview() by pasting this function:
async function downloadProduction() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
try {
const printPackage = await buildPrintPackage(
canvas,
activeSpec,
assetsRef.current,
)
downloadBlob(
printPackage.artworkBlob,
`${activeSpec.designId}-artwork.png`,
)
setLastManifest(printPackage.manifest)
setMessages([])
setStatus(
`Production PNG created at ${activeSpec.pixelWidth} × ${activeSpec.pixelHeight} pixels.`,
)
} catch (error) {
setMessages(error.message.split('\n'))
setStatus('Production export blocked by preflight.')
}
}
What does the download action do?
- The action passes the active canvas plus specification plus original files into buildPrintPackage().
- A failed preflight sends each issue back to the existing error list.
- A successful build downloads <designId>-artwork.png.
- The success status reports the exact configured production dimensions.
- Add the production download button below the existing preview download button:
<button disabled={controlsDisabled} onClick={downloadProduction}>Download Production PNG</button>
When is the button available?
The existing controlsDisabled condition keeps production actions unavailable until the canvas and active specification are ready.
- Add the browser metadata disclosure inside status-card below the active target summary:
<p className="warning">
Canvas PNG metadata is 96 DPI. Your backend or RIP must apply the physical dimensions and intended DPI from the manifest.
</p>
Which values define the production size?
The browser PNG contains 96 DPI metadata. The binding production contract uses exact pixel dimensions plus the manifest's physical width plus physical height plus intended DPI.
- Add the manifest preview below status-card by pasting this block:
{lastManifest && (
<details className="manifest">
<summary>Last generated manifest</summary>
<pre>{JSON.stringify(lastManifest, null, 2)}</pre>
</details>
)}
Why show the manifest?
The preview lets you compare the visible production summary with the machine-readable contract. Matching values prove that the downloaded artwork and metadata came from the same active specification.
- Save src/App.jsx.
- Return to the running garment editor in Chrome.
- Click Check Preflight.
- Correct each displayed boundary or effective-DPI error on the canvas.
- Click Check Preflight again.
You should see Preflight passed. once every object fits the print area and every uploaded raster meets the preset.
Before you export, do you expect the production status to report the preview dimensions or the larger target dimensions?
- Click Download Production PNG.
The status reports the configured target width plus height. Your browser downloads <designId>-artwork.png.
- Expand Last generated manifest below the status card.
- Confirm pixelWidth matches the width in the success status.
- Confirm pixelHeight matches the height in the success status.
- Confirm intendedDpi matches the active printer preset.
- Open the downloaded artwork from your Downloads folder in an image viewer.
You should see only the artwork on transparency. The garment mockup stays in the browser because it lives outside the Fabric.js canvas.
Was the export blocked?
- Move every object fully inside the dashed print boundary.
- Reduce the placed size of any uploaded raster that falls below the preset's effective DPI.
- Add an artwork element if the canvas is empty.
Need help interpreting the result? Help me resolve the production preflight errors in my garment artwork exporter.
✔️ Awesome, I've got everything!
Great work. Your validated artwork now downloads at the exact target dimensions with its editable design and production manifest ready for handoff.
ⓧ I'd like to double check the full code
Compare these completed files with yours. Keep the local receiver and Vite frontend running while you check them.
import { FabricImage } from 'fabric'
export function preflightDesign(canvas, spec) {
const errors = []
const objects = canvas.getObjects()
if (objects.length === 0) {
errors.push('Add at least one artwork element before exporting.')
}
for (const [index, object] of objects.entries()) {
const bounds = object.getBoundingRect()
const outside =
bounds.left < 0 ||
bounds.top < 0 ||
bounds.left + bounds.width > canvas.getWidth() ||
bounds.top + bounds.height > canvas.getHeight()
if (outside) {
errors.push(`Element ${index + 1} extends outside the printable area.`)
}
if (object instanceof FabricImage) {
const physicalWidth =
(object.getScaledWidth() / canvas.getWidth()) * spec.widthInches
const physicalHeight =
(object.getScaledHeight() / canvas.getHeight()) * spec.heightInches
const effectiveDpi = Math.floor(
Math.min(
object.sourcePixelWidth / physicalWidth,
object.sourcePixelHeight / physicalHeight,
),
)
if (!Number.isFinite(effectiveDpi) || effectiveDpi < spec.dpi) {
errors.push(
`${object.assetName ?? `Image ${index + 1}`} is approximately ${effectiveDpi || 0} DPI at its placed size; ${spec.dpi} DPI is required by this preset.`,
)
}
}
}
return errors
}
function renderExactPng(canvas, spec) {
const multiplier = spec.pixelWidth / canvas.getWidth()
const renderedCanvas = canvas.toCanvasElement(multiplier)
const outputCanvas = document.createElement('canvas')
outputCanvas.width = spec.pixelWidth
outputCanvas.height = spec.pixelHeight
const context = outputCanvas.getContext('2d')
if (!context) {
throw new Error('The browser could not create the production canvas.')
}
context.clearRect(0, 0, outputCanvas.width, outputCanvas.height)
context.drawImage(
renderedCanvas,
0,
0,
outputCanvas.width,
outputCanvas.height,
)
return new Promise((resolve, reject) => {
outputCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('The browser could not encode artwork.png.'))
}
}, 'image/png')
})
}
export async function buildPrintPackage(canvas, spec, assets) {
const errors = preflightDesign(canvas, spec)
if (errors.length > 0) {
throw new Error(errors.join('\n'))
}
const artworkBlob = await renderExactPng(canvas, spec)
const designData = {
previewCanvas: {
width: canvas.getWidth(),
height: canvas.getHeight(),
},
fabric: canvas.toObject([
'assetName',
'sourcePixelWidth',
'sourcePixelHeight',
]),
}
const manifest = {
schemaVersion: '1.0',
designId: spec.designId,
productId: spec.productId,
garment: {
style: spec.garmentStyle,
color: spec.garmentColor,
},
printArea: {
side: spec.side,
widthInches: spec.widthInches,
heightInches: spec.heightInches,
intendedDpi: spec.dpi,
pixelWidth: spec.pixelWidth,
pixelHeight: spec.pixelHeight,
},
artwork: {
filename: 'artwork.png',
mimeType: 'image/png',
browserEncodedDpiMetadata: 96,
},
editableDesign: {
filename: 'design.json',
format: 'Fabric.js JSON',
},
assets: assets.map((file) => ({
filename: file.name,
mimeType: file.type,
bytes: file.size,
})),
createdAt: new Date().toISOString(),
}
const designBlob = new Blob([JSON.stringify(designData, null, 2)], {
type: 'application/json',
})
const manifestBlob = new Blob([JSON.stringify(manifest, null, 2)], {
type: 'application/json',
})
const formData = new FormData()
formData.append('artwork', artworkBlob, 'artwork.png')
formData.append('design', designBlob, 'design.json')
formData.append('manifest', manifestBlob, 'manifest.json')
for (const file of assets) {
formData.append('assets', file, file.name)
}
return { artworkBlob, designBlob, manifestBlob, formData, manifest }
}
import { useEffect, useRef, useState } from 'react'
import { Canvas, FabricImage, Textbox } from 'fabric'
import {
buildPrintPackage,
preflightDesign,
} from './printPackage.js'
const initialSpec = {
designId: 'design-001',
productId: 'garment-001',
garmentStyle: 'tshirt',
garmentColor: '#111827',
side: 'front',
widthInches: '',
heightInches: '',
dpi: '',
}
function fitPreview(width, height) {
const scale = Math.min(360 / width, 480 / height)
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('The selected image could not be read.'))
}
})
reader.addEventListener('error', () => {
reject(reader.error ?? new Error('The selected image could not be read.'))
})
reader.readAsDataURL(file)
})
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export default function App() {
const canvasElementRef = useRef(null)
const canvasRef = useRef(null)
const assetsRef = useRef([])
const [draftSpec, setDraftSpec] = useState(initialSpec)
const [activeSpec, setActiveSpec] = useState(null)
const [canvasSize, setCanvasSize] = useState({ width: 300, height: 400 })
const [canvasReady, setCanvasReady] = useState(false)
const [messages, setMessages] = useState([])
const [status, setStatus] = useState('Enter your printer settings to begin.')
const [lastManifest, setLastManifest] = useState(null)
useEffect(() => {
const canvas = new Canvas(canvasElementRef.current, {
width: 300,
height: 400,
preserveObjectStacking: true,
})
canvasRef.current = canvas
setCanvasReady(true)
return () => {
canvasRef.current = null
canvas.dispose()
}
}, [])
function updateSpec(event) {
const { name, value } = event.target
setDraftSpec((current) => ({ ...current, [name]: value }))
}
function applyPrintSpec() {
const canvas = canvasRef.current
const widthInches = Number(draftSpec.widthInches)
const heightInches = Number(draftSpec.heightInches)
const dpi = Number(draftSpec.dpi)
if (
!canvas ||
!draftSpec.designId.trim() ||
!draftSpec.productId.trim() ||
!Number.isFinite(widthInches) ||
!Number.isFinite(heightInches) ||
!Number.isFinite(dpi) ||
widthInches <= 0 ||
heightInches <= 0 ||
dpi <= 0
) {
setMessages(['Enter IDs plus positive width, height, and DPI values.'])
return
}
const pixelWidth = Math.round(widthInches * dpi)
const pixelHeight = Math.round(heightInches * dpi)
if (pixelWidth < 1 || pixelHeight < 1) {
setMessages(['The production pixel dimensions must be at least 1 by 1.'])
return
}
const preview = fitPreview(pixelWidth, pixelHeight)
const applied = {
...draftSpec,
widthInches,
heightInches,
dpi,
pixelWidth,
pixelHeight,
}
canvas.clear()
canvas.setDimensions(preview)
const starterText = new Textbox('YOUR DESIGN', {
left: preview.width / 2,
top: preview.height / 2,
originX: 'center',
originY: 'center',
width: preview.width * 0.72,
fill: '#ffffff',
fontFamily: 'sans-serif',
fontSize: Math.max(22, preview.width * 0.09),
fontWeight: 700,
textAlign: 'center',
})
canvas.add(starterText)
canvas.setActiveObject(starterText)
canvas.requestRenderAll()
assetsRef.current = []
setActiveSpec(applied)
setCanvasSize(preview)
setMessages([])
setLastManifest(null)
setStatus(
`Print spec applied: ${pixelWidth} × ${pixelHeight} target pixels.`,
)
}
function addText() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
const text = new Textbox('EDIT ME', {
left: canvas.getWidth() / 2,
top: canvas.getHeight() / 2,
originX: 'center',
originY: 'center',
width: canvas.getWidth() * 0.6,
fill: '#ffffff',
fontFamily: 'sans-serif',
fontSize: Math.max(20, canvas.getWidth() * 0.08),
fontWeight: 700,
textAlign: 'center',
})
canvas.add(text)
canvas.setActiveObject(text)
canvas.requestRenderAll()
setStatus('Editable text added. Double-click it to change the wording.')
}
async function addImage(event) {
const canvas = canvasRef.current
const file = event.target.files?.[0]
event.target.value = ''
if (!canvas || !activeSpec || !file) return
try {
const dataUrl = await readFileAsDataUrl(file)
const image = await FabricImage.fromURL(dataUrl)
image.set({
assetName: file.name,
sourcePixelWidth: image.width,
sourcePixelHeight: image.height,
})
const maxWidth = canvas.getWidth() * 0.68
const maxHeight = canvas.getHeight() * 0.68
if (image.getScaledWidth() > maxWidth) image.scaleToWidth(maxWidth)
if (image.getScaledHeight() > maxHeight) image.scaleToHeight(maxHeight)
canvas.add(image)
canvas.centerObject(image)
image.setCoords()
canvas.setActiveObject(image)
canvas.requestRenderAll()
assetsRef.current = [...assetsRef.current, file]
setMessages([])
setStatus(`${file.name} added with its original source dimensions.`)
} catch (error) {
setMessages([error.message])
}
}
function deleteSelected() {
const canvas = canvasRef.current
if (!canvas) return
const selected = canvas.getActiveObjects()
canvas.remove(...selected)
canvas.discardActiveObject()
canvas.requestRenderAll()
setStatus(`${selected.length} selected element(s) removed.`)
}
function checkPreflight() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
const errors = preflightDesign(canvas, activeSpec)
setMessages(errors)
setStatus(errors.length === 0 ? 'Preflight passed.' : 'Preflight failed.')
}
async function downloadPreview() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
const blob = await canvas.toBlob({ format: 'png' })
if (!blob) {
setMessages(['The browser could not create the preview PNG.'])
return
}
downloadBlob(blob, `${activeSpec.designId}-preview-not-production.png`)
setStatus(
`Preview-only export: ${canvas.getWidth()} × ${canvas.getHeight()} pixels.`,
)
}
async function downloadProduction() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
try {
const printPackage = await buildPrintPackage(
canvas,
activeSpec,
assetsRef.current,
)
downloadBlob(
printPackage.artworkBlob,
`${activeSpec.designId}-artwork.png`,
)
setLastManifest(printPackage.manifest)
setMessages([])
setStatus(
`Production PNG created at ${activeSpec.pixelWidth} × ${activeSpec.pixelHeight} pixels.`,
)
} catch (error) {
setMessages(error.message.split('\n'))
setStatus('Production export blocked by preflight.')
}
}
const controlsDisabled = !canvasReady || !activeSpec
return (
<main className="app-shell">
<header>
<p className="eyebrow">Production artwork workflow</p>
<h1>Garment Print Exporter</h1>
<p>
Design over a garment preview, validate the artwork, and send a
reproducible production package to your backend.
</p>
</header>
<section className="workspace">
<aside className="panel">
<h2>Printer preset</h2>
<div className="form-grid">
<label>
Design ID
<input name="designId" value={draftSpec.designId} onChange={updateSpec} />
</label>
<label>
Product ID
<input name="productId" value={draftSpec.productId} onChange={updateSpec} />
</label>
<label>
Garment style
<select name="garmentStyle" value={draftSpec.garmentStyle} onChange={updateSpec}>
<option value="tshirt">T-shirt</option>
<option value="hoodie">Hoodie</option>
</select>
</label>
<label>
Print side
<select name="side" value={draftSpec.side} onChange={updateSpec}>
<option value="front">Front</option>
<option value="back">Back</option>
</select>
</label>
<label>
Garment color
<input type="color" name="garmentColor" value={draftSpec.garmentColor} onChange={updateSpec} />
</label>
<label>
Width in inches
<input type="number" min="0.1" step="0.1" name="widthInches" value={draftSpec.widthInches} onChange={updateSpec} />
</label>
<label>
Height in inches
<input type="number" min="0.1" step="0.1" name="heightInches" value={draftSpec.heightInches} onChange={updateSpec} />
</label>
<label>
Target DPI
<input type="number" min="1" step="1" name="dpi" value={draftSpec.dpi} onChange={updateSpec} />
</label>
</div>
<button onClick={applyPrintSpec}>Apply Print Spec</button>
<h2>Artwork tools</h2>
<div className="button-stack">
<button disabled={controlsDisabled} onClick={addText}>Add Text</button>
<label className={`file-button ${controlsDisabled ? 'disabled' : ''}`}>
Upload PNG or JPEG
<input disabled={controlsDisabled} type="file" accept="image/png,image/jpeg" onChange={addImage} />
</label>
<button disabled={controlsDisabled} onClick={deleteSelected}>Delete Selected</button>
</div>
<h2>Production</h2>
<div className="button-stack">
<button disabled={controlsDisabled} onClick={checkPreflight}>Check Preflight</button>
<button disabled={controlsDisabled} onClick={downloadPreview}>Download Preview PNG</button>
<button disabled={controlsDisabled} onClick={downloadProduction}>Download Production PNG</button>
</div>
</aside>
<section className="stage-panel">
<div className={`garment ${draftSpec.garmentStyle}`} style={{ '--garment-color': draftSpec.garmentColor }}>
<span className="garment-label">{draftSpec.garmentStyle} · {draftSpec.side}</span>
<div className="print-area" style={{ width: canvasSize.width, height: canvasSize.height }}>
<canvas ref={canvasElementRef} />
</div>
</div>
<div className="status-card">
<strong>{status}</strong>
{activeSpec && (
<p>
Target: {activeSpec.pixelWidth} × {activeSpec.pixelHeight} pixels at an intended {activeSpec.dpi} DPI.
</p>
)}
<p className="warning">
Canvas PNG metadata is 96 DPI. Your backend or RIP must apply the physical dimensions and intended DPI from the manifest.
</p>
{messages.length > 0 && (
<ul className="errors">
{messages.map((message) => <li key={message}>{message}</li>)}
</ul>
)}
</div>
{lastManifest && (
<details className="manifest">
<summary>Last generated manifest</summary>
<pre>{JSON.stringify(lastManifest, null, 2)}</pre>
</details>
)}
</section>
</section>
</main>
)
}
That is the production package assembled. Next, you will send the same multipart contract to the local inspection receiver and prove the application builds successfully.
Send the Package to the Backend
Your production package now contains the artwork file plus the metadata needed to reproduce it. The last gap is proving that another application can receive the complete contract.
The local Node.js inspection receiver checks the package as multipart form data. This step connects the relative endpoint through Vite and surfaces unsuccessful server responses in the interface.
In this step, get ready to:
- Confirm that the existing development proxy connects the relative endpoint to the inspection receiver.
- Submit the validated package and display the receiver result.
- Verify the multipart handoff and compile the production build.
Confirm the development proxy
The browser sends its request to the relative endpoint /api/print-jobs. During local development, the existing proxy forwards that request to the receiver at http://127.0.0.1:8787.
- Switch back to vite.config.js in Visual Studio Code.
- Find the server.proxy configuration.
- Confirm that /api maps to http://127.0.0.1:8787.
Why Keep the Endpoint Relative?
The frontend always submits to /api/print-jobs. Vite handles the local destination without placing a development-only receiver address inside the application code.
A larger application can preserve the same route and multipart field names. Its deployment layer can direct that route to the real backend.
Connect the upload contract
The package builder already returns the completed FormData object. A transport helper now sends that object and checks whether the receiver accepted it.
- Select src/printPackage.js in the Visual Studio Code Explorer sidebar.
- Add the following exported function below buildPrintPackage():
export async function submitPrintPackage(formData) {
const response = await fetch('/api/print-jobs', {
method: 'POST',
body: formData,
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error ?? `Upload failed with status ${response.status}.`)
}
return result
}
What Does This Function Protect?
- The fetch() call sends the existing multipart body to the project-defined endpoint.
- The receiver's JSON response contains the accepted part names and received byte count.
- The response.ok check catches unsuccessful HTTP statuses because fetch() does not reject them automatically.
- The returned result gives the interface concrete evidence that the backend accepted the package.
- Save src/printPackage.js.
- Return to the Chrome tab from earlier to confirm that the garment editor still renders without an error overlay.
The editor remains available because the new export is valid JavaScript. That confirms the transport helper compiled inside the running development app.
✔️ Awesome, I've got everything!
Great. Your package module now builds the production files and submits them through the relative backend endpoint.
ⓧ I'd like to double check the full code
Compare your complete src/printPackage.js file with this reference.
import { FabricImage } from 'fabric'
export function preflightDesign(canvas, spec) {
const errors = []
const objects = canvas.getObjects()
if (objects.length === 0) {
errors.push('Add at least one artwork element before exporting.')
}
for (const [index, object] of objects.entries()) {
const bounds = object.getBoundingRect()
const outside =
bounds.left < 0 ||
bounds.top < 0 ||
bounds.left + bounds.width > canvas.getWidth() ||
bounds.top + bounds.height > canvas.getHeight()
if (outside) {
errors.push(`Element ${index + 1} extends outside the printable area.`)
}
if (object instanceof FabricImage) {
const physicalWidth =
(object.getScaledWidth() / canvas.getWidth()) * spec.widthInches
const physicalHeight =
(object.getScaledHeight() / canvas.getHeight()) * spec.heightInches
const effectiveDpi = Math.floor(
Math.min(
object.sourcePixelWidth / physicalWidth,
object.sourcePixelHeight / physicalHeight,
),
)
if (!Number.isFinite(effectiveDpi) || effectiveDpi < spec.dpi) {
errors.push(
`${object.assetName ?? `Image ${index + 1}`} is approximately ${effectiveDpi || 0} DPI at its placed size; ${spec.dpi} DPI is required by this preset.`,
)
}
}
}
return errors
}
function renderExactPng(canvas, spec) {
const multiplier = spec.pixelWidth / canvas.getWidth()
const renderedCanvas = canvas.toCanvasElement(multiplier)
const outputCanvas = document.createElement('canvas')
outputCanvas.width = spec.pixelWidth
outputCanvas.height = spec.pixelHeight
const context = outputCanvas.getContext('2d')
if (!context) {
throw new Error('The browser could not create the production canvas.')
}
context.clearRect(0, 0, outputCanvas.width, outputCanvas.height)
context.drawImage(
renderedCanvas,
0,
0,
outputCanvas.width,
outputCanvas.height,
)
return new Promise((resolve, reject) => {
outputCanvas.toBlob((blob) => {
if (blob) {
resolve(blob)
} else {
reject(new Error('The browser could not encode artwork.png.'))
}
}, 'image/png')
})
}
export async function buildPrintPackage(canvas, spec, assets) {
const errors = preflightDesign(canvas, spec)
if (errors.length > 0) {
throw new Error(errors.join('\n'))
}
const artworkBlob = await renderExactPng(canvas, spec)
const designData = {
previewCanvas: {
width: canvas.getWidth(),
height: canvas.getHeight(),
},
fabric: canvas.toObject([
'assetName',
'sourcePixelWidth',
'sourcePixelHeight',
]),
}
const manifest = {
schemaVersion: '1.0',
designId: spec.designId,
productId: spec.productId,
garment: {
style: spec.garmentStyle,
color: spec.garmentColor,
},
printArea: {
side: spec.side,
widthInches: spec.widthInches,
heightInches: spec.heightInches,
intendedDpi: spec.dpi,
pixelWidth: spec.pixelWidth,
pixelHeight: spec.pixelHeight,
},
artwork: {
filename: 'artwork.png',
mimeType: 'image/png',
browserEncodedDpiMetadata: 96,
},
editableDesign: {
filename: 'design.json',
format: 'Fabric.js JSON',
},
assets: assets.map((file) => ({
filename: file.name,
mimeType: file.type,
bytes: file.size,
})),
createdAt: new Date().toISOString(),
}
const designBlob = new Blob([JSON.stringify(designData, null, 2)], {
type: 'application/json',
})
const manifestBlob = new Blob([JSON.stringify(manifest, null, 2)], {
type: 'application/json',
})
const formData = new FormData()
formData.append('artwork', artworkBlob, 'artwork.png')
formData.append('design', designBlob, 'design.json')
formData.append('manifest', manifestBlob, 'manifest.json')
for (const file of assets) {
formData.append('assets', file, file.name)
}
return { artworkBlob, designBlob, manifestBlob, formData, manifest }
}
export async function submitPrintPackage(formData) {
const response = await fetch('/api/print-jobs', {
method: 'POST',
body: formData,
})
const result = await response.json()
if (!response.ok) {
throw new Error(result.error ?? `Upload failed with status ${response.status}.`)
}
return result
}
The component now needs to call the transport helper. It also needs to preserve the manifest and display the receiver's result.
- Switch back to src/App.jsx.
- Add submitPrintPackage to the existing import from ./printPackage.js.
- Save src/App.jsx.
- Return to the Chrome tab from earlier to confirm that the editor still renders without an error overlay.
The existing interface remains visible after the import change. The component can now call the new transport helper.
- Switch back to src/App.jsx.
- Add the following function below downloadProduction():
async function sendPackage() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
try {
setStatus('Building and sending the print package...')
const printPackage = await buildPrintPackage(
canvas,
activeSpec,
assetsRef.current,
)
const result = await submitPrintPackage(printPackage.formData)
setLastManifest(printPackage.manifest)
setMessages([])
setStatus(
`Upload accepted: ${result.receivedParts.join(', ')}; ${result.receivedBytes} bytes received.`,
)
} catch (error) {
setMessages(error.message.split('\n'))
setStatus('Package upload failed.')
}
}
What Happens During the Handoff?
- The function rebuilds the package from the current canvas and active specification.
- The function passes only printPackage.formData into the transport helper.
- A successful response stores the generated manifest and clears earlier errors.
- A failed preflight or backend response appears through the existing message panel.
- Save src/App.jsx.
- Return to the Chrome tab from earlier to confirm that the garment editor still renders without an error overlay.
The editor remains interactive after the new function compiles. The final UI change connects that function to the existing upload button.
- Switch back to src/App.jsx.
- Find the button labeled Send Package to Backend.
- Add onClick={sendPackage} to that button.
- Save src/App.jsx.
✔️ Awesome, I've got everything!
Your upload button now builds the current production package and sends it to the inspection receiver.
ⓧ I'd like to double check the full code
Compare your complete src/App.jsx file with this reference.
import { useEffect, useRef, useState } from 'react'
import { Canvas, FabricImage, Textbox } from 'fabric'
import {
buildPrintPackage,
preflightDesign,
submitPrintPackage,
} from './printPackage.js'
const initialSpec = {
designId: 'design-001',
productId: 'garment-001',
garmentStyle: 'tshirt',
garmentColor: '#111827',
side: 'front',
widthInches: '',
heightInches: '',
dpi: '',
}
function fitPreview(width, height) {
const scale = Math.min(360 / width, 480 / height)
return {
width: Math.max(1, Math.round(width * scale)),
height: Math.max(1, Math.round(height * scale)),
}
}
function readFileAsDataUrl(file) {
return new Promise((resolve, reject) => {
const reader = new FileReader()
reader.addEventListener('load', () => {
if (typeof reader.result === 'string') {
resolve(reader.result)
} else {
reject(new Error('The selected image could not be read.'))
}
})
reader.addEventListener('error', () => {
reject(reader.error ?? new Error('The selected image could not be read.'))
})
reader.readAsDataURL(file)
})
}
function downloadBlob(blob, filename) {
const url = URL.createObjectURL(blob)
const link = document.createElement('a')
link.href = url
link.download = filename
link.click()
setTimeout(() => URL.revokeObjectURL(url), 0)
}
export default function App() {
const canvasElementRef = useRef(null)
const canvasRef = useRef(null)
const assetsRef = useRef([])
const [draftSpec, setDraftSpec] = useState(initialSpec)
const [activeSpec, setActiveSpec] = useState(null)
const [canvasSize, setCanvasSize] = useState({ width: 300, height: 400 })
const [canvasReady, setCanvasReady] = useState(false)
const [messages, setMessages] = useState([])
const [status, setStatus] = useState('Enter your printer settings to begin.')
const [lastManifest, setLastManifest] = useState(null)
useEffect(() => {
const canvas = new Canvas(canvasElementRef.current, {
width: 300,
height: 400,
preserveObjectStacking: true,
})
canvasRef.current = canvas
setCanvasReady(true)
return () => {
canvasRef.current = null
canvas.dispose()
}
}, [])
function updateSpec(event) {
const { name, value } = event.target
setDraftSpec((current) => ({ ...current, [name]: value }))
}
function applyPrintSpec() {
const canvas = canvasRef.current
const widthInches = Number(draftSpec.widthInches)
const heightInches = Number(draftSpec.heightInches)
const dpi = Number(draftSpec.dpi)
if (
!canvas ||
!draftSpec.designId.trim() ||
!draftSpec.productId.trim() ||
!Number.isFinite(widthInches) ||
!Number.isFinite(heightInches) ||
!Number.isFinite(dpi) ||
widthInches <= 0 ||
heightInches <= 0 ||
dpi <= 0
) {
setMessages(['Enter IDs plus positive width, height, and DPI values.'])
return
}
const pixelWidth = Math.round(widthInches * dpi)
const pixelHeight = Math.round(heightInches * dpi)
if (pixelWidth < 1 || pixelHeight < 1) {
setMessages(['The production pixel dimensions must be at least 1 by 1.'])
return
}
const preview = fitPreview(pixelWidth, pixelHeight)
const applied = {
...draftSpec,
widthInches,
heightInches,
dpi,
pixelWidth,
pixelHeight,
}
canvas.clear()
canvas.setDimensions(preview)
const starterText = new Textbox('YOUR DESIGN', {
left: preview.width / 2,
top: preview.height / 2,
originX: 'center',
originY: 'center',
width: preview.width * 0.72,
fill: '#ffffff',
fontFamily: 'sans-serif',
fontSize: Math.max(22, preview.width * 0.09),
fontWeight: 700,
textAlign: 'center',
})
canvas.add(starterText)
canvas.setActiveObject(starterText)
canvas.requestRenderAll()
assetsRef.current = []
setActiveSpec(applied)
setCanvasSize(preview)
setMessages([])
setLastManifest(null)
setStatus(
`Print spec applied: ${pixelWidth} × ${pixelHeight} target pixels.`,
)
}
function addText() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
const text = new Textbox('EDIT ME', {
left: canvas.getWidth() / 2,
top: canvas.getHeight() / 2,
originX: 'center',
originY: 'center',
width: canvas.getWidth() * 0.6,
fill: '#ffffff',
fontFamily: 'sans-serif',
fontSize: Math.max(20, canvas.getWidth() * 0.08),
fontWeight: 700,
textAlign: 'center',
})
canvas.add(text)
canvas.setActiveObject(text)
canvas.requestRenderAll()
setStatus('Editable text added. Double-click it to change the wording.')
}
async function addImage(event) {
const canvas = canvasRef.current
const file = event.target.files?.[0]
event.target.value = ''
if (!canvas || !activeSpec || !file) return
try {
const dataUrl = await readFileAsDataUrl(file)
const image = await FabricImage.fromURL(dataUrl)
image.set({
assetName: file.name,
sourcePixelWidth: image.width,
sourcePixelHeight: image.height,
})
const maxWidth = canvas.getWidth() * 0.68
const maxHeight = canvas.getHeight() * 0.68
if (image.getScaledWidth() > maxWidth) image.scaleToWidth(maxWidth)
if (image.getScaledHeight() > maxHeight) image.scaleToHeight(maxHeight)
canvas.add(image)
canvas.centerObject(image)
image.setCoords()
canvas.setActiveObject(image)
canvas.requestRenderAll()
assetsRef.current = [...assetsRef.current, file]
setMessages([])
setStatus(`${file.name} added with its original source dimensions.`)
} catch (error) {
setMessages([error.message])
}
}
function deleteSelected() {
const canvas = canvasRef.current
if (!canvas) return
const selected = canvas.getActiveObjects()
canvas.remove(...selected)
canvas.discardActiveObject()
canvas.requestRenderAll()
setStatus(`${selected.length} selected element(s) removed.`)
}
function checkPreflight() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
const errors = preflightDesign(canvas, activeSpec)
setMessages(errors)
setStatus(errors.length === 0 ? 'Preflight passed.' : 'Preflight failed.')
}
async function downloadPreview() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
const blob = await canvas.toBlob({ format: 'png' })
if (!blob) {
setMessages(['The browser could not create the preview PNG.'])
return
}
downloadBlob(blob, `${activeSpec.designId}-preview-not-production.png`)
setStatus(
`Preview-only export: ${canvas.getWidth()} × ${canvas.getHeight()} pixels.`,
)
}
async function downloadProduction() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
try {
const printPackage = await buildPrintPackage(
canvas,
activeSpec,
assetsRef.current,
)
downloadBlob(
printPackage.artworkBlob,
`${activeSpec.designId}-artwork.png`,
)
setLastManifest(printPackage.manifest)
setMessages([])
setStatus(
`Production PNG created at ${activeSpec.pixelWidth} × ${activeSpec.pixelHeight} pixels.`,
)
} catch (error) {
setMessages(error.message.split('\n'))
setStatus('Production export blocked by preflight.')
}
}
async function sendPackage() {
const canvas = canvasRef.current
if (!canvas || !activeSpec) return
try {
setStatus('Building and sending the print package...')
const printPackage = await buildPrintPackage(
canvas,
activeSpec,
assetsRef.current,
)
const result = await submitPrintPackage(printPackage.formData)
setLastManifest(printPackage.manifest)
setMessages([])
setStatus(
`Upload accepted: ${result.receivedParts.join(', ')}; ${result.receivedBytes} bytes received.`,
)
} catch (error) {
setMessages(error.message.split('\n'))
setStatus('Package upload failed.')
}
}
const controlsDisabled = !canvasReady || !activeSpec
return (
<main className="app-shell">
<header>
<p className="eyebrow">Production artwork workflow</p>
<h1>Garment Print Exporter</h1>
<p>
Design over a garment preview, validate the artwork, and send a
reproducible production package to your backend.
</p>
</header>
<section className="workspace">
<aside className="panel">
<h2>Printer preset</h2>
<div className="form-grid">
<label>
Design ID
<input name="designId" value={draftSpec.designId} onChange={updateSpec} />
</label>
<label>
Product ID
<input name="productId" value={draftSpec.productId} onChange={updateSpec} />
</label>
<label>
Garment style
<select name="garmentStyle" value={draftSpec.garmentStyle} onChange={updateSpec}>
<option value="tshirt">T-shirt</option>
<option value="hoodie">Hoodie</option>
</select>
</label>
<label>
Print side
<select name="side" value={draftSpec.side} onChange={updateSpec}>
<option value="front">Front</option>
<option value="back">Back</option>
</select>
</label>
<label>
Garment color
<input type="color" name="garmentColor" value={draftSpec.garmentColor} onChange={updateSpec} />
</label>
<label>
Width in inches
<input type="number" min="0.1" step="0.1" name="widthInches" value={draftSpec.widthInches} onChange={updateSpec} />
</label>
<label>
Height in inches
<input type="number" min="0.1" step="0.1" name="heightInches" value={draftSpec.heightInches} onChange={updateSpec} />
</label>
<label>
Target DPI
<input type="number" min="1" step="1" name="dpi" value={draftSpec.dpi} onChange={updateSpec} />
</label>
</div>
<button onClick={applyPrintSpec}>Apply Print Spec</button>
<h2>Artwork tools</h2>
<div className="button-stack">
<button disabled={controlsDisabled} onClick={addText}>Add Text</button>
<label className={`file-button ${controlsDisabled ? 'disabled' : ''}`}>
Upload PNG or JPEG
<input disabled={controlsDisabled} type="file" accept="image/png,image/jpeg" onChange={addImage} />
</label>
<button disabled={controlsDisabled} onClick={deleteSelected}>Delete Selected</button>
</div>
<h2>Production</h2>
<div className="button-stack">
<button disabled={controlsDisabled} onClick={checkPreflight}>Check Preflight</button>
<button disabled={controlsDisabled} onClick={downloadPreview}>Download Preview PNG</button>
<button disabled={controlsDisabled} onClick={downloadProduction}>Download Production PNG</button>
<button className="primary" disabled={controlsDisabled} onClick={sendPackage}>Send Package to Backend</button>
</div>
</aside>
<section className="stage-panel">
<div className={`garment ${draftSpec.garmentStyle}`} style={{ '--garment-color': draftSpec.garmentColor }}>
<span className="garment-label">{draftSpec.garmentStyle} · {draftSpec.side}</span>
<div className="print-area" style={{ width: canvasSize.width, height: canvasSize.height }}>
<canvas ref={canvasElementRef} />
</div>
</div>
<div className="status-card">
<strong>{status}</strong>
{activeSpec && (
<p>
Target: {activeSpec.pixelWidth} × {activeSpec.pixelHeight} pixels at an intended {activeSpec.dpi} DPI.
</p>
)}
<p className="warning">
Canvas PNG metadata is 96 DPI. Your backend or RIP must apply the physical dimensions and intended DPI from the manifest.
</p>
{messages.length > 0 && (
<ul className="errors">
{messages.map((message) => <li key={message}>{message}</li>)}
</ul>
)}
</div>
{lastManifest && (
<details className="manifest">
<summary>Last generated manifest</summary>
<pre>{JSON.stringify(lastManifest, null, 2)}</pre>
</details>
)}
</section>
</section>
</main>
)
}
Verify the receiver and production build
A successful browser message proves that the frontend received an accepted response. The receiver terminal provides a second view of the same request.
Before you click, do you expect the receiver to accept every required part?
- Return to the Chrome tab from earlier.
- Click Check Preflight.
You should see the preflight pass. This confirms that the current canvas can enter the production handoff.
- Click Send Package to Backend.
The UI lists the accepted artwork, design, and manifest parts. It also shows the number of bytes received.
- Switch back to the receiver terminal from earlier.
- Confirm that the latest Received print package: result reports ok as true.
- Confirm that receivedParts contains artwork, design, and manifest.
- Confirm that receivedBytes is greater than zero.
- Confirm that hasOriginalAssets is true when the design includes uploaded artwork.
That handoff worked. Your frontend has now delivered the generated files and original artwork through the same multipart boundary a larger backend can implement.
The live development app has proven the handoff. A production build now checks the complete source for unresolved imports and compilation errors.
Before you build, do you expect any unresolved import or JSX errors to remain?
- Create a third integrated terminal in the garment-print-exporter Visual Studio Code workspace.
- Compile the production application by running:
npm run build
What Does This Build Prove?
The build command asks Vite to compile the pinned React application for production. It catches missing imports and invalid JSX across the final frontend.
The upload already proves the runtime contract. The successful build proves the completed source compiles as one application.
The build completes without an error and returns control to the terminal. Your receiver and development frontend remain available in their original terminals.
Build or Upload Failing?
- Check that submitPrintPackage is exported from src/printPackage.js and imported by src/App.jsx.
- Check that the button handler uses the exact sendPackage function name.
- Switch back to the receiver terminal and confirm that the local process is still listening.
Still stuck? Help me debug the multipart upload or production build.
You have completed the frontend boundary. The relative endpoint and the artwork, design, manifest, and assets field names are ready to map into a larger application's backend.
Secret mission
Qualify a Real Printer Preset
Replace the demonstration values with a documented printer or RIP preset. Force the design through both preflight failure paths. Then prove the corrected artwork opens at the intended physical size with transparent placement in your RIP.
Clean Up Your Resources
Clean Up Your Resources
Everything in this workflow runs on your local machine, so there are no cloud resources or ongoing service costs. Physical test prints can still consume garment stock, film, ink, powder, pretreatment, or printer time.
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
Resources you used:
- Your garment-print-exporter folder containing the source code, package metadata, and installed dependencies.
- The local Node.js inspection receiver running at http://127.0.0.1:8787.
- The local Vite frontend running at http://localhost:5173.
- Any saved copies of artwork.png, design.json, manifest.json, preview exports, and production exports created during qualification.
The inspection receiver holds multipart form data only while processing each request. It does not persist uploaded package parts in a backend data store.
Keep everything running
No action needed. Choose this if you are continuing to test the exporter against your printer or RIP workflow.
- Leave the inspection receiver running while you continue sending packages.
- Leave the frontend running while you continue adjusting artwork.
- Keep the garment-print-exporter folder with its local dependencies.
- Retain your real-printer qualification record with your printer or RIP notes.
- Keep the generated artwork and manifest copies that support your production checks.
Pause - I'll come back to this later
Shut down both local processes to free their terminals. Your project files, printer preset record, and generated qualification artifacts remain in place.
- Switch back to the inspection receiver terminal from earlier.
- Press Ctrl+C to stop the receiver.
- Switch back to the frontend terminal from earlier.
- Press Ctrl+C to stop the frontend.
- Restart the inspection receiver in its terminal later by running:
npm run backend
What Restarts Here?
The backend script starts the local inspection receiver at http://127.0.0.1:8787.
- Restart the frontend in its terminal by running:
npm run dev
What Restarts Here?
The development script starts the frontend at http://localhost:5173. Its existing proxy reconnects API requests to the inspection receiver.
Delete - I don't want to use this again
Deletion is permanent. The commands below target only the local project folder you identify.
- Switch back to the inspection receiver terminal from earlier.
- Press Ctrl+C to stop the receiver.
- Switch back to the frontend terminal from earlier.
- Press Ctrl+C to stop the frontend.
- Copy the full path of your existing garment-print-exporter folder from your file manager: path to your garment-print-exporter folder.
macOS
- Delete the complete garment-print-exporter folder by running:
rm -rf "[[PROJECT_FOLDER_PATH="path to your garment-print-exporter folder"]]"
What Gets Removed?
This removes the selected project folder, including its source files, package metadata, and locally installed dependencies.
- Return to the folder where you saved the qualification exports.
- Delete any saved package copies named artwork.png, design.json, or manifest.json.
- Delete each test image whose filename ends in -artwork.png or -preview-not-production.png.
- Confirm the project folder and test exports no longer appear in their previous locations.
Cleanup complete. Your local exporter code and downloaded test artwork are now removed.
Windows
- Delete the complete garment-print-exporter folder by running this command in PowerShell:
Remove-Item -Recurse -Force "[[PROJECT_FOLDER_PATH="path to your garment-print-exporter folder"]]"
What Gets Removed?
This removes the selected project folder, including its source files, package metadata, and locally installed dependencies.
- Return to the folder where you saved the qualification exports.
- Delete any saved package copies named artwork.png, design.json, or manifest.json.
- Delete each test image whose filename ends in -artwork.png or -preview-not-production.png.
- Confirm the project folder and test exports no longer appear in their previous locations.
Cleanup complete. Your local exporter code and downloaded test artwork are now removed.
These local cleanup steps leave any preset or job saved inside your printer or RIP untouched.
Nice Work!
Nice Work!
You did it! Your garment customizer now turns real printer specifications into a validated production artwork package that reaches a backend-compatible receiver.
You've learned how to:
- Built an interactive garment customizer with editable text and uploaded artwork on a transparent Fabric.js canvas. Converted physical print dimensions into a proportional preview with exact target pixel dimensions.
- Created a production preflight gate that detects empty designs. It also catches out-of-bounds objects and raster artwork below the target effective DPI.
- Generated an exact-size transparent artwork.png with design.json, manifest.json, and original assets. Sent the complete multipart package to /api/print-jobs. Confirmed the receiver accepted the production contract and verified that the pinned app compiles successfully.
- Secret Mission: Qualified a real printer preset against deliberate preflight failures. Confirmed the final artwork's transparency, placement, and manifest-controlled physical dimensions in the RIP.
Ready to quiz yourself?