diff --git a/.gitignore b/.gitignore index 341cb20f7..e91dc56ac 100644 --- a/.gitignore +++ b/.gitignore @@ -8,7 +8,7 @@ pnpm-debug.log* lerna-debug.log* node_modules -dist +dist* dist-ssr *.local .npm-cache diff --git a/src/js/logic/font-modal.ts b/src/js/logic/font-modal.ts new file mode 100644 index 000000000..346bd4525 --- /dev/null +++ b/src/js/logic/font-modal.ts @@ -0,0 +1,644 @@ +import { parseFont, getDisplayName, generateFontSourceUrl, hasValidFontExtension } from '../utils/font-utils.js' + +// Font modal elements +const addFontModal = document.getElementById('addFontModal') as HTMLDivElement +const addFontClose = document.getElementById('addFontClose') as HTMLButtonElement +const addFontCancel = document.getElementById('addFontCancel') as HTMLButtonElement +const addFontSubmit = document.getElementById('addFontSubmit') as HTMLButtonElement +const fontModalError = document.getElementById('fontModalError') as HTMLDivElement +const fontModalErrorTitle = document.getElementById('fontModalErrorTitle') as HTMLElement +const fontModalErrorMessage = document.getElementById('fontModalErrorMessage') as HTMLElement + +// Tab elements +const googleFontTab = document.getElementById('googleFontTab') as HTMLDivElement +const fontUrlTab = document.getElementById('fontUrlTab') as HTMLDivElement +const uploadFontTab = document.getElementById('uploadFontTab') as HTMLDivElement + +// Tab buttons +const tabGoogleFont = document.getElementById('tabGoogleFont') as HTMLButtonElement +const tabFontUrl = document.getElementById('tabFontUrl') as HTMLButtonElement +const tabUploadFont = document.getElementById('tabUploadFont') as HTMLButtonElement + +// Form inputs +const googleFontName = document.getElementById('googleFontName') as HTMLInputElement +const fontUrl = document.getElementById('fontUrl') as HTMLInputElement +const fontUrlName = document.getElementById('fontUrlName') as HTMLInputElement +const uploadFontName = document.getElementById('uploadFontName') as HTMLInputElement +const fontFileInput = document.getElementById('fontFileInput') as HTMLInputElement +const uploadedFileName = document.getElementById('uploadedFileName') as HTMLElement + +// Error message templates +const ERROR_MESSAGES = { + // Input validation errors + googleFontRequired: () => ({ + title: 'Input Required', + message: 'Please enter a Google Font name.' + }), + urlAndNameRequired: () => ({ + title: 'Input Required', + message: 'Please enter both font URL and font name.' + }), + fileAndNameRequired: () => ({ + title: 'Input Required', + message: 'Please select a font file and enter a font name.' + }), + invalidUrl: () => ({ + title: 'Invalid URL', + message: 'Please enter a valid URL (e.g., https://example.com/font.woff2)' + }), + googleCssUrl: () => ({ + title: 'Invalid Font URL', + message: 'This appears to be a Google Fonts CSS URL, not a direct font file URL. Please use the "Google Font" tab instead, or find a direct font file URL.' + }), + + // Google Font errors + googleFontDownloadFailed: (fontName: string, familyName: string) => ({ + title: 'Font Download Failed', + message: [ + `Unable to download "${fontName}"`, + '', + 'The font could not be found in the fontsource CDN.', + '', + 'Possible solutions:', + '• Try a different font name (e.g., "Roboto", "Open Sans")', + '• Use the "Font URL" tab with a direct font file link', + '• Use the "Upload" tab to upload a font file', + `• Check if "${familyName}" exists on fonts.google.com` + ].join('\n') + }), + googleFontTimeout: (fontName: string) => ({ + title: 'Font Download Error', + message: [ + `Failed to download "${fontName}"`, + '', + 'Request timed out after 10 seconds.', + '', + 'This could mean:', + '• The server is very slow or unresponsive', + '• The file is extremely large', + '• Network connectivity issues', + '', + 'Please try again or use a different font source.' + ].join('\n') + }), + googleFontNetworkError: (fontName: string) => ({ + title: 'Font Download Error', + message: [ + `Failed to download "${fontName}"`, + '', + 'Network error occurred. This might be due to:', + '• Internet connection issues', + '• Firewall blocking font downloads', + '• CDN services temporarily unavailable', + '', + 'Try again or use the "Upload" tab instead.' + ].join('\n') + }), + googleFontGenericError: (fontName: string, errorMsg: string) => ({ + title: 'Font Download Error', + message: [ + `Failed to download "${fontName}"`, + '', + errorMsg, + '', + 'Please verify the font name exists on Google Fonts and try again.' + ].join('\n') + }), + + // Font URL errors + fontUrlTimeout: () => ({ + title: 'Font URL Error', + message: [ + 'Failed to download font from URL', + '', + 'Request timed out after 10 seconds.', + '', + 'This could mean:', + '• The server is very slow or unresponsive', + '• The file is extremely large', + '• Network connectivity issues', + '', + 'Please try again or use a different font source.' + ].join('\n') + }), + fontUrlNetworkError: () => ({ + title: 'Font URL Error', + message: [ + 'Failed to download font from URL', + '', + 'Network Error: Cannot reach the server.', + '', + 'This could be due to:', + '• Invalid or malformed URL', + '• Server is down or doesn\'t exist', + '• Internet connectivity issues', + '• DNS resolution problems', + '', + 'Please check the URL and try again.' + ].join('\n') + }), + fontUrlCors: () => ({ + title: 'Font URL Error', + message: [ + 'Failed to download font from URL', + '', + 'CORS Error: The server doesn\'t allow cross-origin access.', + '', + 'This happens when the font server doesn\'t include proper CORS headers.', + '', + 'Solutions:', + '• Use the "Upload" tab to manually upload the font file', + '• Try a CDN URL (e.g., jsDelivr, unpkg) that allows cross-origin access', + '• Use the "Google Font" tab for Google Fonts', + '• Download the font file and upload it manually' + ].join('\n') + }), + fontUrlNotFound: () => ({ + title: 'Font URL Error', + message: [ + 'Failed to download font from URL', + '', + 'File Not Found (404)', + '', + 'The font file doesn\'t exist at this URL.', + '', + 'Please check:', + '• The URL is correct and complete', + '• The file extension (.ttf, .woff2, etc.) is included', + '• The server is accessible' + ].join('\n') + }), + fontUrlAccessDenied: () => ({ + title: 'Font URL Error', + message: [ + 'Failed to download font from URL', + '', + 'Access Denied (403)', + '', + 'The server is blocking access to this font file.', + '', + 'Please try:', + '• A different font source', + '• Using the "Upload" tab instead' + ].join('\n') + }), + fontUrlGenericError: (errorMsg: string) => ({ + title: 'Font URL Error', + message: [ + 'Failed to download font from URL', + '', + errorMsg, + '', + 'Please check the URL and try again.' + ].join('\n') + }), + + // Content validation errors + htmlPage: () => ({ + title: 'Invalid Font File', + message: 'This URL returns an HTML page, not a font file. Please check the URL and try again.' + }), + cssFile: () => ({ + title: 'Invalid Font File', + message: 'This URL returns CSS, not a font file. Please use a direct link to a font file (.ttf, .woff2, etc.)' + }), + jsonFile: () => ({ + title: 'Invalid Font File', + message: 'This URL returns JSON data, not a font file. Please use a direct link to a font file.' + }), + invalidFontFile: (hostname: string, contentType: string) => ({ + title: 'Invalid Font File', + message: `This URL from "${hostname}" doesn't appear to be a font file.\n\nValid font files should:\n• End with .ttf, .otf, .woff, .woff2, or .eot\n• Have proper font MIME types\n\nReceived content-type: ${contentType || 'none'}` + }), + emptyFile: () => ({ + title: 'Invalid Font File', + message: 'Downloaded file is empty' + }), + fileTooSmall: () => ({ + title: 'Invalid Font File', + message: 'Downloaded file is too small to be a font' + }), + + // Generic error + unexpectedError: () => ({ + title: 'Unexpected Error', + message: 'An error occurred while adding the font. Please try again.' + }) +} as const + +// State variables +let currentActiveTab = 'google' +let selectedFontFile: File | null = null +let onFontAdded: ((fontName: string, fontBuffer: ArrayBuffer) => void) | null = null + +/** + * Initialize the font modal functionality + */ +export function initializeFontModal(onFontAddedCallback?: (fontName: string, fontBuffer: ArrayBuffer) => void): void { + onFontAdded = onFontAddedCallback || null + + // Set up event listeners + setupEventListeners() + + // Initialize default tab + switchTab('google') +} + +/** + * Show the font modal + */ +export function showFontModal(): void { + if (!addFontModal) return + addFontModal.classList.remove('hidden') +} + +/** + * Hide the font modal + */ +export function hideFontModal(): void { + if (!addFontModal) return + addFontModal.classList.add('hidden') + resetModalInputs() +} + +/** + * Show error message within the font modal + */ +function showFontModalError(title: string, message: string): void { + if (!fontModalError || !fontModalErrorTitle || !fontModalErrorMessage) return + + fontModalErrorTitle.textContent = title + fontModalErrorMessage.textContent = message + fontModalError.classList.remove('hidden') + + // Scroll error into view + fontModalError.scrollIntoView({ behavior: 'smooth', block: 'nearest' }) +} + +/** + * Hide error message within the font modal + */ +function hideFontModalError(): void { + if (!fontModalError) return + fontModalError.classList.add('hidden') +} + +/** + * Reset all modal inputs and state + */ +function resetModalInputs(): void { + googleFontName.value = '' + fontUrl.value = '' + fontUrlName.value = '' + uploadFontName.value = '' + fontFileInput.value = '' + selectedFontFile = null + uploadedFileName.classList.add('hidden') + uploadedFileName.textContent = '' + hideFontModalError() + + // Reset button state + if (addFontSubmit) { + addFontSubmit.disabled = false + addFontSubmit.textContent = 'Add Font' + addFontSubmit.style.backgroundColor = '' + addFontSubmit.style.borderColor = '' + addFontSubmit.style.cursor = '' + addFontSubmit.style.opacity = '' + } +} + +/** + * Switch between modal tabs + */ +function switchTab(tab: string): void { + currentActiveTab = tab + + // Clear any existing errors when switching tabs + hideFontModalError() + + // Update tab buttons + const tabs = [tabGoogleFont, tabFontUrl, tabUploadFont] + const tabContents = [googleFontTab, fontUrlTab, uploadFontTab] + + tabs.forEach(t => { + t.classList.remove('text-indigo-400', 'border-indigo-400', 'active') + t.classList.add('text-gray-400', 'border-transparent') + }) + + tabContents.forEach(content => { + content.classList.add('hidden') + }) + + // Activate current tab + switch (tab) { + case 'google': + tabGoogleFont.classList.add('text-indigo-400', 'border-indigo-400', 'active') + tabGoogleFont.classList.remove('text-gray-400', 'border-transparent') + googleFontTab.classList.remove('hidden') + break + case 'url': + tabFontUrl.classList.add('text-indigo-400', 'border-indigo-400', 'active') + tabFontUrl.classList.remove('text-gray-400', 'border-transparent') + fontUrlTab.classList.remove('hidden') + break + case 'upload': + tabUploadFont.classList.add('text-indigo-400', 'border-indigo-400', 'active') + tabUploadFont.classList.remove('text-gray-400', 'border-transparent') + uploadFontTab.classList.remove('hidden') + break + } +} + +/** + * Handle font submission + */ +async function handleFontSubmit(): Promise { + try { + addFontSubmit.disabled = true + addFontSubmit.textContent = 'Adding...' + addFontSubmit.style.cursor = 'not-allowed' + addFontSubmit.style.opacity = '0.7' + + let fontBuffer: ArrayBuffer | null = null + let fontName = '' + + switch (currentActiveTab) { + case 'google': + const inputFontName = googleFontName.value.trim() + + if (!inputFontName) { + const error = ERROR_MESSAGES.googleFontRequired() + + showFontModalError(error.title, error.message) + + return + } + + try { + // Parse the font name with smart weight detection + const parsedFont = parseFont(inputFontName) + fontName = getDisplayName(parsedFont) + + try { + const fontSourceUrl = generateFontSourceUrl(parsedFont) + const response = await fetch(fontSourceUrl) + + if (response.ok) { + fontBuffer = await response.arrayBuffer() + } + } catch (error) { + // Font download failed + } + + if (!fontBuffer) { + const error = ERROR_MESSAGES.googleFontDownloadFailed(inputFontName, parsedFont.family) + showFontModalError(error.title, error.message) + return + } + + } catch (error) { + let errorMsg + + if (error.name === 'AbortError' || error.message.includes('aborted')) { + errorMsg = ERROR_MESSAGES.googleFontTimeout(inputFontName) + } else if (error.message.includes('NetworkError') || error.message.includes('fetch')) { + errorMsg = ERROR_MESSAGES.googleFontNetworkError(inputFontName) + } else if (error.message.includes('timeout')) { + errorMsg = ERROR_MESSAGES.googleFontTimeout(inputFontName) + } else { + errorMsg = ERROR_MESSAGES.googleFontGenericError(inputFontName, error.message) + } + + showFontModalError(errorMsg.title, errorMsg.message) + return + } + break + + case 'url': + const url = fontUrl.value.trim() + + fontName = fontUrlName.value.trim() + + if (!url || !fontName) { + const error = ERROR_MESSAGES.urlAndNameRequired() + showFontModalError(error.title, error.message) + return + } + + // Validate URL format before making network request + try { + new URL(url) + } catch { + const error = ERROR_MESSAGES.invalidUrl() + showFontModalError(error.title, error.message) + return + } + + // Check if user accidentally pasted a Google Fonts CSS URL BEFORE making request + if (url.includes('fonts.googleapis.com') && url.includes('css')) { + const error = ERROR_MESSAGES.googleCssUrl() + showFontModalError(error.title, error.message) + return + } + + try { + // Handle direct font file URL only with timeout + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), 10000) // 10 second timeout + + const response = await fetch(url, { + signal: controller.signal + }) + + clearTimeout(timeoutId) + + if (!response.ok) { + throw new Error(`Failed to fetch font file (${response.status})`) + } + + // Validate that it's actually a font file + const contentType = response.headers.get('content-type') || '' + const hasValidExtension = hasValidFontExtension(url) + const hasValidContentType = contentType.includes('font/') || + contentType.includes('application/font') || + contentType.includes('application/x-font') + + // Check for common non-font content types first + if (contentType.includes('text/html')) { + const error = ERROR_MESSAGES.htmlPage() + throw new Error(error.message) + } + + if (contentType.includes('text/css')) { + const error = ERROR_MESSAGES.cssFile() + throw new Error(error.message) + } + + if (contentType.includes('application/json')) { + const error = ERROR_MESSAGES.jsonFile() + throw new Error(error.message) + } + + // Require either valid extension OR valid content type + if (!hasValidExtension && !hasValidContentType) { + const urlHost = new URL(url).hostname + const error = ERROR_MESSAGES.invalidFontFile(urlHost, contentType) + throw new Error(error.message) + } + + fontBuffer = await response.arrayBuffer() + + // Validate buffer size + if (fontBuffer.byteLength === 0) { + const error = ERROR_MESSAGES.emptyFile() + throw new Error(error.message) + } + + if (fontBuffer.byteLength < 1000) { + const error = ERROR_MESSAGES.fileTooSmall() + throw new Error(error.message) + } + + } catch (error) { + let errorMsg + + if (error.name === 'AbortError' || error.message.includes('aborted')) { + errorMsg = ERROR_MESSAGES.fontUrlTimeout() + } else if (error.message.includes('Failed to fetch') || error.message.includes('NetworkError')) { + errorMsg = ERROR_MESSAGES.fontUrlNetworkError() + } else if (error.message.includes('CORS')) { + errorMsg = ERROR_MESSAGES.fontUrlCors() + } else if (error.message.includes('404')) { + errorMsg = ERROR_MESSAGES.fontUrlNotFound() + } else if (error.message.includes('403')) { + errorMsg = ERROR_MESSAGES.fontUrlAccessDenied() + } else { + errorMsg = ERROR_MESSAGES.fontUrlGenericError(error.message) + } + + showFontModalError(errorMsg.title, errorMsg.message) + return + } + break + + case 'upload': + fontName = uploadFontName.value.trim() + + if (!selectedFontFile || !fontName) { + const error = ERROR_MESSAGES.fileAndNameRequired() + showFontModalError(error.title, error.message) + return + } + + fontBuffer = await selectedFontFile.arrayBuffer() + break + } + + // If we got here, font was successfully downloaded/loaded + if (fontBuffer && fontName) { + // Show success state on button (keep it disabled to prevent double-clicks) + addFontSubmit.disabled = true + addFontSubmit.textContent = '✓ Added!' + addFontSubmit.style.backgroundColor = '#16a34a' // green-600 + addFontSubmit.style.borderColor = '#16a34a' + addFontSubmit.style.cursor = 'default' + addFontSubmit.style.opacity = '1' + + // Call the callback to add the font to the form creator + if (onFontAdded) onFontAdded(fontName, fontBuffer) + + // Hide modal after a short delay to show success state + setTimeout(() => { + hideFontModal() + }, 800) + + // Don't reset button state here - let the success state show + return + } + + } catch (error) { + console.error('Error adding font:', error) + const errorMsg = ERROR_MESSAGES.unexpectedError() + showFontModalError(errorMsg.title, errorMsg.message) + } finally { + // Only reset button state if we didn't succeed + if (addFontSubmit.textContent !== '✓ Added!') { + addFontSubmit.disabled = false + addFontSubmit.textContent = 'Add Font' + } + } +} + +/** + * Set up all event listeners for the font modal + */ +function setupEventListeners(): void { + // Tab switching + if (tabGoogleFont) { + tabGoogleFont.addEventListener('click', () => switchTab('google')) + } + + if (tabFontUrl) { + tabFontUrl.addEventListener('click', () => switchTab('url')) + } + + if (tabUploadFont) { + tabUploadFont.addEventListener('click', () => switchTab('upload')) + } + + // File upload handling + if (fontFileInput) { + fontFileInput.addEventListener('change', (e) => { + const file = (e.target as HTMLInputElement).files?.[0] + + if (!file) return + + selectedFontFile = file + uploadedFileName.textContent = `Selected: ${file.name}` + uploadedFileName.classList.remove('hidden') + + // Auto-populate font name from filename + if (!uploadFontName.value) { + const baseName = file.name.replace(/\.[^/.]+$/, '').replace(/[_-]/g, ' ') + uploadFontName.value = baseName.split(' ') + .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' ') + } + + // Clear error when file is selected + hideFontModalError() + }) + } + + // Clear errors when users start typing in input fields + if (googleFontName) { + googleFontName.addEventListener('input', hideFontModalError) + } + + if (fontUrl) { + fontUrl.addEventListener('input', hideFontModalError) + } + + if (fontUrlName) { + fontUrlName.addEventListener('input', hideFontModalError) + } + + if (uploadFontName) { + uploadFontName.addEventListener('input', hideFontModalError) + } + + // Modal event listeners + if (addFontClose) { + addFontClose.addEventListener('click', hideFontModal) + } + + if (addFontCancel) { + addFontCancel.addEventListener('click', hideFontModal) + } + + if (addFontSubmit) { + addFontSubmit.addEventListener('click', handleFontSubmit) + } +} + diff --git a/src/js/logic/form-creator.ts b/src/js/logic/form-creator.ts index 729b2d1b5..a71c09610 100644 --- a/src/js/logic/form-creator.ts +++ b/src/js/logic/form-creator.ts @@ -1,15 +1,68 @@ import { PDFDocument, StandardFonts, rgb, TextAlignment, PDFName, PDFString, PageSizes, PDFBool, PDFDict, PDFArray, PDFRadioGroup } from 'pdf-lib' +import fontkit from '@pdf-lib/fontkit' import { initializeGlobalShortcuts } from '../utils/shortcuts-init.js' import { downloadFile, hexToRgb, getPDFDocument } from '../utils/helpers.js' import { createIcons, icons } from 'lucide' import * as pdfjsLib from 'pdfjs-dist' import 'pdfjs-dist/web/pdf_viewer.css' +import { parseFont } from '../utils/font-utils.js' +import { initializeFontModal, showFontModal } from './font-modal.js' // Initialize PDF.js worker pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString() import { FormField, PageData } from '../types/index.js' +const STANDARD_FONT_NAMES: string[] = Object.values(StandardFonts) + +// Additional fonts storage +let additionalFonts: string[] = [] +let additionalFontBuffers: Map = new Map() + +// Helper function to apply parsed font styles to an element +function applyParsedFontStyles(element: HTMLElement, fontFamily: string): void { + const parsedFont = parseFont(fontFamily) + + element.style.fontFamily = parsedFont.family + element.style.fontWeight = parsedFont.weight + element.style.fontStyle = parsedFont.style +} + +// Callback function for when a font is added via the font modal +function onFontAdded(fontName: string, fontBuffer: ArrayBuffer): void { + // Store in additional fonts + additionalFonts.push(fontName) + + // Store font buffer for later embedding + additionalFontBuffers.set(fontName, fontBuffer) + + // Load the font for web rendering (applies to all custom fonts) + const parsedFont = parseFont(fontName) + const blob = new Blob([fontBuffer], { type: 'font/ttf' }) + const fontUrl = URL.createObjectURL(blob) + const fontFace = new FontFace(parsedFont.family, `url(${fontUrl})`, { + weight: parsedFont.weight, + style: parsedFont.style + }) + + fontFace.load().then(() => { + document.fonts.add(fontFace) + }).catch(err => { + console.warn('Failed to load web font:', err) + }) + + + // Update all font dropdowns + const propFontFamily = document.getElementById('propFontFamily') as HTMLSelectElement + + if (propFontFamily) propFontFamily.innerHTML = getAllFontOptions() + + // Update embedded fonts list + populateEmbeddedFontsList() + + // Update properties panel if currently showing + if (selectedField) showProperties(selectedField) +} let fields: FormField[] = [] let selectedField: FormField | null = null @@ -43,6 +96,7 @@ let selectedToolType: string | null = null const canvas = document.getElementById('pdfCanvas') as HTMLDivElement const propertiesPanel = document.getElementById('propertiesPanel') as HTMLDivElement +const embeddedFontsList = document.getElementById('embeddedFontsList') as HTMLDivElement const fieldCountDisplay = document.getElementById('fieldCount') as HTMLSpanElement const uploadArea = document.getElementById('upload-area') as HTMLDivElement const toolContainer = document.getElementById('tool-container') as HTMLDivElement @@ -66,6 +120,7 @@ const backToToolsBtn = document.getElementById('back-to-tools') as HTMLButtonEle const gotoPageInput = document.getElementById('gotoPageInput') as HTMLInputElement const gotoPageBtn = document.getElementById('gotoPageBtn') as HTMLButtonElement + const gridVInput = document.getElementById('gridVInput') as HTMLInputElement const gridHInput = document.getElementById('gridHInput') as HTMLInputElement const toggleGridBtn = document.getElementById('toggleGridBtn') as HTMLButtonElement @@ -171,6 +226,81 @@ function removeGrid() { if (existingGrid) existingGrid.remove() } +// Get all available font options for dropdown +function getAllFontOptions(selectedFontFamily?: string): string { + const allFonts = [...STANDARD_FONT_NAMES, ...additionalFonts] + const options = allFonts.map(font => + `` + ).join('') + + return options + + `` + + `` +} + +// Populate the embedded fonts list with fonts actually used in form fields +function populateEmbeddedFontsList(): void { + if (!embeddedFontsList) return + + embeddedFontsList.innerHTML = '' + + // Get unique fonts from all fields + const usedFonts = new Set() + fields.forEach(field => { + if (field.fontFamily) { + usedFonts.add(field.fontFamily) + } + }) + + if (usedFonts.size === 0) { + const emptyMessage = document.createElement('div') + emptyMessage.className = 'text-xs text-gray-400 italic text-center py-2' + emptyMessage.textContent = 'No fonts being used yet. Add text fields to see embedded fonts.' + embeddedFontsList.appendChild(emptyMessage) + return + } + + const sortedFonts = Array.from(usedFonts).sort() + + sortedFonts.forEach(fontName => { + const fontItem = document.createElement('div') + fontItem.className = 'flex items-center gap-2 py-1' + + const indicator = document.createElement('span') + const isStandard = STANDARD_FONT_NAMES.includes(fontName) + + if (isStandard) { + indicator.textContent = '🅰' + indicator.title = 'Standard PDF Font' + } else { + indicator.textContent = '🌐' + indicator.title = 'Custom Font' + } + + indicator.className = 'text-xs' + + const label = document.createElement('span') + + label.textContent = fontName + label.className = `text-xs text-gray-300 flex-1 ${STANDARD_FONT_NAMES.includes(fontName) ? '' : 'font-medium'}` + label.style.fontFamily = fontName + + // Count how many fields use this font + const fieldCount = fields.filter(f => f.fontFamily === fontName).length + const countBadge = document.createElement('span') + + countBadge.textContent = fieldCount.toString() + countBadge.className = 'text-xs bg-indigo-600 text-white px-1.5 py-0.5 rounded-full' + countBadge.title = `${fieldCount} field(s) using this font` + + fontItem.appendChild(indicator) + fontItem.appendChild(label) + fontItem.appendChild(countBadge) + + embeddedFontsList.appendChild(fontItem) + }) +} + if (gotoPageBtn && gotoPageInput) { gotoPageBtn.addEventListener('click', () => { const pageNum = parseInt(gotoPageInput.value) @@ -325,6 +455,7 @@ function createField(type: FormField['type'], x: number, y: number): void { fontSize: 12, alignment: 'left', textColor: '#000000', + fontFamily: StandardFonts.Helvetica, required: false, readOnly: false, tooltip: '', @@ -348,6 +479,7 @@ function createField(type: FormField['type'], x: number, y: number): void { fields.push(field) renderField(field) updateFieldCount() + populateEmbeddedFontsList() // Update fonts list when field is added } // Render field on canvas @@ -396,6 +528,11 @@ function renderField(field: FormField): void { contentEl.style.alignItems = field.multiline ? 'flex-start' : 'center' contentEl.textContent = field.defaultValue + // Apply font family with proper weight and style parsing + if (field.fontFamily) { + applyParsedFontStyles(contentEl, field.fontFamily) + } + // Apply combing visual if enabled if (field.combCells > 0) { contentEl.style.backgroundImage = `repeating-linear-gradient(90deg, transparent, transparent calc((100% / ${field.combCells}) - 1px), #e5e7eb calc((100% / ${field.combCells}) - 1px), #e5e7eb calc(100% / ${field.combCells}))` @@ -501,11 +638,14 @@ function renderField(field: FormField): void { // Touch events for moving fields let touchMoveStarted = false + fieldWrapper.addEventListener('touchstart', (e) => { if ((e.target as HTMLElement).classList.contains('resize-handle')) { return } + touchMoveStarted = false + const touch = e.touches[0] const rect = canvas.getBoundingClientRect() offsetX = touch.clientX - rect.left - field.x @@ -516,6 +656,7 @@ function renderField(field: FormField): void { fieldWrapper.addEventListener('touchmove', (e) => { e.preventDefault() touchMoveStarted = true + const touch = e.touches[0] const rect = canvas.getBoundingClientRect() let newX = touch.clientX - rect.left - offsetX @@ -537,9 +678,12 @@ function renderField(field: FormField): void { // Add resize handles to the container - hidden by default const handles = ['nw', 'ne', 'sw', 'se', 'n', 's', 'e', 'w'] + handles.forEach((pos) => { const handle = document.createElement('div') + handle.className = `absolute w-2.5 h-2.5 bg-white border border-indigo-600 z-10 cursor-${pos}-resize resize-handle hidden` // Added hidden class + const positions: Record = { nw: 'top-0 left-0 -translate-x-1/2 -translate-y-1/2', ne: 'top-0 right-0 translate-x-1/2 -translate-y-1/2', @@ -562,6 +706,7 @@ function renderField(field: FormField): void { handle.addEventListener('touchstart', (e) => { e.stopPropagation() e.preventDefault() + const touch = e.touches[0] // Create a synthetic mouse event for startResize const syntheticEvent = { @@ -569,6 +714,7 @@ function renderField(field: FormField): void { clientY: touch.clientY, preventDefault: () => { } } as MouseEvent + startResize(syntheticEvent, field, pos) }) @@ -605,6 +751,7 @@ document.addEventListener('mousemove', (e) => { draggedElement.style.top = newY + 'px' const field = fields.find((f) => f.id === draggedElement!.id) + if (field) { field.x = newX field.y = newY @@ -617,33 +764,41 @@ document.addEventListener('mousemove', (e) => { if (resizePos!.includes('e')) { resizeField.width = Math.max(50, startWidth + dx) } + if (resizePos!.includes('w')) { const newWidth = Math.max(50, startWidth - dx) const widthDiff = startWidth - newWidth + resizeField.width = newWidth resizeField.x = startLeft + widthDiff } + if (resizePos!.includes('s')) { resizeField.height = Math.max(20, startHeight + dy) } + if (resizePos!.includes('n')) { const newHeight = Math.max(20, startHeight - dy) const heightDiff = startHeight - newHeight + resizeField.height = newHeight resizeField.y = startTop + heightDiff } if (fieldWrapper) { const container = fieldWrapper.querySelector('.field-container') as HTMLElement + fieldWrapper.style.width = resizeField.width + 'px' fieldWrapper.style.left = resizeField.x + 'px' fieldWrapper.style.top = resizeField.y + 'px' + if (container) { container.style.height = resizeField.height + 'px' } // Update combing visuals on resize if (resizeField.combCells > 0) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement + if (textEl) { textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)` textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)` @@ -657,11 +812,13 @@ document.addEventListener('mouseup', () => { draggedElement = null resizing = false resizeField = null + if (!gridAlwaysVisible) removeGrid() }) document.addEventListener('touchmove', (e) => { const touch = e.touches[0] + if (resizing && resizeField) { const dx = touch.clientX - startX const dy = touch.clientY - startY @@ -670,32 +827,41 @@ document.addEventListener('touchmove', (e) => { if (resizePos!.includes('e')) { resizeField.width = Math.max(50, startWidth + dx) } + if (resizePos!.includes('w')) { const newWidth = Math.max(50, startWidth - dx) const widthDiff = startWidth - newWidth + resizeField.width = newWidth resizeField.x = startLeft + widthDiff } + if (resizePos!.includes('s')) { resizeField.height = Math.max(20, startHeight + dy) } + if (resizePos!.includes('n')) { const newHeight = Math.max(20, startHeight - dy) const heightDiff = startHeight - newHeight + resizeField.height = newHeight resizeField.y = startTop + heightDiff } if (fieldWrapper) { const container = fieldWrapper.querySelector('.field-container') as HTMLElement + fieldWrapper.style.width = resizeField.width + 'px' fieldWrapper.style.left = resizeField.x + 'px' fieldWrapper.style.top = resizeField.y + 'px' + if (container) { container.style.height = resizeField.height + 'px' } + if (resizeField.combCells > 0) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement + if (textEl) { textEl.style.letterSpacing = `calc(${resizeField.width / resizeField.combCells}px - 1ch)` textEl.style.paddingLeft = `calc((${resizeField.width / resizeField.combCells}px - 1ch) / 2)` @@ -716,7 +882,9 @@ document.addEventListener('touchend', () => { function selectField(field: FormField): void { deselectAll() selectedField = field + const fieldWrapper = document.getElementById(field.id) + if (fieldWrapper) { const container = fieldWrapper.querySelector('.field-container') as HTMLElement const label = fieldWrapper.querySelector('.field-label') as HTMLElement @@ -744,6 +912,7 @@ function selectField(field: FormField): void { function deselectAll(): void { if (selectedField) { const fieldWrapper = document.getElementById(selectedField.id) + if (fieldWrapper) { const container = fieldWrapper.querySelector('.field-container') as HTMLElement const label = fieldWrapper.querySelector('.field-label') as HTMLElement @@ -764,8 +933,10 @@ function deselectAll(): void { handle.classList.add('hidden') }) } + selectedField = null } + hideProperties() } @@ -787,6 +958,12 @@ function showProperties(field: FormField): void { +
+ + +
@@ -991,12 +1168,14 @@ function showProperties(field: FormField): void { nameError.textContent = 'Field name cannot be empty' nameError.classList.remove('hidden') propName.classList.add('border-red-500') + return false } if (field.type === 'radio') { nameError.classList.add('hidden') propName.classList.remove('border-red-500') + return true } @@ -1007,16 +1186,19 @@ function showProperties(field: FormField): void { nameError.textContent = `Field name "${newName}" already exists in this ${isDuplicateInPdf ? 'PDF' : 'form'}. Please try using a unique name.` nameError.classList.remove('hidden') propName.classList.add('border-red-500') + return false } nameError.classList.add('hidden') propName.classList.remove('border-red-500') + return true } propName.addEventListener('input', (e) => { const newName = (e.target as HTMLInputElement).value.trim() + validateName(newName) }) @@ -1029,9 +1211,12 @@ function showProperties(field: FormField): void { } field.name = newName + const fieldWrapper = document.getElementById(field.id) + if (fieldWrapper) { const label = fieldWrapper.querySelector('.field-label') as HTMLElement + if (label) label.textContent = field.name } }) @@ -1042,9 +1227,11 @@ function showProperties(field: FormField): void { if (field.type === 'radio') { const existingGroupsSelect = document.getElementById('existingGroups') as HTMLSelectElement + if (existingGroupsSelect) { existingGroupsSelect.addEventListener('change', (e) => { const selectedGroup = (e.target as HTMLSelectElement).value + if (selectedGroup) { propName.value = selectedGroup field.name = selectedGroup @@ -1052,6 +1239,7 @@ function showProperties(field: FormField): void { // Update field label const fieldWrapper = document.getElementById(field.id) + if (fieldWrapper) { const label = fieldWrapper.querySelector('.field-label') as HTMLElement if (label) label.textContent = field.name @@ -1089,6 +1277,7 @@ function showProperties(field: FormField): void { const propValue = document.getElementById('propValue') as HTMLInputElement const propMaxLength = document.getElementById('propMaxLength') as HTMLInputElement const propComb = document.getElementById('propComb') as HTMLInputElement + const propFontFamily = document.getElementById('propFontFamily') as HTMLSelectElement const propFontSize = document.getElementById('propFontSize') as HTMLInputElement const propTextColor = document.getElementById('propTextColor') as HTMLInputElement const propAlignment = document.getElementById('propAlignment') as HTMLSelectElement @@ -1104,15 +1293,21 @@ function showProperties(field: FormField): void { propMaxLength.addEventListener('input', (e) => { const val = parseInt((e.target as HTMLInputElement).value) + field.maxLength = isNaN(val) ? 0 : Math.max(0, val) + if (field.maxLength > 0) { propValue.maxLength = field.maxLength + if (field.defaultValue.length > field.maxLength) { field.defaultValue = field.defaultValue.substring(0, field.maxLength) propValue.value = field.defaultValue + const fieldWrapper = document.getElementById(field.id) + if (fieldWrapper) { const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement + if (textEl) textEl.textContent = field.defaultValue } } @@ -1123,6 +1318,7 @@ function showProperties(field: FormField): void { propComb.addEventListener('input', (e) => { const val = parseInt((e.target as HTMLInputElement).value) + field.combCells = isNaN(val) ? 0 : Math.max(0, val) if (field.combCells > 0) { @@ -1138,6 +1334,7 @@ function showProperties(field: FormField): void { } else { propMaxLength.disabled = false propValue.removeAttribute('maxLength') + if (field.maxLength > 0) { propValue.maxLength = field.maxLength } @@ -1145,9 +1342,11 @@ function showProperties(field: FormField): void { // Re-render field visual only, NOT the properties panel const fieldWrapper = document.getElementById(field.id) + if (fieldWrapper) { // Update text content const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement + if (textEl) { textEl.textContent = field.defaultValue if (field.combCells > 0) { @@ -1169,6 +1368,30 @@ function showProperties(field: FormField): void { } }) + propFontFamily.addEventListener('change', (e) => { + const selectedValue = (e.target as HTMLSelectElement).value + + if (selectedValue === '__more_fonts__') { + propFontFamily.value = field.fontFamily + showFontModal() + return + } + + field.fontFamily = selectedValue + + populateEmbeddedFontsList() + + const fieldWrapper = document.getElementById(field.id) + + if (fieldWrapper) { + const textEl = fieldWrapper.querySelector('.field-text') as HTMLElement + + if (textEl) { + applyParsedFontStyles(textEl, field.fontFamily) + } + } + }) + propFontSize.addEventListener('input', (e) => { field.fontSize = parseInt((e.target as HTMLInputElement).value) const fieldWrapper = document.getElementById(field.id) @@ -1410,6 +1633,7 @@ function deleteField(field: FormField): void { fields = fields.filter((f) => f.id !== field.id) deselectAll() updateFieldCount() + populateEmbeddedFontsList() // Update fonts list when field is deleted } // Delete key handler @@ -1502,9 +1726,44 @@ downloadBtn.addEventListener('click', async () => { } } + // Register fontkit for custom font embedding + pdfDoc.registerFontkit(fontkit) + const form = pdfDoc.getForm() - const helveticaFont = await pdfDoc.embedFont(StandardFonts.Helvetica) + // Embed fonts that are used in the form + const usedFonts = new Set() + + fields.forEach(field => { + if (field.fontFamily) usedFonts.add(field.fontFamily) + }) + + const embeddedFontMap = new Map() + + for (const fontName of usedFonts) { + try { + let embeddedFont + + // Check if it's a custom font (additional font) or standard font + if (additionalFontBuffers.has(fontName)) { + // Use the downloaded font buffer + const fontBuffer = additionalFontBuffers.get(fontName)! + + if (fontBuffer.byteLength === 0) { + embeddedFont = await pdfDoc.embedFont(StandardFonts.Helvetica) + } else { + embeddedFont = await pdfDoc.embedFont(fontBuffer) + } + } else { + // Use standard PDF font name + embeddedFont = await pdfDoc.embedFont(fontName) + } + + embeddedFontMap.set(fontName, embeddedFont) + } catch (error) { + throw new Error(`Failed to embed font "${fontName}": ${error.message}`) + } + } // Set document metadata for accessibility pdfDoc.setTitle('Fillable Form') @@ -1554,10 +1813,11 @@ downloadBtn.addEventListener('click', async () => { textColor: rgb(rgbColor.r, rgbColor.g, rgbColor.b), }) + // Set ALL properties FIRST (these may mark field as "dirty") textField.setText(field.defaultValue) textField.setFontSize(field.fontSize) - // Set alignment + // Set alignment BEFORE font to avoid marking field dirty again if (field.alignment === 'center') { textField.setAlignment(TextAlignment.Center) } else if (field.alignment === 'right') { @@ -1590,6 +1850,14 @@ downloadBtn.addEventListener('click', async () => { }) } + // ABSOLUTE FINAL STEP: Apply custom font after ALL other properties are set + if (field.fontFamily && embeddedFontMap.has(field.fontFamily)) { + const selectedFont = embeddedFontMap.get(field.fontFamily) + + // Update appearances LAST to avoid "dirty" flag override from ANY property setter + textField.updateAppearances(selectedFont) + } + } else if (field.type === 'checkbox') { const checkBox = form.createCheckBox(field.name) const borderRgb = hexToRgb(field.borderColor || '#000000') @@ -1929,7 +2197,6 @@ downloadBtn.addEventListener('click', async () => { } } - form.updateFieldAppearances(helveticaFont) const pdfBytes = await pdfDoc.save() const blob = new Blob([new Uint8Array(pdfBytes)], { type: 'application/pdf' }) @@ -2006,6 +2273,10 @@ function resetToInitial(): void { uploadedPdfDoc = null selectedField = null + // Clear additional fonts + additionalFonts = [] + additionalFontBuffers.clear() + canvas.innerHTML = '' propertiesPanel.innerHTML = '

Select a field to edit properties

' @@ -2246,6 +2517,7 @@ confirmBlankBtn.addEventListener('click', () => { // Hide upload area and show tool container uploadArea.classList.add('hidden') toolContainer.classList.remove('hidden') + populateEmbeddedFontsList() setTimeout(() => createIcons({ icons }), 100) }) @@ -2282,7 +2554,7 @@ async function handlePdfUpload(file: File) { } }) - // TODO@ALAM: DEBUGGER + // TODO@ALAM: DEBUGGER // console.log('Field counter after upload:', fieldCounter) // console.log('Existing field names:', Array.from(existingFieldNames)) } catch (e) { @@ -2313,6 +2585,7 @@ async function handlePdfUpload(file: File) { // Hide upload area and show tool container uploadArea.classList.add('hidden') toolContainer.classList.remove('hidden') + populateEmbeddedFontsList() // Init icons setTimeout(() => createIcons({ icons }), 100) @@ -2392,4 +2665,10 @@ if (errorModal) { }) } + + + + + +initializeFontModal(onFontAdded) initializeGlobalShortcuts() diff --git a/src/js/types/form-creator.ts b/src/js/types/form-creator.ts index f0f7c5bb4..30a088741 100644 --- a/src/js/types/form-creator.ts +++ b/src/js/types/form-creator.ts @@ -10,6 +10,7 @@ export interface FormField { fontSize: number alignment: 'left' | 'center' | 'right' textColor: string + fontFamily: string required: boolean readOnly: boolean tooltip: string diff --git a/src/js/utils/font-utils.ts b/src/js/utils/font-utils.ts new file mode 100644 index 000000000..b0d547d81 --- /dev/null +++ b/src/js/utils/font-utils.ts @@ -0,0 +1,172 @@ +/** + * Utility for parsing font names with weights and styles + */ + +export interface ParsedFont { + family: string + weight: string + style: string + originalInput: string +} + +// Map common weight names to numeric values +const WEIGHT_MAP: Record = { + 'thin': '100', + 'extralight': '200', + 'extra-light': '200', + 'ultralight': '200', + 'ultra-light': '200', + 'light': '300', + 'normal': '400', + 'regular': '400', + 'medium': '500', + 'semibold': '600', + 'semi-bold': '600', + 'demibold': '600', + 'demi-bold': '600', + 'bold': '700', + 'extrabold': '800', + 'extra-bold': '800', + 'ultrabold': '800', + 'ultra-bold': '800', + 'black': '900', + 'heavy': '900' +} + +// Common style variations +const STYLE_MAP: Record = { + 'italic': 'italic', + 'oblique': 'italic', + 'slanted': 'italic' +} + +const DEFAULT_STYLE = 'normal' +const DEFAULT_WEIGHT = '400' + +/** + * Parse a font string that may include weight and style + * Examples: + * - "Open Sans" -> { family: "Open Sans", weight: "400" } + * - "Open Sans Bold" -> { family: "Open Sans", weight: "700" } + * - "Open Sans 300" -> { family: "Open Sans", weight: "300" } + * - "Roboto Light Italic" -> { family: "Roboto", weight: "300", style: "italic" } + * - "Poppins Semi Bold" -> { family: "Poppins", weight: "600" } + */ +export function parseFont(input: string): ParsedFont { + const originalInput = input + const parts = input.trim().split(/\s+/) + let style = DEFAULT_STYLE + let weight = DEFAULT_WEIGHT + + // If no parts are passed, return the default styling + if (parts.length === 0) { + return { + family: '', + weight, + style, + originalInput + } + } + + // Create a copy of parts to work with + const remainingParts = [...parts] + + + // Step 1: Extract weight keywords + // Check for two-word weights first (e.g., "Semi Bold", "Extra Light") + for (let i = 0; i < remainingParts.length - 1; i++) { + const twoWordWeight = `${remainingParts[i]} ${remainingParts[i + 1]}`.toLowerCase() + const hyphenatedWeight = `${remainingParts[i]}-${remainingParts[i + 1]}`.toLowerCase() + + if (WEIGHT_MAP[twoWordWeight]) { + weight = WEIGHT_MAP[twoWordWeight] + remainingParts.splice(i, 2) + break + } else if (WEIGHT_MAP[hyphenatedWeight]) { + weight = WEIGHT_MAP[hyphenatedWeight] + remainingParts.splice(i, 2) + break + } + } + + // Check for single-word weights + const weightIndex = remainingParts.findIndex(part => + WEIGHT_MAP[part.toLowerCase()] + ) + if (weightIndex !== -1) { + weight = WEIGHT_MAP[remainingParts[weightIndex].toLowerCase()] + remainingParts.splice(weightIndex, 1) + } + + // Step 2: Extract numeric weights (e.g., "300", "700") - highest priority + const numericWeightIndex = remainingParts.findIndex(part => /^[1-9]00$/.test(part)) + if (numericWeightIndex !== -1) { + weight = remainingParts[numericWeightIndex] + remainingParts.splice(numericWeightIndex, 1) + } + + // Step 3: Extract style keywords + const styleIndex = remainingParts.findIndex(part => + STYLE_MAP[part.toLowerCase()] + ) + if (styleIndex !== -1) { + style = STYLE_MAP[remainingParts[styleIndex].toLowerCase()] + remainingParts.splice(styleIndex, 1) + } + + // Step 4: Whatever remains is the font family name + const family = remainingParts.join(' ') + + return { + family, + weight, + style, + originalInput + } + } + +/** + * Generate FontSource CDN URL for downloading font files + */ +export function generateFontSourceUrl(parsed: ParsedFont): string { + if (!parsed.family) { + throw new Error('Font family name is required') + } + + const familySlug = parsed.family.toLowerCase().replace(/\s+/g, '-') + + return `https://cdn.jsdelivr.net/fontsource/fonts/${familySlug}@latest/latin-${parsed.weight}-${parsed.style}.ttf` + } + + +/** + * Get display name for a font with its variations + */ +export function getDisplayName(parsed: ParsedFont): string { + if (parsed.weight === DEFAULT_WEIGHT && !parsed.style) { + return parsed.family + } + + const weightName = Object.keys(WEIGHT_MAP).find( + key => WEIGHT_MAP[key] === parsed.weight + ) || parsed.weight + + const parts = [parsed.family] + + if (parsed.weight !== DEFAULT_WEIGHT) { + parts.push(weightName.charAt(0).toUpperCase() + weightName.slice(1)) + } + + if (parsed.style !== DEFAULT_STYLE) { + parts.push(parsed.style.charAt(0).toUpperCase() + parsed.style.slice(1)) + } + + return parts.join(' ') +} + +/** + * Validate if a URL has a valid font file extension + */ +export function hasValidFontExtension(url: string): boolean { + return url.match(/\.(ttf|otf|woff2?|eot)$/i) !== null +} \ No newline at end of file diff --git a/src/pages/form-creator.html b/src/pages/form-creator.html index 3c0721e31..906d00d6d 100644 --- a/src/pages/form-creator.html +++ b/src/pages/form-creator.html @@ -175,6 +175,22 @@

+ + + diff --git a/src/tests/font-utils.test.ts b/src/tests/font-utils.test.ts new file mode 100644 index 000000000..9eed7e17e --- /dev/null +++ b/src/tests/font-utils.test.ts @@ -0,0 +1,361 @@ +import { describe, it, expect } from 'vitest'; +import { parseFont, generateFontSourceUrl, getDisplayName, hasValidFontExtension } from '../js/utils/font-utils'; + +describe('parseFont', () => { + it('should parse basic font family names', () => { + const result = parseFont('Open Sans'); + + expect(result).toEqual({ + family: 'Open Sans', + weight: '400', + style: 'normal', + originalInput: 'Open Sans' + }); + }); + + it('should parse single word font names', () => { + const result = parseFont('Roboto'); + + expect(result).toEqual({ + family: 'Roboto', + weight: '400', + style: 'normal', + originalInput: 'Roboto' + }); + }); + + it('should parse font with numeric weight', () => { + const result = parseFont('Open Sans 300'); + + expect(result).toEqual({ + family: 'Open Sans', + weight: '300', + style: 'normal', + originalInput: 'Open Sans 300' + }); + }); + + it('should parse font with word weight', () => { + const result = parseFont('Roboto Bold'); + + expect(result).toEqual({ + family: 'Roboto', + weight: '700', + style: 'normal', + originalInput: 'Roboto Bold' + }); + }); + + it('should parse font with italic style', () => { + const result = parseFont('Open Sans Italic'); + + expect(result).toEqual({ + family: 'Open Sans', + weight: '400', + style: 'italic', + originalInput: 'Open Sans Italic' + }); + }); + + it('should parse font with weight and italic', () => { + const result = parseFont('Roboto Bold Italic'); + + expect(result).toEqual({ + family: 'Roboto', + weight: '700', + style: 'italic', + originalInput: 'Roboto Bold Italic' + }); + }); + + it('should parse font with numeric weight and italic', () => { + const result = parseFont('Poppins 300 Italic'); + + expect(result).toEqual({ + family: 'Poppins', + weight: '300', + style: 'italic', + originalInput: 'Poppins 300 Italic' + }); + }); + + it('should parse two-word weights', () => { + const result = parseFont('Poppins Semi Bold'); + + expect(result).toEqual({ + family: 'Poppins', + weight: '600', + style: 'normal', + originalInput: 'Poppins Semi Bold' + }); + }); + + it('should parse hyphenated weights', () => { + const result = parseFont('Open Sans Extra-Light'); + + expect(result).toEqual({ + family: 'Open Sans', + weight: '200', + style: 'normal', + originalInput: 'Open Sans Extra-Light' + }); + }); + + it('should handle all weight variants', () => { + const testCases = [ + { input: 'Font Thin', expectedWeight: '100' }, + { input: 'Font ExtraLight', expectedWeight: '200' }, + { input: 'Font Extra-Light', expectedWeight: '200' }, + { input: 'Font UltraLight', expectedWeight: '200' }, + { input: 'Font Light', expectedWeight: '300' }, + { input: 'Font Normal', expectedWeight: '400' }, + { input: 'Font Regular', expectedWeight: '400' }, + { input: 'Font Medium', expectedWeight: '500' }, + { input: 'Font SemiBold', expectedWeight: '600' }, + { input: 'Font Semi-Bold', expectedWeight: '600' }, + { input: 'Font DemiBold', expectedWeight: '600' }, + { input: 'Font Bold', expectedWeight: '700' }, + { input: 'Font ExtraBold', expectedWeight: '800' }, + { input: 'Font UltraBold', expectedWeight: '800' }, + { input: 'Font Black', expectedWeight: '900' }, + { input: 'Font Heavy', expectedWeight: '900' } + ]; + + testCases.forEach(({ input, expectedWeight }) => { + const result = parseFont(input); + expect(result.weight).toBe(expectedWeight); + expect(result.family).toBe('Font'); + }); + }); + + it('should handle style variants', () => { + const testCases = [ + { input: 'Font Italic', expectedStyle: 'italic' }, + { input: 'Font Oblique', expectedStyle: 'italic' }, + { input: 'Font Slanted', expectedStyle: 'italic' } + ]; + + testCases.forEach(({ input, expectedStyle }) => { + const result = parseFont(input); + expect(result.style).toBe(expectedStyle); + expect(result.family).toBe('Font'); + }); + }); + + it('should handle case insensitivity for weights and styles', () => { + const result1 = parseFont('Roboto BOLD'); + + expect(result1.weight).toBe('700'); + + const result2 = parseFont('Open Sans italic'); + + expect(result2.style).toBe('italic'); + }); + + it('should handle complex multi-word font names', () => { + const result = parseFont('Source Sans Pro Light Italic'); + + expect(result).toEqual({ + family: 'Source Sans Pro', + weight: '300', + style: 'italic', + originalInput: 'Source Sans Pro Light Italic' + }); + }); + + it('should prioritize numeric weight over word weight', () => { + const result = parseFont('Font 500 Bold'); + + expect(result.weight).toBe('500'); + expect(result.family).toBe('Font'); + }); + + it('should handle edge cases', () => { + // Empty string + const empty = parseFont(''); + + expect(empty).toEqual({ + family: '', + weight: '400', + style: 'normal', + originalInput: '' + }); + + // Whitespace only + const whitespace = parseFont(' '); + + expect(whitespace).toEqual({ + family: '', + weight: '400', + style: 'normal', + originalInput: ' ' + }); + + // Single word with weight + const singleWord = parseFont('Bold'); + + expect(singleWord).toEqual({ + family: '', + weight: '700', + style: 'normal', + originalInput: 'Bold' + }); + }); + + it('should preserve original input', () => { + const input = ' Roboto Bold Italic '; + const result = parseFont(input); + + expect(result.originalInput).toBe(input); + }); + + it('should handle numeric weights at different positions', () => { + const result1 = parseFont('300 Open Sans'); + + expect(result1.weight).toBe('300'); + expect(result1.family).toBe('Open Sans'); + + const result2 = parseFont('Open 300 Sans'); + + expect(result2.weight).toBe('300'); + expect(result2.family).toBe('Open Sans'); + }); +}); + +describe('getDisplayName', () => { + it('should return just family name for normal 400 weight', () => { + const parsed = { family: 'Roboto', weight: '400', style: 'normal', originalInput: 'Roboto' }; + const displayName = getDisplayName(parsed); + + expect(displayName).toBe('Roboto'); + }); + + it('should include weight name for non-400 weights', () => { + const parsed = { family: 'Roboto', weight: '700', style: 'normal', originalInput: 'Roboto Bold' }; + const displayName = getDisplayName(parsed); + + expect(displayName).toBe('Roboto Bold'); + }); + + it('should include style for non-normal styles', () => { + const parsed = { family: 'Roboto', weight: '400', style: 'italic', originalInput: 'Roboto Italic' }; + const displayName = getDisplayName(parsed); + + expect(displayName).toBe('Roboto Italic'); + }); + + it('should include both weight and style', () => { + const parsed = { family: 'Open Sans', weight: '600', style: 'italic', originalInput: 'Open Sans SemiBold Italic' }; + const displayName = getDisplayName(parsed); + + expect(displayName).toBe('Open Sans Semibold Italic'); + }); + + it('should use numeric weight when no word equivalent exists', () => { + const parsed = { family: 'Font', weight: '450', style: 'normal', originalInput: 'Font 450' }; + const displayName = getDisplayName(parsed); + + expect(displayName).toBe('Font 450'); + }); + + it('should capitalize weight and style names', () => { + const parsed = { family: 'Font', weight: '300', style: 'italic', originalInput: 'Font Light Italic' }; + const displayName = getDisplayName(parsed); + + expect(displayName).toBe('Font Light Italic'); + }); +}); + +describe('generateFontSourceUrl', () => { + it('should generate basic URL for normal weight', () => { + const parsed = { family: 'Open Sans', weight: '400', style: 'normal', originalInput: 'Open Sans' }; + const url = generateFontSourceUrl(parsed); + + expect(url).toBe('https://cdn.jsdelivr.net/fontsource/fonts/open-sans@latest/latin-400-normal.ttf'); + }); + + it('should generate URL with custom weight', () => { + const parsed = { family: 'Roboto', weight: '700', style: 'normal', originalInput: 'Roboto Bold' }; + const url = generateFontSourceUrl(parsed); + + expect(url).toBe('https://cdn.jsdelivr.net/fontsource/fonts/roboto@latest/latin-700-normal.ttf'); + }); + + it('should generate URL with italic style', () => { + const parsed = { family: 'Open Sans', weight: '400', style: 'italic', originalInput: 'Open Sans Italic' }; + const url = generateFontSourceUrl(parsed); + + expect(url).toBe('https://cdn.jsdelivr.net/fontsource/fonts/open-sans@latest/latin-400-italic.ttf'); + }); + + it('should generate URL with custom weight and italic', () => { + const parsed = { family: 'Poppins', weight: '600', style: 'italic', originalInput: 'Poppins SemiBold Italic' }; + const url = generateFontSourceUrl(parsed); + + expect(url).toBe('https://cdn.jsdelivr.net/fontsource/fonts/poppins@latest/latin-600-italic.ttf'); + }); + + it('should handle font names with spaces', () => { + const parsed = { family: 'Source Sans Pro', weight: '300', style: 'normal', originalInput: 'Source Sans Pro Light' }; + const url = generateFontSourceUrl(parsed); + + expect(url).toBe('https://cdn.jsdelivr.net/fontsource/fonts/source-sans-pro@latest/latin-300-normal.ttf'); + }); + + it('should throw error for empty family name', () => { + const parsed = { family: '', weight: '400', style: 'normal', originalInput: '' }; + + expect(() => generateFontSourceUrl(parsed)).toThrow('Font family name is required'); + }); + + it('should handle special characters in font names', () => { + const parsed = { family: 'Font & Family', weight: '400', style: 'normal', originalInput: 'Font & Family' }; + const url = generateFontSourceUrl(parsed); + + expect(url).toBe('https://cdn.jsdelivr.net/fontsource/fonts/font-&-family@latest/latin-400-normal.ttf'); + }); +}); + +describe('hasValidFontExtension', () => { + it('should return true for valid font extensions', () => { + const validUrls = [ + 'https://example.com/font.ttf', + 'https://example.com/font.otf', + 'https://example.com/font.woff', + 'https://example.com/font.woff2', + 'https://example.com/font.eot', + 'https://example.com/path/to/font.TTF', + 'https://example.com/font.WOFF2' + ]; + + validUrls.forEach(url => { + expect(hasValidFontExtension(url)).toBe(true); + }); + }); + + it('should return false for invalid font extensions', () => { + const invalidUrls = [ + 'https://example.com/font.css', + 'https://example.com/font.js', + 'https://example.com/font.html', + 'https://example.com/font.json', + 'https://example.com/font.txt', + 'https://example.com/font', + 'https://fonts.googleapis.com/css2?family=Roboto' + ]; + + invalidUrls.forEach(url => { + expect(hasValidFontExtension(url)).toBe(false); + }); + }); + + it('should handle URLs with query parameters', () => { + expect(hasValidFontExtension('https://example.com/font.woff2?version=1.0')).toBe(false); + expect(hasValidFontExtension('https://example.com/font.woff2')).toBe(true); + }); + + it('should handle URLs with fragments', () => { + expect(hasValidFontExtension('https://example.com/font.ttf#section')).toBe(false); + expect(hasValidFontExtension('https://example.com/font.ttf')).toBe(true); + }); +});