@liiift-studio/sanity-font-manager 2.5.0 → 2.5.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.
- package/dist/{UploadModal-NME2W53V.mjs → UploadModal-ADNRGQUI.mjs} +1 -1
- package/dist/UploadModal-WPK2CXLR.js +6 -0
- package/dist/{chunk-FH4QKHOH.js → chunk-JCDZ7SWZ.js} +581 -146
- package/dist/{chunk-646WCBRR.mjs → chunk-TMDE4A54.mjs} +615 -180
- package/dist/index.js +47 -45
- package/dist/index.mjs +4 -2
- package/package.json +1 -1
- package/src/components/FontReviewCard.jsx +41 -1
- package/src/components/UploadModal.jsx +43 -7
- package/src/components/UploadStep2Review.jsx +2 -0
- package/src/components/UploadStep3bInstances.jsx +396 -0
- package/src/index.js +1 -0
- package/src/utils/buildUploadPlan.js +1 -0
- package/src/utils/executeUploadPlan.js +1 -8
- package/src/utils/parseVariableFontInstances.js +237 -147
- package/src/utils/processFontFiles.js +5 -4
- package/src/utils/updateTypefaceDocument.js +15 -2
- package/dist/UploadModal-6LIX7XOK.js +0 -6
|
@@ -1,21 +1,201 @@
|
|
|
1
|
-
// Resolves named variable font instances into Sanity font document references
|
|
1
|
+
// Resolves named variable font instances into Sanity font document references with multi-pass subfamily-aware matching
|
|
2
2
|
|
|
3
3
|
import { nanoid } from 'nanoid';
|
|
4
4
|
import { expandAbbreviations } from './generateKeywords';
|
|
5
5
|
|
|
6
|
+
/** Known width prefixes — longest first to match XXNarrow before Narrow */
|
|
7
|
+
const WIDTH_PREFIXES = [
|
|
8
|
+
'XXXWide', 'XXWide', 'XWide', 'Wide',
|
|
9
|
+
'XXXNarrow', 'XXNarrow', 'XNarrow', 'Narrow',
|
|
10
|
+
];
|
|
11
|
+
|
|
6
12
|
/**
|
|
7
|
-
* Parses a
|
|
8
|
-
*
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
* Parses a VF instance name into subfamily (width), weight, and style components.
|
|
14
|
+
* e.g. "XXNarrow Bold Slant" → { subfamily: "XXNarrow", weight: "Bold", style: "Slant" }
|
|
15
|
+
*/
|
|
16
|
+
function parseInstanceName(instanceName) {
|
|
17
|
+
let subfamily = '';
|
|
18
|
+
let remaining = instanceName.trim();
|
|
19
|
+
|
|
20
|
+
for (const prefix of WIDTH_PREFIXES) {
|
|
21
|
+
if (remaining.toLowerCase().startsWith(prefix.toLowerCase() + ' ') || remaining.toLowerCase() === prefix.toLowerCase()) {
|
|
22
|
+
subfamily = prefix;
|
|
23
|
+
remaining = remaining.substring(prefix.length).trim();
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
let style = '';
|
|
29
|
+
for (const suffix of ['Backslant', 'Slant', 'Italic', 'Oblique']) {
|
|
30
|
+
if (remaining.toLowerCase().endsWith(' ' + suffix.toLowerCase()) || remaining.toLowerCase() === suffix.toLowerCase()) {
|
|
31
|
+
style = suffix;
|
|
32
|
+
remaining = remaining.substring(0, remaining.length - suffix.length).trim();
|
|
33
|
+
break;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
return { subfamily, weight: remaining || 'Regular', style };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Filters static fonts to the same subfamily, preventing cross-subfamily matches.
|
|
42
|
+
*/
|
|
43
|
+
function filterBySubfamily(staticFonts, instanceSubfamily, typefaceName) {
|
|
44
|
+
if (!instanceSubfamily) {
|
|
45
|
+
return staticFonts.filter(sf => {
|
|
46
|
+
const sub = (sf.subfamily || '').toLowerCase();
|
|
47
|
+
if (sub === '' || sub === 'regular') return true;
|
|
48
|
+
const afterTypeface = (sf.title || '').replace(typefaceName, '').trim();
|
|
49
|
+
return !WIDTH_PREFIXES.some(p => afterTypeface.toLowerCase().startsWith(p.toLowerCase()));
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
const lowerSf = instanceSubfamily.toLowerCase();
|
|
53
|
+
const expanded = (expandAbbreviations(instanceSubfamily) || '').toLowerCase();
|
|
54
|
+
return staticFonts.filter(sf => {
|
|
55
|
+
const sub = (sf.subfamily || '').toLowerCase();
|
|
56
|
+
if (sub === lowerSf || (expanded && sub === expanded)) return true;
|
|
57
|
+
const afterTypeface = (sf.title || '').replace(typefaceName, '').trim().toLowerCase();
|
|
58
|
+
if (afterTypeface.startsWith(lowerSf)) return true;
|
|
59
|
+
if (expanded && afterTypeface.startsWith(expanded)) return true;
|
|
60
|
+
return false;
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Weight keyword → numeric weight mapping */
|
|
65
|
+
const WEIGHT_MAP = [
|
|
66
|
+
{ term: 'ultra', weight: 950 },
|
|
67
|
+
{ term: 'xxlight', weight: 200 },
|
|
68
|
+
{ term: 'xlight', weight: 250 },
|
|
69
|
+
{ term: 'extralight', weight: 200 },
|
|
70
|
+
{ term: 'extra light', weight: 200 },
|
|
71
|
+
{ term: 'thin', weight: 100 },
|
|
72
|
+
{ term: 'hairline', weight: 100 },
|
|
73
|
+
{ term: 'light', weight: 300 },
|
|
74
|
+
{ term: 'regular', weight: 400 },
|
|
75
|
+
{ term: 'normal', weight: 400 },
|
|
76
|
+
{ term: 'medium', weight: 500 },
|
|
77
|
+
{ term: 'semibold', weight: 600 },
|
|
78
|
+
{ term: 'semi bold', weight: 600 },
|
|
79
|
+
{ term: 'extrabold', weight: 800 },
|
|
80
|
+
{ term: 'extra bold', weight: 800 },
|
|
81
|
+
{ term: 'xbold', weight: 800 },
|
|
82
|
+
{ term: 'bold', weight: 700 },
|
|
83
|
+
{ term: 'black', weight: 900 },
|
|
84
|
+
{ term: 'heavy', weight: 900 },
|
|
85
|
+
];
|
|
86
|
+
|
|
87
|
+
/** Converts a weight name to a numeric weight */
|
|
88
|
+
function weightFromName(name) {
|
|
89
|
+
const lower = name.toLowerCase();
|
|
90
|
+
for (const { term, weight } of WEIGHT_MAP) {
|
|
91
|
+
if (lower === term || lower.includes(term)) return weight;
|
|
92
|
+
}
|
|
93
|
+
return 400;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Multi-pass matching strategies, ordered from most confident to least.
|
|
98
|
+
* Each returns a match function: (instanceName, parsed, candidates, typefaceName, font) => matchedFont|null
|
|
99
|
+
*/
|
|
100
|
+
const STRATEGIES = [
|
|
101
|
+
// Pass 1: Exact title match (with typeface prefix)
|
|
102
|
+
{
|
|
103
|
+
name: 'exact-title',
|
|
104
|
+
match: (instanceName, parsed, candidates, typefaceName) => {
|
|
105
|
+
const withPrefix = `${typefaceName} ${instanceName}`;
|
|
106
|
+
return candidates.find(sf => sf.title === instanceName || sf.title === withPrefix) || null;
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
// Pass 2: Title normalisation — strip typeface name and compare remainder
|
|
110
|
+
{
|
|
111
|
+
name: 'title-normalised',
|
|
112
|
+
match: (instanceName, parsed, candidates, typefaceName) => {
|
|
113
|
+
return candidates.find(sf => {
|
|
114
|
+
const sfName = (sf.title || '').replace(typefaceName, '').trim();
|
|
115
|
+
if (sfName.toLowerCase() === instanceName.toLowerCase()) return true;
|
|
116
|
+
// Handle "Regular" suffix: instance "Narrow Regular" → font title remainder "Narrow"
|
|
117
|
+
if (parsed.weight === 'Regular' && !parsed.style) {
|
|
118
|
+
if (sfName.toLowerCase() === parsed.subfamily.toLowerCase()) return true;
|
|
119
|
+
}
|
|
120
|
+
return false;
|
|
121
|
+
}) || null;
|
|
122
|
+
},
|
|
123
|
+
},
|
|
124
|
+
// Pass 3: Abbreviation expansion (XLight → ExtraLight, XBold → ExtraBold)
|
|
125
|
+
{
|
|
126
|
+
name: 'abbreviation',
|
|
127
|
+
match: (instanceName, parsed, candidates, typefaceName) => {
|
|
128
|
+
const expandedFull = instanceName.split(' ').map(w => expandAbbreviations(w) || w).join(' ');
|
|
129
|
+
let found = candidates.find(sf => {
|
|
130
|
+
const sfName = (sf.title || '').replace(typefaceName, '').trim();
|
|
131
|
+
return sfName.toLowerCase() === expandedFull.toLowerCase();
|
|
132
|
+
});
|
|
133
|
+
if (found) return found;
|
|
134
|
+
|
|
135
|
+
// Try expanding just the weight part and rebuilding
|
|
136
|
+
const expandedWeight = expandAbbreviations(parsed.weight) || parsed.weight;
|
|
137
|
+
const target = [parsed.subfamily, expandedWeight, parsed.style].filter(Boolean).join(' ');
|
|
138
|
+
return candidates.find(sf => {
|
|
139
|
+
const sfName = (sf.title || '').replace(typefaceName, '').trim();
|
|
140
|
+
return sfName.toLowerCase() === target.toLowerCase();
|
|
141
|
+
}) || null;
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
// Pass 4: fullName metadata comparison
|
|
145
|
+
{
|
|
146
|
+
name: 'metadata-fullName',
|
|
147
|
+
match: (instanceName, parsed, candidates, typefaceName) => {
|
|
148
|
+
return candidates.find(sf => {
|
|
149
|
+
if (!sf.metaData?.fullName) return false;
|
|
150
|
+
const typefacePattern = new RegExp(`^${typefaceName}\\s+`, 'i');
|
|
151
|
+
const stylePart = sf.metaData.fullName.replace(typefacePattern, '').trim();
|
|
152
|
+
return instanceName.toLowerCase() === stylePart.toLowerCase();
|
|
153
|
+
}) || null;
|
|
154
|
+
},
|
|
155
|
+
},
|
|
156
|
+
// Pass 5: Weight + style matching (numeric, within subfamily)
|
|
157
|
+
{
|
|
158
|
+
name: 'weight-style',
|
|
159
|
+
match: (instanceName, parsed, candidates) => {
|
|
160
|
+
const instanceWeight = weightFromName(parsed.weight);
|
|
161
|
+
const isBackslant = parsed.style.toLowerCase() === 'backslant';
|
|
162
|
+
const isSlant = parsed.style.toLowerCase() === 'slant';
|
|
163
|
+
const isItalic = parsed.style.toLowerCase() === 'italic';
|
|
164
|
+
|
|
165
|
+
return candidates.find(sf => {
|
|
166
|
+
if (Number(sf.weight) !== instanceWeight) return false;
|
|
167
|
+
if (isBackslant) return sf.style === 'Italic' && sf.title?.toLowerCase().includes('backslant');
|
|
168
|
+
if (isSlant) return sf.style === 'Italic' && !sf.title?.toLowerCase().includes('backslant');
|
|
169
|
+
if (isItalic) return sf.style === 'Italic';
|
|
170
|
+
return sf.style === 'Regular';
|
|
171
|
+
}) || null;
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
// Pass 6: weightName string comparison
|
|
175
|
+
{
|
|
176
|
+
name: 'weightName',
|
|
177
|
+
match: (instanceName, parsed, candidates) => {
|
|
178
|
+
const cleanInstance = parsed.weight.toLowerCase().trim();
|
|
179
|
+
return candidates.find(sf => {
|
|
180
|
+
if (!sf.weightName) return false;
|
|
181
|
+
const cleanWeight = sf.weightName.toLowerCase().replace(/italic|slant|backslant/gi, '').trim();
|
|
182
|
+
return cleanInstance === cleanWeight;
|
|
183
|
+
}) || null;
|
|
184
|
+
},
|
|
185
|
+
},
|
|
186
|
+
];
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Multi-pass variable font instance matcher.
|
|
190
|
+
*
|
|
191
|
+
* For each strategy (most confident first):
|
|
192
|
+
* 1. Try to match ALL unmatched instances against ALL unclaimed fonts
|
|
193
|
+
* 2. Collect all matches for this pass
|
|
194
|
+
* 3. Claim matched fonts, remove from both pools
|
|
195
|
+
* 4. Move to next strategy with remaining unmatched
|
|
15
196
|
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
* @returns {Promise<Array>} Array of { key, value, _key } instance mappings
|
|
197
|
+
* This prevents a less-specific match from "stealing" a font that would be
|
|
198
|
+
* the exact match for a different instance processed later.
|
|
19
199
|
*/
|
|
20
200
|
export const parseVariableFontInstances = async (font, client) => {
|
|
21
201
|
if (!font.variableFont || !font.variableInstances) return [];
|
|
@@ -30,19 +210,12 @@ export const parseVariableFontInstances = async (font, client) => {
|
|
|
30
210
|
|
|
31
211
|
if (Object.keys(variableInstances).length === 0) return [];
|
|
32
212
|
|
|
33
|
-
// Fetch
|
|
213
|
+
// Fetch static fonts
|
|
34
214
|
let staticFonts;
|
|
35
215
|
const typeface = await client.fetch(
|
|
36
216
|
`*[_type == 'typeface' && title == $typefaceName][0]{
|
|
37
217
|
'fonts': styles.fonts[]-> {
|
|
38
|
-
_id,
|
|
39
|
-
title,
|
|
40
|
-
subfamily,
|
|
41
|
-
style,
|
|
42
|
-
weight,
|
|
43
|
-
weightName,
|
|
44
|
-
metaData,
|
|
45
|
-
variableFont
|
|
218
|
+
_id, title, subfamily, style, weight, weightName, metaData, variableFont
|
|
46
219
|
}
|
|
47
220
|
}`,
|
|
48
221
|
{ typefaceName: font.typefaceName }
|
|
@@ -55,154 +228,71 @@ export const parseVariableFontInstances = async (font, client) => {
|
|
|
55
228
|
console.warn('Typeface not found or no fonts in curated list, falling back to all fonts query');
|
|
56
229
|
staticFonts = await client.fetch(
|
|
57
230
|
`*[_type == 'font' && typefaceName == $typefaceName && variableFont != true]{
|
|
58
|
-
_id,
|
|
59
|
-
title,
|
|
60
|
-
subfamily,
|
|
61
|
-
style,
|
|
62
|
-
weight,
|
|
63
|
-
weightName,
|
|
64
|
-
metaData
|
|
231
|
+
_id, title, subfamily, style, weight, weightName, metaData
|
|
65
232
|
}`,
|
|
66
233
|
{ typefaceName: font.typefaceName }
|
|
67
234
|
);
|
|
68
235
|
}
|
|
69
236
|
|
|
70
|
-
|
|
71
|
-
console.log('
|
|
237
|
+
const instanceNames = Object.keys(variableInstances);
|
|
238
|
+
console.log('Variable font instances:', instanceNames.length);
|
|
239
|
+
console.log('Available static fonts:', staticFonts.length);
|
|
72
240
|
|
|
73
|
-
|
|
241
|
+
// Parse all instance names upfront
|
|
242
|
+
const parsedInstances = instanceNames.map(name => ({
|
|
243
|
+
name,
|
|
244
|
+
parsed: parseInstanceName(name),
|
|
245
|
+
}));
|
|
74
246
|
|
|
75
|
-
|
|
76
|
-
|
|
247
|
+
// Track results and claimed fonts
|
|
248
|
+
const results = new Map(); // instanceName → { fontId, strategy }
|
|
249
|
+
const claimedFontIds = new Set();
|
|
77
250
|
|
|
78
|
-
|
|
79
|
-
|
|
251
|
+
// Multi-pass: each strategy gets a full pass over all remaining unmatched instances
|
|
252
|
+
for (const strategy of STRATEGIES) {
|
|
253
|
+
const unmatched = parsedInstances.filter(inst => !results.has(inst.name));
|
|
254
|
+
if (unmatched.length === 0) break;
|
|
80
255
|
|
|
81
|
-
//
|
|
82
|
-
|
|
83
|
-
matchingFont = staticFonts.find(sf => {
|
|
84
|
-
if (!sf.metaData?.fullName) return false;
|
|
85
|
-
|
|
86
|
-
let fullName = sf.metaData.fullName;
|
|
87
|
-
|
|
88
|
-
const WORDS_TO_REMOVE = ['VF', 'var', 'variable', 'VAR', 'vf'];
|
|
89
|
-
const variableName = font.metaData?.familyName
|
|
90
|
-
?.replace(new RegExp(`\\b(${WORDS_TO_REMOVE.join('|')})\\b`, 'gi'), '')
|
|
91
|
-
.replace(/\s{2,}/g, ' ')
|
|
92
|
-
.trim();
|
|
93
|
-
|
|
94
|
-
if (variableName && fullName.startsWith(variableName)) {
|
|
95
|
-
fullName = fullName.substring(variableName.length).trim();
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
if (variableName) {
|
|
99
|
-
const words = variableName.split(/\s+/).map(w => w.trim()).filter(Boolean);
|
|
100
|
-
if (words.length > 0) {
|
|
101
|
-
const regex = new RegExp(`\\b(${words.join('|')})\\b`, 'gi');
|
|
102
|
-
const stripped = fullName.replace(regex, '').replace(/\s{2,}/g, ' ').trim();
|
|
103
|
-
if (stripped !== '') fullName = stripped;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
|
-
|
|
107
|
-
if (fullName.startsWith(font.typefaceName)) {
|
|
108
|
-
fullName = fullName.substring(font.typefaceName.length).trim();
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
if (sf.style?.toLowerCase() === 'italic' &&
|
|
112
|
-
!fullName.toLowerCase().endsWith('italic') &&
|
|
113
|
-
!fullName.toLowerCase().endsWith('slanted')) {
|
|
114
|
-
fullName = fullName + ' Italic';
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
if (fullName.trim().toLowerCase().endsWith('regular')) {
|
|
118
|
-
if (instanceName.trim().toLowerCase() + ' regular' === fullName.trim().toLowerCase()) return true;
|
|
119
|
-
}
|
|
120
|
-
if (fullName.trim().toLowerCase().startsWith('regular')) {
|
|
121
|
-
if ('regular ' + instanceName.trim().toLowerCase() === fullName.trim().toLowerCase()) return true;
|
|
122
|
-
}
|
|
123
|
-
if (fullName.trim().toLowerCase().endsWith('italic')) {
|
|
124
|
-
if (instanceName.trim().toLowerCase().endsWith('italic')) {
|
|
125
|
-
const k = instanceName.trim().toLowerCase().slice(0, -6).trim() + ' regular italic';
|
|
126
|
-
if (k === fullName.trim().toLowerCase()) return true;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
256
|
+
// Collect all potential matches for this pass (don't claim yet)
|
|
257
|
+
const passMatches = [];
|
|
129
258
|
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
259
|
+
for (const inst of unmatched) {
|
|
260
|
+
// Get subfamily-scoped candidates that haven't been claimed
|
|
261
|
+
const subfamilyCandidates = filterBySubfamily(staticFonts, inst.parsed.subfamily, font.typefaceName)
|
|
262
|
+
.filter(sf => !claimedFontIds.has(sf._id));
|
|
133
263
|
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
const nameWithoutTypeface = sf.title.replace(font.typefaceName, '').trim();
|
|
139
|
-
return nameWithoutTypeface === expandedName;
|
|
140
|
-
});
|
|
264
|
+
const match = strategy.match(inst.name, inst.parsed, subfamilyCandidates, font.typefaceName, font);
|
|
265
|
+
if (match) {
|
|
266
|
+
passMatches.push({ instanceName: inst.name, font: match, strategy: strategy.name });
|
|
267
|
+
}
|
|
141
268
|
}
|
|
142
269
|
|
|
143
|
-
//
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
{ term: 'extralight', weight: '200' },
|
|
149
|
-
{ term: 'extra light', weight: '200' },
|
|
150
|
-
{ term: 'light', weight: '300' },
|
|
151
|
-
{ term: 'regular', weight: '400' },
|
|
152
|
-
{ term: 'normal', weight: '400' },
|
|
153
|
-
{ term: 'medium', weight: '500' },
|
|
154
|
-
{ term: 'semibold', weight: '600' },
|
|
155
|
-
{ term: 'semi bold', weight: '600' },
|
|
156
|
-
{ term: 'bold', weight: '700' },
|
|
157
|
-
{ term: 'extrabold', weight: '800' },
|
|
158
|
-
{ term: 'extra bold', weight: '800' },
|
|
159
|
-
{ term: 'black', weight: '900' },
|
|
160
|
-
{ term: 'heavy', weight: '900' },
|
|
161
|
-
];
|
|
162
|
-
|
|
163
|
-
let instanceWeight = '400';
|
|
164
|
-
for (const { term, weight } of weightTerms) {
|
|
165
|
-
if (instanceName.toLowerCase().includes(term)) {
|
|
166
|
-
instanceWeight = weight;
|
|
167
|
-
break;
|
|
168
|
-
}
|
|
270
|
+
// Claim matches — if multiple instances matched the same font, the first one wins
|
|
271
|
+
for (const m of passMatches) {
|
|
272
|
+
if (!claimedFontIds.has(m.font._id) && !results.has(m.instanceName)) {
|
|
273
|
+
results.set(m.instanceName, { fontId: m.font._id, strategy: m.strategy });
|
|
274
|
+
claimedFontIds.add(m.font._id);
|
|
169
275
|
}
|
|
170
|
-
|
|
171
|
-
matchingFont = staticFonts.find(sf =>
|
|
172
|
-
sf.weight === instanceWeight &&
|
|
173
|
-
((isItalic && sf.style === 'Italic') || (!isItalic && sf.style === 'Regular'))
|
|
174
|
-
);
|
|
175
276
|
}
|
|
277
|
+
}
|
|
176
278
|
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
if (!sf.weightName) return false;
|
|
181
|
-
const cleanInstance = instanceName.toLowerCase().replace(/italic/i, '').trim();
|
|
182
|
-
const cleanWeight = sf.weightName.toLowerCase().replace(/italic/i, '').trim();
|
|
183
|
-
return cleanInstance === cleanWeight;
|
|
184
|
-
});
|
|
185
|
-
}
|
|
279
|
+
// Build output
|
|
280
|
+
const matched = [...results.values()].length;
|
|
281
|
+
console.log(`[parseVariableFontInstances] Matched ${matched}/${instanceNames.length} instances across ${STRATEGIES.length} passes`);
|
|
186
282
|
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
if (!sf.metaData?.fullName) return false;
|
|
191
|
-
const typefacePattern = new RegExp(`^${font.typefaceName}\\s+`, 'i');
|
|
192
|
-
const stylePart = sf.metaData.fullName.replace(typefacePattern, '').trim();
|
|
193
|
-
return instanceName.toLowerCase() === stylePart.toLowerCase();
|
|
194
|
-
});
|
|
195
|
-
}
|
|
283
|
+
const instanceMappings = instanceNames.map(instanceName => {
|
|
284
|
+
const result = results.get(instanceName);
|
|
285
|
+
const matchedFont = result ? staticFonts.find(sf => sf._id === result.fontId) : null;
|
|
196
286
|
|
|
197
|
-
console.log(`Instance "${instanceName}"
|
|
287
|
+
console.log(`Instance "${instanceName}" → ${matchedFont ? `${matchedFont.title} (${result.strategy})` : 'No match'}`);
|
|
198
288
|
|
|
199
|
-
|
|
289
|
+
return {
|
|
200
290
|
key: instanceName,
|
|
201
|
-
value:
|
|
202
|
-
? { _type: 'reference', _ref:
|
|
291
|
+
value: matchedFont
|
|
292
|
+
? { _type: 'reference', _ref: matchedFont._id, _weak: true }
|
|
203
293
|
: null,
|
|
204
294
|
_key: nanoid(),
|
|
205
|
-
}
|
|
295
|
+
};
|
|
206
296
|
});
|
|
207
297
|
|
|
208
298
|
return instanceMappings;
|
|
@@ -168,16 +168,17 @@ export const extractFontMetadata = (font, title, weightKeywordList, italicKeywor
|
|
|
168
168
|
|
|
169
169
|
const fullName = getNameString(font, 4) || ttfFallbackMeta?.fullName || '';
|
|
170
170
|
|
|
171
|
-
|
|
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) {
|
|
172
176
|
weightName = extractWeightFromFullName(font, title, ttfFallbackMeta);
|
|
173
177
|
if (!preserveShortenedNames) {
|
|
174
178
|
weightName = expandAbbreviations(weightName);
|
|
175
179
|
}
|
|
176
180
|
}
|
|
177
181
|
|
|
178
|
-
const axes = getVariationAxes(font);
|
|
179
|
-
const variableFont = axes !== null;
|
|
180
|
-
|
|
181
182
|
// Subfamily detection — extract width/optical variant from name table.
|
|
182
183
|
// Primary: nameId4 (fullName) minus typeface title — the most complete name record,
|
|
183
184
|
// always contains width + weight (e.g. "Gear XXNarrow Regular" → "XXNarrow Regular").
|
|
@@ -38,9 +38,22 @@ export const updateTypefaceDocument = async (
|
|
|
38
38
|
|
|
39
39
|
// Use dot-path keys so .set() does not clobber sibling fields
|
|
40
40
|
// (styles.collections, styles.pairs, styles.free, styles.displayStyles)
|
|
41
|
+
// Deduplicate by _ref to prevent duplicate entries on re-upload
|
|
42
|
+
const dedupeRefs = (existing, incoming) => {
|
|
43
|
+
const merged = [...(existing || [])];
|
|
44
|
+
const existingRefs = new Set(merged.map(r => r._ref).filter(Boolean));
|
|
45
|
+
incoming.forEach(ref => {
|
|
46
|
+
if (ref._ref && !existingRefs.has(ref._ref)) {
|
|
47
|
+
merged.push(ref);
|
|
48
|
+
existingRefs.add(ref._ref);
|
|
49
|
+
}
|
|
50
|
+
});
|
|
51
|
+
return merged;
|
|
52
|
+
};
|
|
53
|
+
|
|
41
54
|
let patch = {
|
|
42
|
-
'styles.fonts': stylesObject.fonts
|
|
43
|
-
'styles.variableFont': stylesObject?.variableFont
|
|
55
|
+
'styles.fonts': dedupeRefs(stylesObject.fonts, fontRefs),
|
|
56
|
+
'styles.variableFont': dedupeRefs(stylesObject?.variableFont, variableRefs),
|
|
44
57
|
};
|
|
45
58
|
|
|
46
59
|
setStatus('Organising font subfamilies...');
|