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

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 +1634 -17839
  4. package/dist/index.mjs +1551 -17745
  5. package/package.json +83 -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,326 +1,326 @@
1
- // Phase 1 orchestrator — parses font files, resolves existing documents, builds a complete upload plan for user review
2
-
3
- import { nanoid } from 'nanoid';
4
- import { parseFont } from './parseFont';
5
- import { readFontFile, extractFontMetadata, determineWeight } from './processFontFiles';
6
- import {
7
- getNameString,
8
- getFontMetadata,
9
- getFontMetrics,
10
- getVariationAxes,
11
- getNamedInstances,
12
- getAllFeatureTags,
13
- getGlyphCount,
14
- getCharacterSet,
15
- getItalicAngle,
16
- getWeightClass,
17
- } from './fontHelpers';
18
- import { resolveExistingFont } from './resolveExistingFont';
19
- import { sanitizeForSanityId } from './sanitizeForSanityId';
20
- import {
21
- FONT_STATUS,
22
- PLAN_PHASE,
23
- RECOMMENDATION,
24
- PLAN_VERSION,
25
- createFontDecisions,
26
- createEmptyPlan,
27
- } from './planTypes';
28
-
29
- /**
30
- * Phase 1: Reads font files, parses metadata, resolves existing documents,
31
- * returns a complete upload plan for user review.
32
- *
33
- * NOTE: This function performs Sanity READ operations (existing document lookups)
34
- * but NO writes. The client parameter is used for reads only.
35
- *
36
- * @param {object} params
37
- * @param {File[]} params.files - Font files to process
38
- * @param {string} params.typefaceTitle - Parent typeface title
39
- * @param {string} params.docId - Typeface document _id (for scoping resolution queries)
40
- * @param {object} params.settings - { price, preserveShortenedNames, preserveFileNames }
41
- * @param {object} params.client - Sanity client (read-only usage)
42
- * @param {object} params.stylesObject - Existing typeface styles
43
- * @param {string[]} params.weightKeywordList - Weight keywords for extraction
44
- * @param {string[]} params.italicKeywordList - Italic keywords for extraction
45
- * @param {function} params.onProgress - Called after each font is processed
46
- * @returns {Promise<object>} UploadPlan object
47
- */
48
- export async function buildUploadPlan({
49
- files,
50
- typefaceTitle,
51
- docId,
52
- settings = {},
53
- client,
54
- stylesObject = {},
55
- weightKeywordList = [],
56
- italicKeywordList = [],
57
- onProgress,
58
- }) {
59
- const plan = createEmptyPlan(settings);
60
- plan.settings.typefaceTitle = typefaceTitle;
61
- plan.phase = PLAN_PHASE.PROCESSING;
62
- plan.processingProgress.total = files.length;
63
-
64
- for (let i = 0; i < files.length; i++) {
65
- const file = files[i];
66
- plan.processingProgress.currentFile = file.name;
67
-
68
- try {
69
- const fontBuffer = await readFontFile(file);
70
- const font = await parseFont(fontBuffer, file.name);
71
-
72
- const entry = await buildFontPlanEntry({
73
- file,
74
- font,
75
- typefaceTitle,
76
- settings: plan.settings,
77
- weightKeywordList,
78
- italicKeywordList,
79
- client,
80
- });
81
-
82
- // Merge into plan — if same documentId, merge files
83
- const existingKey = Object.keys(plan.fonts).find(k => plan.fonts[k].documentId === entry.documentId);
84
- if (existingKey) {
85
- plan.fonts[existingKey].files = [...plan.fonts[existingKey].files, ...entry.files];
86
- } else {
87
- plan.fonts[entry.tempId] = entry;
88
-
89
- // Add to subfamily group
90
- if (!entry.variableFont || entry.subfamily) {
91
- const sfName = entry.subfamily;
92
- if (!plan.subfamilyGroups[sfName]) {
93
- plan.subfamilyGroups[sfName] = { title: sfName, fontIds: [] };
94
- }
95
- plan.subfamilyGroups[sfName].fontIds.push(entry.tempId);
96
- }
97
- }
98
-
99
- plan.processingProgress.completed++;
100
-
101
- if (onProgress) {
102
- onProgress({
103
- type: 'font-processed',
104
- tempId: entry.tempId,
105
- fontEntry: entry,
106
- progress: { ...plan.processingProgress },
107
- });
108
- }
109
- } catch (err) {
110
- console.error(`Error processing ${file.name}:`, err.message);
111
- plan.processingProgress.failed++;
112
-
113
- const errorTempId = sanitizeForSanityId(file.name) + '-' + nanoid(6);
114
- plan.fonts[errorTempId] = {
115
- tempId: errorTempId,
116
- files: [file],
117
- sourceFileName: file.name,
118
- title: file.name,
119
- documentId: '',
120
- weight: 400,
121
- weightName: '',
122
- style: 'Regular',
123
- subfamily: '',
124
- variableFont: false,
125
- originalFilename: null,
126
- decisions: createFontDecisions({}),
127
- status: FONT_STATUS.ERROR,
128
- error: err.message,
129
- parsedMetadata: null,
130
- glyphCount: 0,
131
- opentypeFeatures: [],
132
- variationAxes: null,
133
- };
134
-
135
- if (onProgress) {
136
- onProgress({
137
- type: 'font-error',
138
- tempId: errorTempId,
139
- error: err.message,
140
- progress: { ...plan.processingProgress },
141
- });
142
- }
143
- }
144
- }
145
-
146
- plan.processingProgress.currentFile = null;
147
- plan.phase = PLAN_PHASE.REVIEWING;
148
-
149
- if (onProgress) {
150
- onProgress({
151
- type: 'processing-complete',
152
- progress: { ...plan.processingProgress },
153
- });
154
- }
155
-
156
- return plan;
157
- }
158
-
159
- /**
160
- * Builds a single FontPlanEntry from a parsed font file.
161
- * @param {object} params
162
- * @returns {Promise<object>} FontPlanEntry
163
- */
164
- async function buildFontPlanEntry({
165
- file,
166
- font,
167
- typefaceTitle,
168
- settings,
169
- weightKeywordList,
170
- italicKeywordList,
171
- client,
172
- }) {
173
- // Extract metadata using existing processFontFiles helpers
174
- const { weightName, subfamilyName, fontTitle, style, italicKW, variableFont } = extractFontMetadata(
175
- font,
176
- typefaceTitle,
177
- weightKeywordList,
178
- italicKeywordList,
179
- settings.preserveShortenedNames,
180
- );
181
-
182
- // Determine title and ID based on preserveFileNames setting
183
- let finalTitle = fontTitle;
184
- let originalFilename = null;
185
-
186
- if (settings.preserveFileNames) {
187
- originalFilename = file.name.replace(/\.(ttf|otf|woff2?|eot|svg)$/i, '');
188
- const normalizedName = originalFilename
189
- .replace(/-/g, ' ')
190
- .replace(/([a-z])([A-Z])/g, '$1 $2')
191
- .replace(/\s+/g, ' ')
192
- .trim();
193
- finalTitle = normalizedName;
194
- }
195
-
196
- const documentId = sanitizeForSanityId(finalTitle);
197
- const tempId = documentId + '-' + nanoid(6);
198
- const weight = Number(determineWeight(font, weightName));
199
-
200
- // Build title alternatives from all name sources
201
- const titleAlternatives = [
202
- { value: getNameString(font, 1), source: 'nameId1-familyName' },
203
- { value: getNameString(font, 4), source: 'nameId4-fullName' },
204
- { value: getNameString(font, 6), source: 'nameId6-postscriptName' },
205
- { value: getNameString(font, 16), source: 'nameId16-preferredFamily' },
206
- { value: getNameString(font, 17), source: 'nameId17-preferredSubfamily' },
207
- { value: file.name.replace(/\.(ttf|otf|woff2?|eot|svg)$/i, ''), source: 'filename' },
208
- ].filter(alt => alt.value);
209
-
210
- // Determine title source
211
- const titleSource = settings.preserveFileNames ? 'filename' : 'fontkit-fullName';
212
-
213
- // Determine weight source
214
- const usWeightClass = getWeightClass(font);
215
- const weightSource = usWeightClass ? 'os2-usWeightClass' : 'keyword-match';
216
- const matchedKeyword = usWeightClass ? null : weightName;
217
-
218
- // Determine weight name source — nameId17 (preferredSubfamily) or nameId2 (fontSubfamily)
219
- const nameId17 = getNameString(font, 17);
220
- const weightNameSource = variableFont
221
- ? 'variable-font-empty'
222
- : nameId17
223
- ? 'nameId17-preferredSubfamily'
224
- : 'nameId2-fontSubfamily';
225
-
226
- // Determine style source
227
- const italicAngle = getItalicAngle(font);
228
- const fullName = getNameString(font, 4);
229
- let styleSource = 'default-regular';
230
- let styleReason = '';
231
- if (style === 'Italic') {
232
- if (italicAngle !== 0) {
233
- styleSource = 'italic-angle';
234
- styleReason = `italicAngle = ${italicAngle}`;
235
- } else if (fullName.toLowerCase().includes('italic')) {
236
- styleSource = 'name-contains-italic';
237
- styleReason = 'fullName contains "italic"';
238
- }
239
- }
240
-
241
- // Determine subfamily source — mirrors the logic in extractFontMetadata
242
- const familyNameRaw = getNameString(font, 1);
243
- const nameId4Remainder = fullName ? fullName.replace(typefaceTitle.trim(), '').trim() : '';
244
- const nameId1Remainder = familyNameRaw ? familyNameRaw.replace(typefaceTitle.trim(), '').trim() : '';
245
- let subfamilySource = 'default-empty';
246
- if (nameId4Remainder && subfamilyName) {
247
- subfamilySource = 'nameId4-subtraction';
248
- } else if (nameId1Remainder && subfamilyName) {
249
- subfamilySource = 'nameId1-subtraction';
250
- }
251
-
252
- // Build decisions audit trail
253
- const decisions = createFontDecisions({
254
- titleSource,
255
- title: finalTitle,
256
- titleOriginal: getNameString(font, 4),
257
- documentId,
258
- weight,
259
- weightSource,
260
- matchedKeyword,
261
- weightName,
262
- weightNameSource,
263
- style,
264
- styleSource,
265
- styleReason,
266
- subfamily: subfamilyName,
267
- subfamilySource,
268
- titleAlternatives,
269
- });
270
-
271
- // Resolve existing document
272
- const fontForResolution = {
273
- _id: documentId,
274
- typefaceName: typefaceTitle,
275
- weightName,
276
- style,
277
- subfamily: subfamilyName,
278
- variableFont,
279
- title: finalTitle,
280
- };
281
-
282
- try {
283
- const resolution = await resolveExistingFont(fontForResolution, client);
284
- decisions.existingDocument = {
285
- recommendation: resolution.recommendation,
286
- exact: resolution.exact,
287
- candidates: resolution.candidates,
288
- userChoice: null,
289
- selectedCandidate: null,
290
- lookupFailed: resolution.lookupFailed || false,
291
- };
292
- } catch (err) {
293
- console.warn('Document resolution failed for', documentId, err.message);
294
- decisions.existingDocument.lookupFailed = true;
295
- }
296
-
297
- // Extract parsed metadata snapshot (no raw fontkit/lib-font object retained)
298
- const metadata = getFontMetadata(font);
299
- const metrics = getFontMetrics(font);
300
- const axes = getVariationAxes(font);
301
-
302
- // Default empty subfamily to "Regular" — matches Regenerate Subfamilies behavior
303
- const finalSubfamily = variableFont ? '' : (subfamilyName || 'Regular');
304
- console.log(`[buildFontPlanEntry] "${finalTitle}" → subfamily: "${subfamilyName}" → final: "${finalSubfamily}" (variableFont: ${variableFont})`);
305
-
306
- return {
307
- tempId,
308
- files: [file],
309
- sourceFileName: file.name,
310
- title: finalTitle,
311
- documentId,
312
- weight,
313
- weightName,
314
- style,
315
- subfamily: finalSubfamily,
316
- variableFont,
317
- originalFilename,
318
- decisions,
319
- status: FONT_STATUS.PROCESSED,
320
- error: null,
321
- parsedMetadata: { ...metadata, ...metrics },
322
- glyphCount: getGlyphCount(font),
323
- opentypeFeatures: getAllFeatureTags(font),
324
- variationAxes: axes,
325
- };
326
- }
1
+ // Phase 1 orchestrator — parses font files, resolves existing documents, builds a complete upload plan for user review
2
+
3
+ import { nanoid } from 'nanoid';
4
+ import { parseFont } from './parseFont';
5
+ import { readFontFile, extractFontMetadata, determineWeight } from './processFontFiles';
6
+ import {
7
+ getNameString,
8
+ getFontMetadata,
9
+ getFontMetrics,
10
+ getVariationAxes,
11
+ getNamedInstances,
12
+ getAllFeatureTags,
13
+ getGlyphCount,
14
+ getCharacterSet,
15
+ getItalicAngle,
16
+ getWeightClass,
17
+ } from './fontHelpers';
18
+ import { resolveExistingFont } from './resolveExistingFont';
19
+ import { sanitizeForSanityId } from './sanitizeForSanityId';
20
+ import {
21
+ FONT_STATUS,
22
+ PLAN_PHASE,
23
+ RECOMMENDATION,
24
+ PLAN_VERSION,
25
+ createFontDecisions,
26
+ createEmptyPlan,
27
+ } from './planTypes';
28
+
29
+ /**
30
+ * Phase 1: Reads font files, parses metadata, resolves existing documents,
31
+ * returns a complete upload plan for user review.
32
+ *
33
+ * NOTE: This function performs Sanity READ operations (existing document lookups)
34
+ * but NO writes. The client parameter is used for reads only.
35
+ *
36
+ * @param {object} params
37
+ * @param {File[]} params.files - Font files to process
38
+ * @param {string} params.typefaceTitle - Parent typeface title
39
+ * @param {string} params.docId - Typeface document _id (for scoping resolution queries)
40
+ * @param {object} params.settings - { price, preserveShortenedNames, preserveFileNames }
41
+ * @param {object} params.client - Sanity client (read-only usage)
42
+ * @param {object} params.stylesObject - Existing typeface styles
43
+ * @param {string[]} params.weightKeywordList - Weight keywords for extraction
44
+ * @param {string[]} params.italicKeywordList - Italic keywords for extraction
45
+ * @param {function} params.onProgress - Called after each font is processed
46
+ * @returns {Promise<object>} UploadPlan object
47
+ */
48
+ export async function buildUploadPlan({
49
+ files,
50
+ typefaceTitle,
51
+ docId,
52
+ settings = {},
53
+ client,
54
+ stylesObject = {},
55
+ weightKeywordList = [],
56
+ italicKeywordList = [],
57
+ onProgress,
58
+ }) {
59
+ const plan = createEmptyPlan(settings);
60
+ plan.settings.typefaceTitle = typefaceTitle;
61
+ plan.phase = PLAN_PHASE.PROCESSING;
62
+ plan.processingProgress.total = files.length;
63
+
64
+ for (let i = 0; i < files.length; i++) {
65
+ const file = files[i];
66
+ plan.processingProgress.currentFile = file.name;
67
+
68
+ try {
69
+ const fontBuffer = await readFontFile(file);
70
+ const font = await parseFont(fontBuffer, file.name);
71
+
72
+ const entry = await buildFontPlanEntry({
73
+ file,
74
+ font,
75
+ typefaceTitle,
76
+ settings: plan.settings,
77
+ weightKeywordList,
78
+ italicKeywordList,
79
+ client,
80
+ });
81
+
82
+ // Merge into plan — if same documentId, merge files
83
+ const existingKey = Object.keys(plan.fonts).find(k => plan.fonts[k].documentId === entry.documentId);
84
+ if (existingKey) {
85
+ plan.fonts[existingKey].files = [...plan.fonts[existingKey].files, ...entry.files];
86
+ } else {
87
+ plan.fonts[entry.tempId] = entry;
88
+
89
+ // Add to subfamily group
90
+ if (!entry.variableFont || entry.subfamily) {
91
+ const sfName = entry.subfamily;
92
+ if (!plan.subfamilyGroups[sfName]) {
93
+ plan.subfamilyGroups[sfName] = { title: sfName, fontIds: [] };
94
+ }
95
+ plan.subfamilyGroups[sfName].fontIds.push(entry.tempId);
96
+ }
97
+ }
98
+
99
+ plan.processingProgress.completed++;
100
+
101
+ if (onProgress) {
102
+ onProgress({
103
+ type: 'font-processed',
104
+ tempId: entry.tempId,
105
+ fontEntry: entry,
106
+ progress: { ...plan.processingProgress },
107
+ });
108
+ }
109
+ } catch (err) {
110
+ console.error(`Error processing ${file.name}:`, err.message);
111
+ plan.processingProgress.failed++;
112
+
113
+ const errorTempId = sanitizeForSanityId(file.name) + '-' + nanoid(6);
114
+ plan.fonts[errorTempId] = {
115
+ tempId: errorTempId,
116
+ files: [file],
117
+ sourceFileName: file.name,
118
+ title: file.name,
119
+ documentId: '',
120
+ weight: 400,
121
+ weightName: '',
122
+ style: 'Regular',
123
+ subfamily: '',
124
+ variableFont: false,
125
+ originalFilename: null,
126
+ decisions: createFontDecisions({}),
127
+ status: FONT_STATUS.ERROR,
128
+ error: err.message,
129
+ parsedMetadata: null,
130
+ glyphCount: 0,
131
+ opentypeFeatures: [],
132
+ variationAxes: null,
133
+ };
134
+
135
+ if (onProgress) {
136
+ onProgress({
137
+ type: 'font-error',
138
+ tempId: errorTempId,
139
+ error: err.message,
140
+ progress: { ...plan.processingProgress },
141
+ });
142
+ }
143
+ }
144
+ }
145
+
146
+ plan.processingProgress.currentFile = null;
147
+ plan.phase = PLAN_PHASE.REVIEWING;
148
+
149
+ if (onProgress) {
150
+ onProgress({
151
+ type: 'processing-complete',
152
+ progress: { ...plan.processingProgress },
153
+ });
154
+ }
155
+
156
+ return plan;
157
+ }
158
+
159
+ /**
160
+ * Builds a single FontPlanEntry from a parsed font file.
161
+ * @param {object} params
162
+ * @returns {Promise<object>} FontPlanEntry
163
+ */
164
+ async function buildFontPlanEntry({
165
+ file,
166
+ font,
167
+ typefaceTitle,
168
+ settings,
169
+ weightKeywordList,
170
+ italicKeywordList,
171
+ client,
172
+ }) {
173
+ // Extract metadata using existing processFontFiles helpers
174
+ const { weightName, subfamilyName, fontTitle, style, italicKW, variableFont } = extractFontMetadata(
175
+ font,
176
+ typefaceTitle,
177
+ weightKeywordList,
178
+ italicKeywordList,
179
+ settings.preserveShortenedNames,
180
+ );
181
+
182
+ // Determine title and ID based on preserveFileNames setting
183
+ let finalTitle = fontTitle;
184
+ let originalFilename = null;
185
+
186
+ if (settings.preserveFileNames) {
187
+ originalFilename = file.name.replace(/\.(ttf|otf|woff2?|eot|svg)$/i, '');
188
+ const normalizedName = originalFilename
189
+ .replace(/-/g, ' ')
190
+ .replace(/([a-z])([A-Z])/g, '$1 $2')
191
+ .replace(/\s+/g, ' ')
192
+ .trim();
193
+ finalTitle = normalizedName;
194
+ }
195
+
196
+ const documentId = sanitizeForSanityId(finalTitle);
197
+ const tempId = documentId + '-' + nanoid(6);
198
+ const weight = Number(determineWeight(font, weightName));
199
+
200
+ // Build title alternatives from all name sources
201
+ const titleAlternatives = [
202
+ { value: getNameString(font, 1), source: 'nameId1-familyName' },
203
+ { value: getNameString(font, 4), source: 'nameId4-fullName' },
204
+ { value: getNameString(font, 6), source: 'nameId6-postscriptName' },
205
+ { value: getNameString(font, 16), source: 'nameId16-preferredFamily' },
206
+ { value: getNameString(font, 17), source: 'nameId17-preferredSubfamily' },
207
+ { value: file.name.replace(/\.(ttf|otf|woff2?|eot|svg)$/i, ''), source: 'filename' },
208
+ ].filter(alt => alt.value);
209
+
210
+ // Determine title source
211
+ const titleSource = settings.preserveFileNames ? 'filename' : 'fontkit-fullName';
212
+
213
+ // Determine weight source
214
+ const usWeightClass = getWeightClass(font);
215
+ const weightSource = usWeightClass ? 'os2-usWeightClass' : 'keyword-match';
216
+ const matchedKeyword = usWeightClass ? null : weightName;
217
+
218
+ // Determine weight name source — nameId17 (preferredSubfamily) or nameId2 (fontSubfamily)
219
+ const nameId17 = getNameString(font, 17);
220
+ const weightNameSource = variableFont
221
+ ? 'variable-font-empty'
222
+ : nameId17
223
+ ? 'nameId17-preferredSubfamily'
224
+ : 'nameId2-fontSubfamily';
225
+
226
+ // Determine style source
227
+ const italicAngle = getItalicAngle(font);
228
+ const fullName = getNameString(font, 4);
229
+ let styleSource = 'default-regular';
230
+ let styleReason = '';
231
+ if (style === 'Italic') {
232
+ if (italicAngle !== 0) {
233
+ styleSource = 'italic-angle';
234
+ styleReason = `italicAngle = ${italicAngle}`;
235
+ } else if (fullName.toLowerCase().includes('italic')) {
236
+ styleSource = 'name-contains-italic';
237
+ styleReason = 'fullName contains "italic"';
238
+ }
239
+ }
240
+
241
+ // Determine subfamily source — mirrors the logic in extractFontMetadata
242
+ const familyNameRaw = getNameString(font, 1);
243
+ const nameId4Remainder = fullName ? fullName.replace(typefaceTitle.trim(), '').trim() : '';
244
+ const nameId1Remainder = familyNameRaw ? familyNameRaw.replace(typefaceTitle.trim(), '').trim() : '';
245
+ let subfamilySource = 'default-empty';
246
+ if (nameId4Remainder && subfamilyName) {
247
+ subfamilySource = 'nameId4-subtraction';
248
+ } else if (nameId1Remainder && subfamilyName) {
249
+ subfamilySource = 'nameId1-subtraction';
250
+ }
251
+
252
+ // Build decisions audit trail
253
+ const decisions = createFontDecisions({
254
+ titleSource,
255
+ title: finalTitle,
256
+ titleOriginal: getNameString(font, 4),
257
+ documentId,
258
+ weight,
259
+ weightSource,
260
+ matchedKeyword,
261
+ weightName,
262
+ weightNameSource,
263
+ style,
264
+ styleSource,
265
+ styleReason,
266
+ subfamily: subfamilyName,
267
+ subfamilySource,
268
+ titleAlternatives,
269
+ });
270
+
271
+ // Resolve existing document
272
+ const fontForResolution = {
273
+ _id: documentId,
274
+ typefaceName: typefaceTitle,
275
+ weightName,
276
+ style,
277
+ subfamily: subfamilyName,
278
+ variableFont,
279
+ title: finalTitle,
280
+ };
281
+
282
+ try {
283
+ const resolution = await resolveExistingFont(fontForResolution, client);
284
+ decisions.existingDocument = {
285
+ recommendation: resolution.recommendation,
286
+ exact: resolution.exact,
287
+ candidates: resolution.candidates,
288
+ userChoice: null,
289
+ selectedCandidate: null,
290
+ lookupFailed: resolution.lookupFailed || false,
291
+ };
292
+ } catch (err) {
293
+ console.warn('Document resolution failed for', documentId, err.message);
294
+ decisions.existingDocument.lookupFailed = true;
295
+ }
296
+
297
+ // Extract parsed metadata snapshot (no raw fontkit/lib-font object retained)
298
+ const metadata = getFontMetadata(font);
299
+ const metrics = getFontMetrics(font);
300
+ const axes = getVariationAxes(font);
301
+
302
+ // Default empty subfamily to "Regular" — matches Regenerate Subfamilies behavior
303
+ const finalSubfamily = variableFont ? '' : (subfamilyName || 'Regular');
304
+ console.log(`[buildFontPlanEntry] "${finalTitle}" → subfamily: "${subfamilyName}" → final: "${finalSubfamily}" (variableFont: ${variableFont})`);
305
+
306
+ return {
307
+ tempId,
308
+ files: [file],
309
+ sourceFileName: file.name,
310
+ title: finalTitle,
311
+ documentId,
312
+ weight,
313
+ weightName,
314
+ style,
315
+ subfamily: finalSubfamily,
316
+ variableFont,
317
+ originalFilename,
318
+ decisions,
319
+ status: FONT_STATUS.PROCESSED,
320
+ error: null,
321
+ parsedMetadata: { ...metadata, ...metrics },
322
+ glyphCount: getGlyphCount(font),
323
+ opentypeFeatures: getAllFeatureTags(font),
324
+ variationAxes: axes,
325
+ };
326
+ }