Skip to main content

Engraving Tool

Preact-based custom engraving interface with 15 fonts, real-time canvas preview, and GSM Outdoors API integration

Overview

The SOG Knives custom engraving tool allows customers to personalize their knife purchases with custom text engraving. Built with Preact for lightweight performance, the tool provides real-time canvas preview, supports 15 custom fonts, and integrates with the GSM Outdoors API for order processing.

Framework: Preact (React alternative, 3KB)
Rendering: HTML5 Canvas API
Fonts: 15 custom fonts including Stratum2, Blade Runner, Terminator
API: GSM Outdoors Engraving API
Validation: Client-side + server-side

Architecture

Component Structure

Engraving Tool (Preact App)
├── <EngravingApp /> # Root component
│ ├── <EngravingForm /> # Main form container
│ │ ├── <FontSelector /> # Font dropdown (15 options)
│ │ ├── <TextInput /> # Text entry with validation
│ │ ├── <PositionControl /> # Alignment options
│ │ ├── <LineSpacingSlider /> # Line spacing adjustment
│ │ └── <SubmitButton /> # Form submission
│ ├── <CanvasPreview /> # Real-time rendering
│ ├── <ErrorDisplay /> # Validation errors
│ └── <LoadingSpinner /> # API call indicator
└── <EngravingAPI /> # API client service

File Structure

assets/js/engraving/
├── app.jsx # Entry point, Preact app initialization
├── components/
│ ├── EngravingApp.jsx # Root component
│ ├── EngravingForm.jsx # Form container
│ ├── FontSelector.jsx # Font dropdown
│ ├── TextInput.jsx # Text input with validation
│ ├── PositionControl.jsx # Text alignment
│ ├── LineSpacingSlider.jsx # Spacing control
│ ├── CanvasPreview.jsx # Canvas rendering
│ ├── ErrorDisplay.jsx # Error messages
│ └── LoadingSpinner.jsx # Loading indicator
├── services/
│ ├── EngravingAPI.js # GSM API client
│ └── ValidationService.js # Text validation
├── utils/
│ ├── canvasRenderer.js # Canvas drawing logic
│ ├── fontLoader.js # Font loading/caching
│ └── textMeasurement.js # Text dimension calculations
└── config/
├── fonts.js # Font definitions
└── constants.js # Configuration constants

Implementation

Initialization

The engraving tool initializes when the product page detects an engravable product:

// assets/js/theme/product.js
import { render } from 'preact';
import EngravingApp from './engraving/components/EngravingApp';

export function initEngravingTool(container) {
const productId = container.dataset.productId;
const maxChars = parseInt(container.dataset.maxChars, 10) || 20;
const allowedFonts = container.dataset.allowedFonts?.split(',') || null;

render(
<EngravingApp
productId={productId}
maxCharacters={maxChars}
allowedFonts={allowedFonts}
/>,
document.getElementById('engraving-app')
);
}

Root Component

// assets/js/engraving/components/EngravingApp.jsx
import { h } from 'preact';
import { useState, useEffect } from 'preact/hooks';
import EngravingForm from './EngravingForm';
import CanvasPreview from './CanvasPreview';
import ErrorDisplay from './ErrorDisplay';
import LoadingSpinner from './LoadingSpinner';
import { EngravingAPI } from '../services/EngravingAPI';

export default function EngravingApp({ productId, maxCharacters, allowedFonts }) {
const [engravingText, setEngravingText] = useState('');
const [selectedFont, setSelectedFont] = useState('stratum2-regular');
const [textPosition, setTextPosition] = useState('center');
const [lineSpacing, setLineSpacing] = useState(1.2);
const [errors, setErrors] = useState([]);
const [isSubmitting, setIsSubmitting] = useState(false);
const [engravingId, setEngravingId] = useState(null);

const handleSubmit = async () => {
setIsSubmitting(true);
setErrors([]);

try {
const response = await EngravingAPI.submitEngraving({
product_id: productId,
engraving_text: engravingText,
font: selectedFont,
position: textPosition,
line_spacing: lineSpacing
});

setEngravingId(response.engraving_id);

// Attach engraving ID to cart form
document.getElementById('engraving-id-field').value = response.engraving_id;

// Show success message
showSuccessNotification('Engraving preview created!');
} catch (error) {
setErrors([error.message]);
} finally {
setIsSubmitting(false);
}
};

return (
<div class="engraving-tool">
<h3 class="engraving-tool__title">Personalize Your Knife</h3>

<div class="engraving-tool__container">
<div class="engraving-tool__form">
<EngravingForm
text={engravingText}
onTextChange={setEngravingText}
font={selectedFont}
onFontChange={setSelectedFont}
position={textPosition}
onPositionChange={setTextPosition}
lineSpacing={lineSpacing}
onLineSpacingChange={setLineSpacing}
maxCharacters={maxCharacters}
allowedFonts={allowedFonts}
onSubmit={handleSubmit}
isSubmitting={isSubmitting}
/>
</div>

<div class="engraving-tool__preview">
<CanvasPreview
text={engravingText}
font={selectedFont}
position={textPosition}
lineSpacing={lineSpacing}
/>
</div>
</div>

{errors.length > 0 && <ErrorDisplay errors={errors} />}
{isSubmitting && <LoadingSpinner />}
</div>
);
}

