@liiift-studio/sanity-font-manager 2.7.0 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +695 -437
  3. package/dist/index.js +2330 -7084
  4. package/dist/index.mjs +2331 -7085
  5. package/package.json +84 -83
  6. package/src/components/BatchUploadFonts.jsx +655 -655
  7. package/src/components/BulkActions.jsx +99 -99
  8. package/src/components/ExistingDocumentResolver.jsx +152 -152
  9. package/src/components/FontReviewCard.jsx +455 -455
  10. package/src/components/FontScriptUploaderComponent.jsx +463 -463
  11. package/src/components/GenerateCollectionsPairsComponent.jsx +259 -259
  12. package/src/components/KeyValueInput.jsx +95 -95
  13. package/src/components/KeyValueReferenceInput.jsx +267 -267
  14. package/src/components/NestedObjectArraySelector.jsx +146 -146
  15. package/src/components/PriceInput.jsx +26 -26
  16. package/src/components/PrimaryCollectionGeneratorTypeface.jsx +116 -116
  17. package/src/components/RegenerateSubfamiliesComponent.jsx +185 -185
  18. package/src/components/SetOTF.jsx +87 -87
  19. package/src/components/SingleUploaderTool.jsx +674 -674
  20. package/src/components/StatusDisplay.jsx +26 -26
  21. package/src/components/StyleCountInput.jsx +16 -16
  22. package/src/components/UpdateScriptsComponent.jsx +76 -76
  23. package/src/components/UploadButton.jsx +43 -43
  24. package/src/components/UploadModal.jsx +309 -309
  25. package/src/components/UploadScriptsComponent.jsx +539 -539
  26. package/src/components/UploadStep1Settings.jsx +272 -272
  27. package/src/components/UploadStep2Review.jsx +478 -478
  28. package/src/components/UploadStep3Execute.jsx +234 -234
  29. package/src/components/UploadStep3bInstances.jsx +396 -396
  30. package/src/components/UploadSummary.jsx +196 -196
  31. package/src/components/VariableInstanceReferencesInput.jsx +190 -190
  32. package/src/hooks/useNestedObjects.js +92 -92
  33. package/src/hooks/useSanityClient.js +9 -9
  34. package/src/index.js +120 -120
  35. package/src/schema/openTypeField.js +1995 -1995
  36. package/src/schema/styleCountField.js +12 -12
  37. package/src/schema/stylesField.js +302 -302
  38. package/src/schema/stylisticSetField.js +301 -301
  39. package/src/utils/buildUploadPlan.js +326 -326
  40. package/src/utils/executeUploadPlan.js +430 -430
  41. package/src/utils/executionReducer.js +56 -56
  42. package/src/utils/fontHelpers.js +281 -281
  43. package/src/utils/generateCssFile.js +207 -207
  44. package/src/utils/generateFontData.js +98 -98
  45. package/src/utils/generateFontFile.js +38 -38
  46. package/src/utils/generateKeywords.js +185 -185
  47. package/src/utils/generateSubset.js +45 -45
  48. package/src/utils/getEmptyFontKit.js +101 -101
  49. package/src/utils/parseFont.js +56 -56
  50. package/src/utils/parseVariableFontInstances.js +301 -301
  51. package/src/utils/planReducer.js +531 -531
  52. package/src/utils/planTypes.js +183 -183
  53. package/src/utils/processFontFiles.js +530 -530
  54. package/src/utils/regenerateFontData.js +146 -146
  55. package/src/utils/resolveExistingFont.js +87 -87
  56. package/src/utils/retitleFontEntries.js +154 -154
  57. package/src/utils/sanitizeForSanityId.js +65 -65
  58. package/src/utils/setupDecompressors.js +27 -27
  59. package/src/utils/updateFontPrices.js +94 -94
  60. package/src/utils/updateTypefaceDocument.js +162 -162
  61. package/src/utils/uploadFontFiles.js +405 -405
  62. package/src/utils/utils.js +24 -24