Font Configuration

// assets/js/engraving/config/fonts.js
export const ENGRAVING_FONTS = [
{
id: 'stratum2-regular',
name: 'Stratum2 Regular',
family: 'Stratum2',
weight: 400,
style: 'normal',
file: '/assets/fonts/Stratum2-Regular.woff2'
},
{
id: 'stratum2-bold',
name: 'Stratum2 Bold',
family: 'Stratum2',
weight: 700,
style: 'normal',
file: '/assets/fonts/Stratum2-Bold.woff2'
},
{
id: 'blade-runner',
name: 'Blade Runner',
family: 'BladeRunner',
weight: 400,
style: 'normal',
file: '/assets/fonts/BladeRunner.woff2'
},
{
id: 'terminator',
name: 'Terminator',
family: 'Terminator',
weight: 400,
style: 'normal',
file: '/assets/fonts/Terminator.woff2'
},
{
id: 'arial',
name: 'Arial',
family: 'Arial',
weight: 400,
style: 'normal',
system: true
},
{
id: 'times-new-roman',
name: 'Times New Roman',
family: 'Times New Roman',
weight: 400,
style: 'normal',
system: true
},
{
id: 'courier',
name: 'Courier',
family: 'Courier New',
weight: 400,
style: 'normal',
system: true
},
{
id: 'brush-script',
name: 'Brush Script',
family: 'Brush Script MT',
weight: 400,
style: 'italic',
file: '/assets/fonts/BrushScript.woff2'
},
{
id: 'old-english',
name: 'Old English',
family: 'Old English Text MT',
weight: 400,
style: 'normal',
file: '/assets/fonts/OldEnglish.woff2'
},
{
id: 'stencil',
name: 'Stencil',
family: 'Stencil Std',
weight: 700,
style: 'normal',
file: '/assets/fonts/Stencil.woff2'
},
{
id: 'impact',
name: 'Impact',
family: 'Impact',
weight: 400,
style: 'normal',
system: true
},
{
id: 'comic-sans',
name: 'Comic Sans',
family: 'Comic Sans MS',
weight: 400,
style: 'normal',
system: true
},
{
id: 'helvetica-neue',
name: 'Helvetica Neue',
family: 'Helvetica Neue',
weight: 400,
style: 'normal',
system: true
},
{
id: 'georgia',
name: 'Georgia',
family: 'Georgia',
weight: 400,
style: 'normal',
system: true
},
{
id: 'verdana',
name: 'Verdana',
family: 'Verdana',
weight: 400,
style: 'normal',
system: true
}
];

// Filter fonts based on product configuration
export function getAvailableFonts(allowedFonts) {
if (!allowedFonts || allowedFonts.length === 0) {
return ENGRAVING_FONTS;
}
return ENGRAVING_FONTS.filter(font => allowedFonts.includes(font.id));
}

Canvas Rendering

// assets/js/engraving/utils/canvasRenderer.js
export class EngravingRenderer {
constructor(canvas, options = {}) {
this.canvas = canvas;
this.ctx = canvas.getContext('2d');
this.options = {
width: options.width || 400,
height: options.height || 200,
backgroundColor: options.backgroundColor || '#f5f5f5',
textColor: options.textColor || '#000000',
padding: options.padding || 20
};

this.initCanvas();
}

initCanvas() {
this.canvas.width = this.options.width;
this.canvas.height = this.options.height;
}

render(text, font, position, lineSpacing) {
// Clear canvas
this.ctx.fillStyle = this.options.backgroundColor;
this.ctx.fillRect(0, 0, this.canvas.width, this.canvas.height);

if (!text) return;

// Split text into lines
const lines = text.split('\n');

// Set font
this.ctx.font = `${font.weight} 24px ${font.family}`;
this.ctx.fillStyle = this.options.textColor;
this.ctx.textBaseline = 'middle';

// Calculate line height
const lineHeight = 24 * lineSpacing;
const totalHeight = lines.length * lineHeight;
let startY = (this.canvas.height - totalHeight) / 2;

// Render each line
lines.forEach((line, index) => {
const y = startY + (index * lineHeight);
this.renderLine(line, y, position);
});
}

renderLine(text, y, position) {
const metrics = this.ctx.measureText(text);
let x;

switch (position) {
case 'left':
x = this.options.padding;
this.ctx.textAlign = 'left';
break;
case 'right':
x = this.canvas.width - this.options.padding;
this.ctx.textAlign = 'right';
break;
case 'center':
default:
x = this.canvas.width / 2;
this.ctx.textAlign = 'center';
break;
}

this.ctx.fillText(text, x, y);
}

exportImage(format = 'png', quality = 0.92) {
return this.canvas.toDataURL(`image/${format}`, quality);
}
}

GSM Outdoors API Integration

API Service

// assets/js/engraving/services/EngravingAPI.js
export class EngravingAPI {
static BASE_URL = 'https://api.gsmoutdoors.com/v1';
static API_KEY = window.gsmApiKey; // Injected by theme

static async submitEngraving(data) {
const response = await fetch(`${this.BASE_URL}/api/engraving/orders`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.API_KEY}`
},
body: JSON.stringify(data)
});

if (!response.ok) {
const error = await response.json();
throw new Error(error.message || 'Failed to submit engraving');
}

return response.json();
}

static async validateText(text, productId) {
const response = await fetch(`${this.BASE_URL}/api/engraving/validate`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.API_KEY}`
},
body: JSON.stringify({ text, product_id: productId })
});

return response.json();
}

static async getEngravingPreview(engravingId) {
const response = await fetch(
`${this.BASE_URL}/api/engraving/orders/${engravingId}/preview`,
{
headers: {
'Authorization': `Bearer ${this.API_KEY}`
}
}
);

return response.json();
}
}

API Endpoints

Submit Engraving Order

POST /api/engraving/orders
Content-Type: application/json
Authorization: Bearer {api_key}

{
"product_id": "12345",
"engraving_text": "John Smith\nUSMC",
"font": "stratum2-bold",
"position": "center",
"line_spacing": 1.2
}

Response:

{
"engraving_id": "eng_abc123xyz",
"preview_url": "https://cdn.gsmoutdoors.com/previews/eng_abc123xyz.png",
"status": "pending",
"estimated_completion": "2026-04-25T10:00:00Z"
}

Validate Engraving Text

POST /api/engraving/validate
Content-Type: application/json
Authorization: Bearer {api_key}

{
"text": "John Smith",
"product_id": "12345"
}

Response:

{
"valid": true,
"errors": [],
"warnings": [
"Text may be difficult to read at small sizes"
]
}

Validation Rules

Client-Side Validation

// assets/js/engraving/services/ValidationService.js
export class ValidationService {
static validate(text, maxCharacters) {
const errors = [];

// Check if empty
if (!text || text.trim().length === 0) {
errors.push('Engraving text cannot be empty');
return { valid: false, errors };
}

// Check character limit
if (text.length > maxCharacters) {
errors.push(`Text exceeds maximum length of ${maxCharacters} characters`);
}

// Check for invalid characters
const invalidChars = /[<>{}[\]\\|]/g;
if (invalidChars.test(text)) {
errors.push('Text contains invalid characters: < > { } [ ] \\ |');
}

// Check line length (max 2 lines)
const lines = text.split('\n');
if (lines.length > 2) {
errors.push('Maximum of 2 lines allowed');
}

// Check individual line length
lines.forEach((line, index) => {
if (line.length > 15) {
errors.push(`Line ${index + 1} exceeds 15 characters`);
}
});

return {
valid: errors.length === 0,
errors
};
}

static sanitizeText(text) {
return text
.replace(/[<>{}[\]\\|]/g, '') // Remove invalid chars
.substring(0, 30) // Hard limit
.trim();
}
}