@@ -1,530 +1,530 @@
1
- // Reads font files via FileReader, parses with lib-font, and builds the fontsObjects map — exports individual weight/style extraction helpers
2
-
3
- import { parseFont } from './parseFont';
4
- import { getNameString, getVariationAxes, getItalicAngle, getWeightClass } from './fontHelpers';
5
- import { nanoid } from 'nanoid';
6
- import { expandAbbreviations, removeWeightNames, reverseSpellingLookup } from './generateKeywords';
7
- import { sanitizeForSanityId } from './sanitizeForSanityId';
8
-
9
- /**
10
- * Reads a font file and returns its content as an ArrayBuffer.
11
- * @param {File} file
12
- * @returns {Promise<ArrayBuffer>}
13
- */
14
- export const readFontFile = (file) => {
15
- return new Promise((resolve, reject) => {
16
- const reader = new FileReader();
17
- reader.onload = (event) => { resolve(event.target.result); };
18
- reader.onerror = (error) => { reject(error); };
19
- reader.readAsArrayBuffer(file);
20
- });
21
- };
22
-
23
- /**
24
- * Processes an array of font files and extracts metadata for each.
25
- * @param {File[]} files
26
- * @param {string} title - Typeface title
27
- * @param {string[]} weightKeywordList
28
- * @param {string[]} italicKeywordList
29
- * @param {Function} setStatus
30
- * @param {boolean} preserveShortenedNames - Skip abbreviation expansion when true
31
- * @param {boolean} preserveFileNames - Preserve original filename capitalization when true
32
- * @returns {Promise<Object>}
33
- */
34
- export const processFontFiles = async (
35
- files,
36
- title,
37
- weightKeywordList,
38
- italicKeywordList,
39
- setStatus,
40
- preserveShortenedNames = false,
41
- preserveFileNames = false,
42
- ) => {
43
- let failedFiles = [];
44
- let subfamilies = {};
45
- let fontsObjects = {};
46
- let newPreferredStyle = { weight: -100, style: 'Italic', _ref: '' };
47
-
48
- for (let i = 0; i < files.length; i++) {
49
- const file = files[i];
50
- const fontBuffer = await readFontFile(file);
51
- const font = await parseFont(fontBuffer, file.name);
52
-
53
- console.log('File name:', file.name);
54
-
55
- // For webfonts with missing metadata, try to extract from TTF companion
56
- const ttfFallbackMeta = await getWebfontFallbackMetadata(file, font, files);
57
-
58
- let { weightName, subfamilyName, fontTitle, style, italicKW, variableFont } = extractFontMetadata(
59
- font,
60
- title,
61
- weightKeywordList,
62
- italicKeywordList,
63
- preserveShortenedNames,
64
- ttfFallbackMeta,
65
- );
66
-
67
- let id;
68
- let originalFilename = null;
69
-
70
- if (preserveFileNames) {
71
- originalFilename = file.name.replace(/\.(ttf|otf|woff2?|eot|svg)$/i, '');
72
- // Normalize filename: hyphens to spaces, split camelCase boundaries, collapse whitespace
73
- const normalizedName = originalFilename
74
- .replace(/-/g, ' ')
75
- .replace(/([a-z])([A-Z])/g, '$1 $2')
76
- .replace(/\s+/g, ' ')
77
- .trim();
78
- fontTitle = normalizedName;
79
- id = sanitizeForSanityId(normalizedName);
80
- } else {
81
- id = sanitizeForSanityId(fontTitle);
82
- }
83
-
84
- logFontInfo(id, fontTitle, font, file.name, subfamilyName, style, weightName, variableFont, italicKW);
85
-
86
- subfamilies[id] = subfamilyName;
87
-
88
- if (fontsObjects[id]) {
89
- fontsObjects[id].files = [...fontsObjects[id].files, file];
90
- if (preserveFileNames && originalFilename) {
91
- fontsObjects[id].originalFilename = originalFilename;
92
- }
93
- } else {
94
- fontsObjects[id] = createFontObject(
95
- id,
96
- fontTitle,
97
- title,
98
- font,
99
- variableFont,
100
- weightName,
101
- subfamilyName,
102
- file,
103
- preserveFileNames ? originalFilename : null,
104
- );
105
- }
106
- }
107
-
108
- fontsObjects = sortFontObjects(fontsObjects);
109
- const uniqueSubfamilies = [...new Set(Object.values(subfamilies))];
110
-
111
- console.log('Subfamilies:', subfamilies);
112
- console.log('Unique subfamilies:', uniqueSubfamilies, uniqueSubfamilies.length);
113
- console.log('Font objects:', Object.keys(fontsObjects));
114
-
115
- return { fontsObjects, subfamilies, uniqueSubfamilies, newPreferredStyle, failedFiles };
116
- };
117
-
118
- /**
119
- * Gets fallback metadata from a matching TTF when woff/woff2 metadata is missing.
120
- * Returns null if no fallback is needed or no TTF companion exists.
121
- * Unlike the old fontkit approach, this does NOT mutate the font object.
122
- * @param {File} file
123
- * @param {object} font - lib-font parsed font
124
- * @param {File[]} files
125
- * @returns {Promise<{ fullName: string, familyName: string, subfamilyName: string, preferredSubfamily: string }|null>}
126
- */
127
- const getWebfontFallbackMetadata = async (file, font, files) => {
128
- if (!file.name.endsWith('.woff2') && !file.name.endsWith('.woff')) return null;
129
-
130
- const fullName = getNameString(font, 4);
131
- // Check if name table is missing or corrupt (empty, or only uppercase hex-like garbage)
132
- if (fullName && fullName !== '' && !/^[A-Z0-9]+$/.test(fullName)) return null;
133
-
134
- const ttfFile = files.find(f => f.name === file.name.replace('.woff2', '.ttf').replace('.woff', '.ttf'));
135
- if (!ttfFile) return null;
136
-
137
- try {
138
- const ttfBuffer = await readFontFile(ttfFile);
139
- const ttfFont = await parseFont(ttfBuffer, ttfFile.name);
140
- return {
141
- fullName: getNameString(ttfFont, 4),
142
- familyName: getNameString(ttfFont, 1),
143
- subfamilyName: getNameString(ttfFont, 2),
144
- preferredSubfamily: getNameString(ttfFont, 17),
145
- preferredFamily: getNameString(ttfFont, 16),
146
- };
147
- } catch (err) {
148
- console.warn('Could not parse TTF companion for webfont fallback:', err.message);
149
- return null;
150
- }
151
- };
152
-
153
- /**
154
- * Extracts and normalises metadata from a lib-font parsed font object.
155
- * @param {object} font - lib-font parsed font
156
- * @param {string} title - Typeface title
157
- * @param {string[]} weightKeywordList
158
- * @param {string[]} italicKeywordList
159
- * @param {boolean} preserveShortenedNames
160
- * @param {object|null} ttfFallbackMeta - Fallback metadata from TTF companion (for webfonts with missing names)
161
- * @returns {Object}
162
- */
163
- export const extractFontMetadata = (font, title, weightKeywordList, italicKeywordList, preserveShortenedNames = false, ttfFallbackMeta = null) => {
164
- let weightName = extractWeightName(font, italicKeywordList, ttfFallbackMeta);
165
- if (!preserveShortenedNames) {
166
- weightName = expandAbbreviations(weightName);
167
- }
168
-
169
- const fullName = getNameString(font, 4) || ttfFallbackMeta?.fullName || '';
170
-
171
- const axes = getVariationAxes(font);
172
- const variableFont = axes !== null;
173
-
174
- // For non-VF fonts, fall back to extracting weight from fullName when weightName is empty
175
- if (!variableFont && (weightName === '' || weightName.toLowerCase() === 'roman') && fullName) {
176
- weightName = extractWeightFromFullName(font, title, ttfFallbackMeta);
177
- if (!preserveShortenedNames) {
178
- weightName = expandAbbreviations(weightName);
179
- }
180
- }
181
-
182
- // Subfamily detection — extract width/optical variant from name table.
183
- // Primary: nameId4 (fullName) minus typeface title — the most complete name record,
184
- // always contains width + weight (e.g. "Gear XXNarrow Regular" → "XXNarrow Regular").
185
- // Fallback: nameId1 (familyName) minus typeface title — contains width but not always weight.
186
- // processSubfamilyName then strips weight/italic keywords, leaving just the width variant.
187
- // This matches the production logic that has been reliable across all foundry sites.
188
- const trimmedTitle = title.trim();
189
-
190
- const nameId4Remainder = fullName ? fullName.replace(trimmedTitle, '').trim() : '';
191
- const nameId1 = getNameString(font, 1) || ttfFallbackMeta?.familyName || '';
192
- const nameId1Remainder = nameId1 ? nameId1.replace(trimmedTitle, '').trim() : '';
193
-
194
- let subfamilyName = nameId4Remainder || nameId1Remainder;
195
-
196
- if (!preserveShortenedNames) {
197
- subfamilyName = expandAbbreviations(subfamilyName);
198
- }
199
-
200
- let fontTitle = fullName.trim() || '';
201
- const italicAngle = getItalicAngle(font);
202
- let style = (italicAngle !== 0 || fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular';
203
-
204
- const italicKW = processItalicKeywords(font, fontTitle, italicKeywordList);
205
-
206
- subfamilyName = processSubfamilyName(subfamilyName, weightKeywordList, italicKW, preserveShortenedNames);
207
- fontTitle = formatFontTitle(fontTitle, preserveShortenedNames);
208
-
209
- // Style-only names are not subfamilies — strip them
210
- subfamilyName = subfamilyName
211
- .replace(/\b(Italic|Slant|Slanted|Oblique|Backslant|Roman|Upright)\b/gi, '')
212
- .replace(/\s+/g, ' ')
213
- .trim();
214
-
215
- if (subfamilyName !== '') {
216
- weightName = weightName
217
- .replace(`${subfamilyName} `, '')
218
- .replace(` ${subfamilyName}`, '')
219
- .trim();
220
- }
221
-
222
- if (variableFont) {
223
- if (!fontTitle.toLowerCase().includes('vf')) {
224
- fontTitle = fontTitle + ' VF';
225
- }
226
- // Variable fonts are not placed in subfamilies — they go in the separate variableFont array
227
- subfamilyName = '';
228
- }
229
-
230
- if (!(variableFont && fontTitle.toLowerCase().includes('italic'))) {
231
- fontTitle = addItalicToFontTitle(font, fontTitle, italicKW, style, preserveShortenedNames);
232
- }
233
-
234
- return { weightName, subfamilyName, fontTitle, style, italicKW, variableFont };
235
- };
236
-
237
- /**
238
- * Extracts the weight name from a font's preferred subfamily or subfamily record.
239
- * Returns "Variable" for variable fonts.
240
- * @param {object} font - lib-font parsed font
241
- * @param {string[]} italicKW
242
- * @param {object|null} ttfFallbackMeta
243
- * @returns {string}
244
- */
245
- export const extractWeightName = (font, italicKW, ttfFallbackMeta = null) => {
246
- let weightName = getNameString(font, 17) || getNameString(font, 2) ||
247
- ttfFallbackMeta?.preferredSubfamily || ttfFallbackMeta?.subfamilyName || '';
248
-
249
- const axes = getVariationAxes(font);
250
- if (axes !== null) {
251
- return '';
252
- }
253
-
254
- if (italicKW) {
255
- italicKW.forEach(keyword => {
256
- const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
257
- if (kwRegex.test(weightName)) {
258
- weightName = weightName.replace(kwRegex, '').trim();
259
- }
260
- });
261
- }
262
-
263
- return weightName?.toString()
264
- .replace('Italic', '')
265
- .replace('It', '')
266
- .replace('Slanted', '')
267
- .replace('Slant', '')
268
- .replace('Backslant', '')
269
- .trim();
270
- };
271
-
272
- /**
273
- * Extracts a weight name from the font's full name record when subfamily is empty or "Roman".
274
- * @param {object} font - lib-font parsed font
275
- * @param {string} title
276
- * @param {object|null} ttfFallbackMeta
277
- * @returns {string}
278
- */
279
- export const extractWeightFromFullName = (font, title, ttfFallbackMeta = null) => {
280
- let weightName = getNameString(font, 4) || ttfFallbackMeta?.fullName || '';
281
- weightName = weightName.replace(title + ' ', '').replace(title, '').trim();
282
- weightName = weightName.replace('Italic', '').replace('It', '').replace('Slanted', '').replace('Slant', '').trim();
283
- return weightName;
284
- };
285
-
286
- /**
287
- * Strips weight and italic keywords from a subfamily name string.
288
- * @param {string} subfamilyName
289
- * @param {string[]} weightKeywordList
290
- * @param {string[]} italicKeywordList
291
- * @param {boolean} preserveShortenedNames
292
- * @returns {string}
293
- */
294
- export const processSubfamilyName = (subfamilyName, weightKeywordList, italicKeywordList, preserveShortenedNames = false) => {
295
- weightKeywordList.forEach(keyword => {
296
- const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
297
- if (kwRegex.test(subfamilyName)) {
298
- subfamilyName = subfamilyName.replace(kwRegex, '').trim();
299
- }
300
- subfamilyName = removeWeightNames(subfamilyName) || subfamilyName;
301
- if (!preserveShortenedNames) {
302
- subfamilyName = expandAbbreviations(subfamilyName);
303
- }
304
- });
305
-
306
- italicKeywordList.forEach(keyword => {
307
- const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
308
- if (kwRegex.test(subfamilyName)) {
309
- subfamilyName = subfamilyName.replace(kwRegex, '').trim();
310
- }
311
- });
312
-
313
- return subfamilyName;
314
- };
315
-
316
- /**
317
- * Collects italic keywords present in a font's full name.
318
- * @param {object} font - lib-font parsed font
319
- * @param {string} fontTitle
320
- * @param {string[]} italicKeywordList
321
- * @returns {string[]}
322
- */
323
- export const processItalicKeywords = (font, fontTitle, italicKeywordList) => {
324
- let italicKW = [];
325
- const fullName = getNameString(font, 4);
326
-
327
- italicKeywordList.forEach(keyword => {
328
- const kw = keyword.trim();
329
- const kwRegex = new RegExp(`\\b${kw}\\b`, 'i');
330
- if (kwRegex.test(fontTitle)) {
331
- fontTitle = fontTitle.replace(kwRegex, '').trim();
332
- italicKW.push(kw);
333
- }
334
- if (fullName && fullName.toLowerCase().includes(kw.toLowerCase())) {
335
- if (!italicKW.includes(kw)) italicKW.push(kw);
336
- }
337
- });
338
-
339
- return italicKW;
340
- };
341
-
342
- /**
343
- * Normalises and title-cases a font title, optionally expanding abbreviations.
344
- * @param {string} fontTitle
345
- * @param {boolean} preserveShortenedNames
346
- * @returns {string}
347
- */
348
- export const formatFontTitle = (fontTitle, preserveShortenedNames = false) => {
349
- const hasItalic = fontTitle.toLowerCase().includes('italic');
350
- fontTitle = fontTitle.replace(/-/g, ' ');
351
-
352
- return fontTitle.replace(/\s+/g, ' ').trim().split(' ').map(word => {
353
- if (hasItalic && word.toLowerCase() === 'italic') return 'Italic';
354
- let fullWord = word;
355
- if (!preserveShortenedNames) {
356
- fullWord = reverseSpellingLookup(word) || word;
357
- }
358
- return fullWord[0].toUpperCase() + fullWord.slice(1);
359
- }).join(' ');
360
- };
361
-
362
- /**
363
- * Appends any italic keywords to the font title that aren't already present.
364
- * @param {object} font - lib-font parsed font
365
- * @param {string} fontTitle
366
- * @param {string[]} italicKW
367
- * @param {string} style
368
- * @param {boolean} preserveShortenedNames
369
- * @returns {string}
370
- */
371
- export const addItalicToFontTitle = (font, fontTitle, italicKW, style, preserveShortenedNames = false) => {
372
- const hasItalicAngle = getItalicAngle(font) !== 0;
373
- const fullName = getNameString(font, 4);
374
- const hasItalicInName = fullName.toLowerCase().includes('italic');
375
-
376
- if (italicKW.length > 0 || hasItalicAngle || hasItalicInName) {
377
- italicKW = [...new Set(italicKW)];
378
-
379
- if (italicKW.length === 0 && (hasItalicAngle || hasItalicInName)) {
380
- italicKW = ['Italic'];
381
- }
382
-
383
- if (!preserveShortenedNames) {
384
- italicKW = italicKW.map(item => reverseSpellingLookup(item) || item);
385
- }
386
-
387
- italicKW = [...new Set(italicKW)];
388
-
389
- if (italicKW.length > 1 && italicKW.includes('Italic')) {
390
- italicKW = ['Italic'];
391
- }
392
-
393
- const fontTitleLower = fontTitle.toLowerCase();
394
- italicKW = italicKW.filter(keyword => {
395
- const keywordLower = keyword.toLowerCase();
396
- const kwRegex = new RegExp(`\\b${keywordLower}\\b`);
397
- const isSubstring = fontTitleLower.split(' ').some(word =>
398
- word.includes(keywordLower) || keywordLower.includes(word)
399
- );
400
- return !kwRegex.test(fontTitleLower) && !isSubstring;
401
- });
402
-
403
- if (italicKW.length > 0) {
404
- fontTitle = fontTitle.trim() + ' ' + italicKW.join(' ');
405
- }
406
- }
407
-
408
- return fontTitle;
409
- };
410
-
411
- /**
412
- * Builds a font object ready for staging and upload.
413
- * originalFilename is stored temporarily and deleted before saving to Sanity.
414
- * @param {string} id
415
- * @param {string} fontTitle
416
- * @param {string} title
417
- * @param {object} font - lib-font parsed font
418
- * @param {boolean} variableFont
419
- * @param {string} weightName
420
- * @param {string} subfamilyName
421
- * @param {File} file
422
- * @param {string|null} originalFilename
423
- * @returns {Object}
424
- */
425
- export const createFontObject = (id, fontTitle, title, font, variableFont, weightName, subfamilyName, file, originalFilename = null) => {
426
- const italicAngle = getItalicAngle(font);
427
- const fullName = getNameString(font, 4);
428
-
429
- const fontObject = {
430
- _key: nanoid(),
431
- _id: id,
432
- title: fontTitle,
433
- slug: { _type: 'slug', current: id },
434
- typefaceName: title,
435
- style: (italicAngle !== 0 || fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular',
436
- variableFont: variableFont,
437
- weightName: weightName,
438
- subfamily: subfamilyName,
439
- normalWeight: true,
440
- weight: Number(determineWeight(font, weightName)),
441
- fileInput: {},
442
- files: [file],
443
- fontKit: font,
444
- };
445
-
446
- if (originalFilename) {
447
- fontObject.originalFilename = originalFilename;
448
- }
449
-
450
- return fontObject;
451
- };
452
-
453
- /**
454
- * Determines a numeric CSS weight value for a font.
455
- * @param {object} font - lib-font parsed font
456
- * @param {string} weightName
457
- * @returns {number}
458
- */
459
- export const determineWeight = (font, weightName) => {
460
- const usWeightClass = getWeightClass(font);
461
- if (usWeightClass) {
462
- return Number(usWeightClass);
463
- }
464
-
465
- const wn = weightName?.toLowerCase() || '';
466
-
467
- if (/hairline|extra thin|extrathin/.test(wn)) return 100;
468
- if (/thin|extra light|extralight/.test(wn)) return 200;
469
- if (/light|book/.test(wn)) return 300;
470
- if (/regular|normal/.test(wn)) return 400;
471
- if (/medium/.test(wn)) return 500;
472
- if (/semi bold|semibold/.test(wn)) return 600;
473
- if (/extra bold|extrabold/.test(wn)) return 800;
474
- if (/bold/.test(wn)) return 700;
475
- if (/black|ultra/.test(wn)) return 900;
476
-
477
- return 400;
478
- };
479
-
480
- /**
481
- * Sorts a map of font objects by ascending weight, with Regular before Italic at equal weights.
482
- * @param {Object} fontsObjects
483
- * @returns {Object}
484
- */
485
- export const sortFontObjects = (fontsObjects) => {
486
- return Object.fromEntries(
487
- Object.entries(fontsObjects).sort((a, b) => {
488
- const weightA = Number(a[1].weight);
489
- const weightB = Number(b[1].weight);
490
- if (weightA === weightB) {
491
- if (a[1].style === 'Regular' && b[1].style === 'Italic') return -1;
492
- if (a[1].style === 'Italic' && b[1].style === 'Regular') return 1;
493
- return 0;
494
- }
495
- return weightA - weightB;
496
- })
497
- );
498
- };
499
-
500
- /**
501
- * Logs font metadata to the console for debugging.
502
- * @param {string} id
503
- * @param {string} fontTitle
504
- * @param {object} font - lib-font parsed font
505
- * @param {string} fileName
506
- * @param {string} subfamilyName
507
- * @param {string} style
508
- * @param {string} weightName
509
- * @param {boolean} variableFont
510
- * @param {string[]} italicKW
511
- */
512
- export const logFontInfo = (id, fontTitle, font, fileName, subfamilyName, style, weightName, variableFont, italicKW) => {
513
- const fullName = getNameString(font, 4);
514
- const familyName = getNameString(font, 1);
515
- const italicAngle = getItalicAngle(font);
516
-
517
- console.log('=== Font Info ====');
518
- console.log('Font id:', id);
519
- console.log('Font title:', fontTitle);
520
- console.log('Full name:', fullName);
521
- console.log('Family name:', familyName);
522
- console.log('File name:', fileName);
523
- console.log('Subfamily:', subfamilyName);
524
- console.log('Style:', style);
525
- console.log('Weight:', weightName);
526
- console.log('Variable:', variableFont);
527
- console.log('ItalicKW:', italicKW);
528
- console.log('Italic detection:', (italicAngle !== 0 || fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular');
529
- console.log('=======');
530
- };
1
+ // Reads font files via FileReader, parses with lib-font, and builds the fontsObjects map — exports individual weight/style extraction helpers
2
+
3
+ import { parseFont } from './parseFont';
4
+ import { getNameString, getVariationAxes, getItalicAngle, getWeightClass } from './fontHelpers';
5
+ import { nanoid } from 'nanoid';
6
+ import { expandAbbreviations, removeWeightNames, reverseSpellingLookup } from './generateKeywords';
7
+ import { sanitizeForSanityId } from './sanitizeForSanityId';
8
+
9
+ /**
10
+ * Reads a font file and returns its content as an ArrayBuffer.
11
+ * @param {File} file
12
+ * @returns {Promise<ArrayBuffer>}
13
+ */
14
+ export const readFontFile = (file) => {
15
+ return new Promise((resolve, reject) => {
16
+ const reader = new FileReader();
17
+ reader.onload = (event) => { resolve(event.target.result); };
18
+ reader.onerror = (error) => { reject(error); };
19
+ reader.readAsArrayBuffer(file);
20
+ });
21
+ };
22
+
23
+ /**
24
+ * Processes an array of font files and extracts metadata for each.
25
+ * @param {File[]} files
26
+ * @param {string} title - Typeface title
27
+ * @param {string[]} weightKeywordList
28
+ * @param {string[]} italicKeywordList
29
+ * @param {Function} setStatus
30
+ * @param {boolean} preserveShortenedNames - Skip abbreviation expansion when true
31
+ * @param {boolean} preserveFileNames - Preserve original filename capitalization when true
32
+ * @returns {Promise<Object>}
33
+ */
34
+ export const processFontFiles = async (
35
+ files,
36
+ title,
37
+ weightKeywordList,
38
+ italicKeywordList,
39
+ setStatus,
40
+ preserveShortenedNames = false,
41
+ preserveFileNames = false,
42
+ ) => {
43
+ let failedFiles = [];
44
+ let subfamilies = {};
45
+ let fontsObjects = {};
46
+ let newPreferredStyle = { weight: -100, style: 'Italic', _ref: '' };
47
+
48
+ for (let i = 0; i < files.length; i++) {
49
+ const file = files[i];
50
+ const fontBuffer = await readFontFile(file);
51
+ const font = await parseFont(fontBuffer, file.name);
52
+
53
+ console.log('File name:', file.name);
54
+
55
+ // For webfonts with missing metadata, try to extract from TTF companion
56
+ const ttfFallbackMeta = await getWebfontFallbackMetadata(file, font, files);
57
+
58
+ let { weightName, subfamilyName, fontTitle, style, italicKW, variableFont } = extractFontMetadata(
59
+ font,
60
+ title,
61
+ weightKeywordList,
62
+ italicKeywordList,
63
+ preserveShortenedNames,
64
+ ttfFallbackMeta,
65
+ );
66
+
67
+ let id;
68
+ let originalFilename = null;
69
+
70
+ if (preserveFileNames) {
71
+ originalFilename = file.name.replace(/\.(ttf|otf|woff2?|eot|svg)$/i, '');
72
+ // Normalize filename: hyphens to spaces, split camelCase boundaries, collapse whitespace
73
+ const normalizedName = originalFilename
74
+ .replace(/-/g, ' ')
75
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
76
+ .replace(/\s+/g, ' ')
77
+ .trim();
78
+ fontTitle = normalizedName;
79
+ id = sanitizeForSanityId(normalizedName);
80
+ } else {
81
+ id = sanitizeForSanityId(fontTitle);
82
+ }
83
+
84
+ logFontInfo(id, fontTitle, font, file.name, subfamilyName, style, weightName, variableFont, italicKW);
85
+
86
+ subfamilies[id] = subfamilyName;
87
+
88
+ if (fontsObjects[id]) {
89
+ fontsObjects[id].files = [...fontsObjects[id].files, file];
90
+ if (preserveFileNames && originalFilename) {
91
+ fontsObjects[id].originalFilename = originalFilename;
92
+ }
93
+ } else {
94
+ fontsObjects[id] = createFontObject(
95
+ id,
96
+ fontTitle,
97
+ title,
98
+ font,
99
+ variableFont,
100
+ weightName,
101
+ subfamilyName,
102
+ file,
103
+ preserveFileNames ? originalFilename : null,
104
+ );
105
+ }
106
+ }
107
+
108
+ fontsObjects = sortFontObjects(fontsObjects);
109
+ const uniqueSubfamilies = [...new Set(Object.values(subfamilies))];
110
+
111
+ console.log('Subfamilies:', subfamilies);
112
+ console.log('Unique subfamilies:', uniqueSubfamilies, uniqueSubfamilies.length);
113
+ console.log('Font objects:', Object.keys(fontsObjects));
114
+
115
+ return { fontsObjects, subfamilies, uniqueSubfamilies, newPreferredStyle, failedFiles };
116
+ };
117
+
118
+ /**
119
+ * Gets fallback metadata from a matching TTF when woff/woff2 metadata is missing.
120
+ * Returns null if no fallback is needed or no TTF companion exists.
121
+ * Unlike the old fontkit approach, this does NOT mutate the font object.
122
+ * @param {File} file
123
+ * @param {object} font - lib-font parsed font
124
+ * @param {File[]} files
125
+ * @returns {Promise<{ fullName: string, familyName: string, subfamilyName: string, preferredSubfamily: string }|null>}
126
+ */
127
+ const getWebfontFallbackMetadata = async (file, font, files) => {
128
+ if (!file.name.endsWith('.woff2') && !file.name.endsWith('.woff')) return null;
129
+
130
+ const fullName = getNameString(font, 4);
131
+ // Check if name table is missing or corrupt (empty, or only uppercase hex-like garbage)
132
+ if (fullName && fullName !== '' && !/^[A-Z0-9]+$/.test(fullName)) return null;
133
+
134
+ const ttfFile = files.find(f => f.name === file.name.replace('.woff2', '.ttf').replace('.woff', '.ttf'));
135
+ if (!ttfFile) return null;
136
+
137
+ try {
138
+ const ttfBuffer = await readFontFile(ttfFile);
139
+ const ttfFont = await parseFont(ttfBuffer, ttfFile.name);
140
+ return {
141
+ fullName: getNameString(ttfFont, 4),
142
+ familyName: getNameString(ttfFont, 1),
143
+ subfamilyName: getNameString(ttfFont, 2),
144
+ preferredSubfamily: getNameString(ttfFont, 17),
145
+ preferredFamily: getNameString(ttfFont, 16),
146
+ };
147
+ } catch (err) {
148
+ console.warn('Could not parse TTF companion for webfont fallback:', err.message);
149
+ return null;
150
+ }
151
+ };
152
+
153
+ /**
154
+ * Extracts and normalises metadata from a lib-font parsed font object.
155
+ * @param {object} font - lib-font parsed font
156
+ * @param {string} title - Typeface title
157
+ * @param {string[]} weightKeywordList
158
+ * @param {string[]} italicKeywordList
159
+ * @param {boolean} preserveShortenedNames
160
+ * @param {object|null} ttfFallbackMeta - Fallback metadata from TTF companion (for webfonts with missing names)
161
+ * @returns {Object}
162
+ */
163
+ export const extractFontMetadata = (font, title, weightKeywordList, italicKeywordList, preserveShortenedNames = false, ttfFallbackMeta = null) => {
164
+ let weightName = extractWeightName(font, italicKeywordList, ttfFallbackMeta);
165
+ if (!preserveShortenedNames) {
166
+ weightName = expandAbbreviations(weightName);
167
+ }
168
+
169
+ const fullName = getNameString(font, 4) || ttfFallbackMeta?.fullName || '';
170
+
171
+ const axes = getVariationAxes(font);
172
+ const variableFont = axes !== null;
173
+
174
+ // For non-VF fonts, fall back to extracting weight from fullName when weightName is empty
175
+ if (!variableFont && (weightName === '' || weightName.toLowerCase() === 'roman') && fullName) {
176
+ weightName = extractWeightFromFullName(font, title, ttfFallbackMeta);
177
+ if (!preserveShortenedNames) {
178
+ weightName = expandAbbreviations(weightName);
179
+ }
180
+ }
181
+
182
+ // Subfamily detection — extract width/optical variant from name table.
183
+ // Primary: nameId4 (fullName) minus typeface title — the most complete name record,
184
+ // always contains width + weight (e.g. "Gear XXNarrow Regular" → "XXNarrow Regular").
185
+ // Fallback: nameId1 (familyName) minus typeface title — contains width but not always weight.
186
+ // processSubfamilyName then strips weight/italic keywords, leaving just the width variant.
187
+ // This matches the production logic that has been reliable across all foundry sites.
188
+ const trimmedTitle = title.trim();
189
+
190
+ const nameId4Remainder = fullName ? fullName.replace(trimmedTitle, '').trim() : '';
191
+ const nameId1 = getNameString(font, 1) || ttfFallbackMeta?.familyName || '';
192
+ const nameId1Remainder = nameId1 ? nameId1.replace(trimmedTitle, '').trim() : '';
193
+
194
+ let subfamilyName = nameId4Remainder || nameId1Remainder;
195
+
196
+ if (!preserveShortenedNames) {
197
+ subfamilyName = expandAbbreviations(subfamilyName);
198
+ }
199
+
200
+ let fontTitle = fullName.trim() || '';
201
+ const italicAngle = getItalicAngle(font);
202
+ let style = (italicAngle !== 0 || fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular';
203
+
204
+ const italicKW = processItalicKeywords(font, fontTitle, italicKeywordList);
205
+
206
+ subfamilyName = processSubfamilyName(subfamilyName, weightKeywordList, italicKW, preserveShortenedNames);
207
+ fontTitle = formatFontTitle(fontTitle, preserveShortenedNames);
208
+
209
+ // Style-only names are not subfamilies — strip them
210
+ subfamilyName = subfamilyName
211
+ .replace(/\b(Italic|Slant|Slanted|Oblique|Backslant|Roman|Upright)\b/gi, '')
212
+ .replace(/\s+/g, ' ')
213
+ .trim();
214
+
215
+ if (subfamilyName !== '') {
216
+ weightName = weightName
217
+ .replace(`${subfamilyName} `, '')
218
+ .replace(` ${subfamilyName}`, '')
219
+ .trim();
220
+ }
221
+
222
+ if (variableFont) {
223
+ if (!fontTitle.toLowerCase().includes('vf')) {
224
+ fontTitle = fontTitle + ' VF';
225
+ }
226
+ // Variable fonts are not placed in subfamilies — they go in the separate variableFont array
227
+ subfamilyName = '';
228
+ }
229
+
230
+ if (!(variableFont && fontTitle.toLowerCase().includes('italic'))) {
231
+ fontTitle = addItalicToFontTitle(font, fontTitle, italicKW, style, preserveShortenedNames);
232
+ }
233
+
234
+ return { weightName, subfamilyName, fontTitle, style, italicKW, variableFont };
235
+ };
236
+
237
+ /**
238
+ * Extracts the weight name from a font's preferred subfamily or subfamily record.
239
+ * Returns "Variable" for variable fonts.
240
+ * @param {object} font - lib-font parsed font
241
+ * @param {string[]} italicKW
242
+ * @param {object|null} ttfFallbackMeta
243
+ * @returns {string}
244
+ */
245
+ export const extractWeightName = (font, italicKW, ttfFallbackMeta = null) => {
246
+ let weightName = getNameString(font, 17) || getNameString(font, 2) ||
247
+ ttfFallbackMeta?.preferredSubfamily || ttfFallbackMeta?.subfamilyName || '';
248
+
249
+ const axes = getVariationAxes(font);
250
+ if (axes !== null) {
251
+ return '';
252
+ }
253
+
254
+ if (italicKW) {
255
+ italicKW.forEach(keyword => {
256
+ const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
257
+ if (kwRegex.test(weightName)) {
258
+ weightName = weightName.replace(kwRegex, '').trim();
259
+ }
260
+ });
261
+ }
262
+
263
+ return weightName?.toString()
264
+ .replace('Italic', '')
265
+ .replace('It', '')
266
+ .replace('Slanted', '')
267
+ .replace('Slant', '')
268
+ .replace('Backslant', '')
269
+ .trim();
270
+ };
271
+
272
+ /**
273
+ * Extracts a weight name from the font's full name record when subfamily is empty or "Roman".
274
+ * @param {object} font - lib-font parsed font
275
+ * @param {string} title
276
+ * @param {object|null} ttfFallbackMeta
277
+ * @returns {string}
278
+ */
279
+ export const extractWeightFromFullName = (font, title, ttfFallbackMeta = null) => {
280
+ let weightName = getNameString(font, 4) || ttfFallbackMeta?.fullName || '';
281
+ weightName = weightName.replace(title + ' ', '').replace(title, '').trim();
282
+ weightName = weightName.replace('Italic', '').replace('It', '').replace('Slanted', '').replace('Slant', '').trim();
283
+ return weightName;
284
+ };
285
+
286
+ /**
287
+ * Strips weight and italic keywords from a subfamily name string.
288
+ * @param {string} subfamilyName
289
+ * @param {string[]} weightKeywordList
290
+ * @param {string[]} italicKeywordList
291
+ * @param {boolean} preserveShortenedNames
292
+ * @returns {string}
293
+ */
294
+ export const processSubfamilyName = (subfamilyName, weightKeywordList, italicKeywordList, preserveShortenedNames = false) => {
295
+ weightKeywordList.forEach(keyword => {
296
+ const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
297
+ if (kwRegex.test(subfamilyName)) {
298
+ subfamilyName = subfamilyName.replace(kwRegex, '').trim();
299
+ }
300
+ subfamilyName = removeWeightNames(subfamilyName) || subfamilyName;
301
+ if (!preserveShortenedNames) {
302
+ subfamilyName = expandAbbreviations(subfamilyName);
303
+ }
304
+ });
305
+
306
+ italicKeywordList.forEach(keyword => {
307
+ const kwRegex = new RegExp(`\\b${keyword.trim()}\\b`, 'i');
308
+ if (kwRegex.test(subfamilyName)) {
309
+ subfamilyName = subfamilyName.replace(kwRegex, '').trim();
310
+ }
311
+ });
312
+
313
+ return subfamilyName;
314
+ };
315
+
316
+ /**
317
+ * Collects italic keywords present in a font's full name.
318
+ * @param {object} font - lib-font parsed font
319
+ * @param {string} fontTitle
320
+ * @param {string[]} italicKeywordList
321
+ * @returns {string[]}
322
+ */
323
+ export const processItalicKeywords = (font, fontTitle, italicKeywordList) => {
324
+ let italicKW = [];
325
+ const fullName = getNameString(font, 4);
326
+
327
+ italicKeywordList.forEach(keyword => {
328
+ const kw = keyword.trim();
329
+ const kwRegex = new RegExp(`\\b${kw}\\b`, 'i');
330
+ if (kwRegex.test(fontTitle)) {
331
+ fontTitle = fontTitle.replace(kwRegex, '').trim();
332
+ italicKW.push(kw);
333
+ }
334
+ if (fullName && fullName.toLowerCase().includes(kw.toLowerCase())) {
335
+ if (!italicKW.includes(kw)) italicKW.push(kw);
336
+ }
337
+ });
338
+
339
+ return italicKW;
340
+ };
341
+
342
+ /**
343
+ * Normalises and title-cases a font title, optionally expanding abbreviations.
344
+ * @param {string} fontTitle
345
+ * @param {boolean} preserveShortenedNames
346
+ * @returns {string}
347
+ */
348
+ export const formatFontTitle = (fontTitle, preserveShortenedNames = false) => {
349
+ const hasItalic = fontTitle.toLowerCase().includes('italic');
350
+ fontTitle = fontTitle.replace(/-/g, ' ');
351
+
352
+ return fontTitle.replace(/\s+/g, ' ').trim().split(' ').map(word => {
353
+ if (hasItalic && word.toLowerCase() === 'italic') return 'Italic';
354
+ let fullWord = word;
355
+ if (!preserveShortenedNames) {
356
+ fullWord = reverseSpellingLookup(word) || word;
357
+ }
358
+ return fullWord[0].toUpperCase() + fullWord.slice(1);
359
+ }).join(' ');
360
+ };
361
+
362
+ /**
363
+ * Appends any italic keywords to the font title that aren't already present.
364
+ * @param {object} font - lib-font parsed font
365
+ * @param {string} fontTitle
366
+ * @param {string[]} italicKW
367
+ * @param {string} style
368
+ * @param {boolean} preserveShortenedNames
369
+ * @returns {string}
370
+ */
371
+ export const addItalicToFontTitle = (font, fontTitle, italicKW, style, preserveShortenedNames = false) => {
372
+ const hasItalicAngle = getItalicAngle(font) !== 0;
373
+ const fullName = getNameString(font, 4);
374
+ const hasItalicInName = fullName.toLowerCase().includes('italic');
375
+
376
+ if (italicKW.length > 0 || hasItalicAngle || hasItalicInName) {
377
+ italicKW = [...new Set(italicKW)];
378
+
379
+ if (italicKW.length === 0 && (hasItalicAngle || hasItalicInName)) {
380
+ italicKW = ['Italic'];
381
+ }
382
+
383
+ if (!preserveShortenedNames) {
384
+ italicKW = italicKW.map(item => reverseSpellingLookup(item) || item);
385
+ }
386
+
387
+ italicKW = [...new Set(italicKW)];
388
+
389
+ if (italicKW.length > 1 && italicKW.includes('Italic')) {
390
+ italicKW = ['Italic'];
391
+ }
392
+
393
+ const fontTitleLower = fontTitle.toLowerCase();
394
+ italicKW = italicKW.filter(keyword => {
395
+ const keywordLower = keyword.toLowerCase();
396
+ const kwRegex = new RegExp(`\\b${keywordLower}\\b`);
397
+ const isSubstring = fontTitleLower.split(' ').some(word =>
398
+ word.includes(keywordLower) || keywordLower.includes(word)
399
+ );
400
+ return !kwRegex.test(fontTitleLower) && !isSubstring;
401
+ });
402
+
403
+ if (italicKW.length > 0) {
404
+ fontTitle = fontTitle.trim() + ' ' + italicKW.join(' ');
405
+ }
406
+ }
407
+
408
+ return fontTitle;
409
+ };
410
+
411
+ /**
412
+ * Builds a font object ready for staging and upload.
413
+ * originalFilename is stored temporarily and deleted before saving to Sanity.
414
+ * @param {string} id
415
+ * @param {string} fontTitle
416
+ * @param {string} title
417
+ * @param {object} font - lib-font parsed font
418
+ * @param {boolean} variableFont
419
+ * @param {string} weightName
420
+ * @param {string} subfamilyName
421
+ * @param {File} file
422
+ * @param {string|null} originalFilename
423
+ * @returns {Object}
424
+ */
425
+ export const createFontObject = (id, fontTitle, title, font, variableFont, weightName, subfamilyName, file, originalFilename = null) => {
426
+ const italicAngle = getItalicAngle(font);
427
+ const fullName = getNameString(font, 4);
428
+
429
+ const fontObject = {
430
+ _key: nanoid(),
431
+ _id: id,
432
+ title: fontTitle,
433
+ slug: { _type: 'slug', current: id },
434
+ typefaceName: title,
435
+ style: (italicAngle !== 0 || fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular',
436
+ variableFont: variableFont,
437
+ weightName: weightName,
438
+ subfamily: subfamilyName,
439
+ normalWeight: true,
440
+ weight: Number(determineWeight(font, weightName)),
441
+ fileInput: {},
442
+ files: [file],
443
+ fontKit: font,
444
+ };
445
+
446
+ if (originalFilename) {
447
+ fontObject.originalFilename = originalFilename;
448
+ }
449
+
450
+ return fontObject;
451
+ };
452
+
453
+ /**
454
+ * Determines a numeric CSS weight value for a font.
455
+ * @param {object} font - lib-font parsed font
456
+ * @param {string} weightName
457
+ * @returns {number}
458
+ */
459
+ export const determineWeight = (font, weightName) => {
460
+ const usWeightClass = getWeightClass(font);
461
+ if (usWeightClass) {
462
+ return Number(usWeightClass);
463
+ }
464
+
465
+ const wn = weightName?.toLowerCase() || '';
466
+
467
+ if (/hairline|extra thin|extrathin/.test(wn)) return 100;
468
+ if (/thin|extra light|extralight/.test(wn)) return 200;
469
+ if (/light|book/.test(wn)) return 300;
470
+ if (/regular|normal/.test(wn)) return 400;
471
+ if (/medium/.test(wn)) return 500;
472
+ if (/semi bold|semibold/.test(wn)) return 600;
473
+ if (/extra bold|extrabold/.test(wn)) return 800;
474
+ if (/bold/.test(wn)) return 700;
475
+ if (/black|ultra/.test(wn)) return 900;
476
+
477
+ return 400;
478
+ };
479
+
480
+ /**
481
+ * Sorts a map of font objects by ascending weight, with Regular before Italic at equal weights.
482
+ * @param {Object} fontsObjects
483
+ * @returns {Object}
484
+ */
485
+ export const sortFontObjects = (fontsObjects) => {
486
+ return Object.fromEntries(
487
+ Object.entries(fontsObjects).sort((a, b) => {
488
+ const weightA = Number(a[1].weight);
489
+ const weightB = Number(b[1].weight);
490
+ if (weightA === weightB) {
491
+ if (a[1].style === 'Regular' && b[1].style === 'Italic') return -1;
492
+ if (a[1].style === 'Italic' && b[1].style === 'Regular') return 1;
493
+ return 0;
494
+ }
495
+ return weightA - weightB;
496
+ })
497
+ );
498
+ };
499
+
500
+ /**
501
+ * Logs font metadata to the console for debugging.
502
+ * @param {string} id
503
+ * @param {string} fontTitle
504
+ * @param {object} font - lib-font parsed font
505
+ * @param {string} fileName
506
+ * @param {string} subfamilyName
507
+ * @param {string} style
508
+ * @param {string} weightName
509
+ * @param {boolean} variableFont
510
+ * @param {string[]} italicKW
511
+ */
512
+ export const logFontInfo = (id, fontTitle, font, fileName, subfamilyName, style, weightName, variableFont, italicKW) => {
513
+ const fullName = getNameString(font, 4);
514
+ const familyName = getNameString(font, 1);
515
+ const italicAngle = getItalicAngle(font);
516
+
517
+ console.log('=== Font Info ====');
518
+ console.log('Font id:', id);
519
+ console.log('Font title:', fontTitle);
520
+ console.log('Full name:', fullName);
521
+ console.log('Family name:', familyName);
522
+ console.log('File name:', fileName);
523
+ console.log('Subfamily:', subfamilyName);
524
+ console.log('Style:', style);
525
+ console.log('Weight:', weightName);
526
+ console.log('Variable:', variableFont);
527
+ console.log('ItalicKW:', italicKW);
528
+ console.log('Italic detection:', (italicAngle !== 0 || fullName.toLowerCase().includes('italic')) ? 'Italic' : 'Regular');
529
+ console.log('=======');
530
+ };