Server-Side Validation

The GSM Outdoors API performs additional validation:

  • Profanity filter: Checks against profanity database
  • SQL injection: Parameterized queries, input sanitization
  • XSS prevention: HTML entity encoding
  • Rate limiting: 10 submissions per minute per IP

Error Handling

Error Display Component

// assets/js/engraving/components/ErrorDisplay.jsx
import { h } from 'preact';

export default function ErrorDisplay({ errors }) {
if (!errors || errors.length === 0) return null;

return (
<div class="engraving-errors" role="alert" aria-live="polite">
<h4 class="engraving-errors__title">Please fix the following:</h4>
<ul class="engraving-errors__list">
{errors.map((error, index) => (
<li key={index} class="engraving-errors__item">
{error}
</li>
))}
</ul>
</div>
);
}

Error Styling

// assets/scss/components/_engraving-tool.scss
.engraving-errors {
margin: 16px 0;
padding: 16px;
background: #fef2f2;
border: 2px solid #ef4444;
border-radius: 8px;

&__title {
margin: 0 0 8px;
color: #dc2626;
font-size: 1rem;
font-weight: 700;
}

&__list {
margin: 0;
padding-left: 20px;
color: #991b1b;
}

&__item {
margin: 4px 0;
}
}

Cart Integration

Adding Engraving to Cart

When the customer adds a product to cart, the engraving ID is included:

// assets/js/theme/product.js
async function addToCart(formData, productId, engravingId) {
const cartItem = {
product_id: productId,
quantity: formData.get('qty'),
option_selections: [],
customizations: {
engraving_id: engravingId
}
};

const response = await fetch('/api/storefront/carts/items', {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(cartItem)
});

return response.json();
}

Cart Display

Engraving preview shown in cart:

{{!-- templates/components/cart/item.html --}}
<div class="cart-item">
<div class="cart-item__image">
<img src="{{image.url}}" alt="{{name}}">
</div>
<div class="cart-item__details">
<h3 class="cart-item__name">{{name}}</h3>

{{#if customizations.engraving_id}}
<div class="cart-item__engraving">
<strong>Custom Engraving:</strong>
<img src="https://api.gsmoutdoors.com/v1/api/engraving/orders/{{customizations.engraving_id}}/preview"
alt="Engraving preview"
class="engraving-preview">
</div>
{{/if}}
</div>
</div>

Performance Optimization

Font Loading

Fonts are preloaded and cached:

// assets/js/engraving/utils/fontLoader.js
export class FontLoader {
static cache = new Map();

static async loadFont(font) {
if (this.cache.has(font.id)) {
return this.cache.get(font.id);
}

if (font.system) {
// System fonts are already available
this.cache.set(font.id, true);
return true;
}

// Load custom font
const fontFace = new FontFace(font.family, `url(${font.file})`, {
weight: font.weight,
style: font.style
});

try {
const loadedFont = await fontFace.load();
document.fonts.add(loadedFont);
this.cache.set(font.id, true);
return true;
} catch (error) {
console.error(`Failed to load font: ${font.name}`, error);
return false;
}
}

static async preloadAllFonts(fonts) {
const promises = fonts.map(font => this.loadFont(font));
await Promise.all(promises);
}
}

Canvas Debouncing

Render updates are debounced to improve performance:

import { debounce } from '../utils/debounce';

const debouncedRender = debounce((renderer, text, font, position, spacing) => {
renderer.render(text, font, position, spacing);
}, 150);

Testing

Unit Tests

// assets/js/engraving/__tests__/ValidationService.test.js
import { ValidationService } from '../services/ValidationService';

describe('ValidationService', () => {
test('validates empty text', () => {
const result = ValidationService.validate('', 20);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Engraving text cannot be empty');
});

test('validates character limit', () => {
const longText = 'A'.repeat(25);
const result = ValidationService.validate(longText, 20);
expect(result.valid).toBe(false);
expect(result.errors.length).toBeGreaterThan(0);
});

test('validates invalid characters', () => {
const result = ValidationService.validate('Hello<script>', 20);
expect(result.valid).toBe(false);
});

test('validates line count', () => {
const threeLines = 'Line 1\nLine 2\nLine 3';
const result = ValidationService.validate(threeLines, 50);
expect(result.valid).toBe(false);
expect(result.errors).toContain('Maximum of 2 lines allowed');
});
});