Skip to content

Commit d479bce

Browse files
authored
Add font family support for QR code frames (#255)
This pull request adds font family support to QR code frames in both single and batch export modes. ## Where to find it ![CleanShot 2026-03-13 at 20.20.09@2x.png](https://app.graphite.com/user-attachments/assets/656a75a6-8be3-45eb-9258-866481f9e6d7.png) ![CleanShot 2026-03-13 at 20.20.19@2x.png](https://app.graphite.com/user-attachments/assets/cd4479ac-171b-4700-af7f-7ba5a98b214e.png) ## Testing - Batch export template also supports font family: https://cleanshot.com/share/f6K969Hr - Added e2e and unit tests for frame font family ## Changes - Added `frameFontFamily` field support to CSV batch processing for both simple and vCard formats - Introduced a curated font selection dropdown with web-safe fonts (Arial, Georgia, Verdana) and popular Google Fonts (Roboto, Open Sans, Poppins, etc.) - Implemented dynamic Google Font loading when non-system fonts are selected - Updated the frame component to apply the selected font family to frame text - Enhanced the batch preview panel to display the font family for each row when specified - Modified CSV parsing to handle the new `frameFontFamily` column - Updated CSV examples and field guides to include the new font family option - Added comprehensive test coverage for font family validation and CSV parsing The font family can be specified in CSV files using either the font label (e.g., "Poppins") or the full CSS value (e.g., "'Poppins', sans-serif"). Google Fonts are automatically loaded when selected, while system fonts require no additional loading.
1 parent 2cdd0a1 commit d479bce

10 files changed

Lines changed: 323 additions & 29 deletions

src/components/BatchExportFieldsGuide.vue

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ const closeAccordion = () => {
1818
}
1919
2020
const simpleFields: Array<{
21-
name: keyof SimpleCSVData
21+
name: string
2222
required: boolean
2323
description: string
2424
example: string
@@ -40,11 +40,17 @@ const simpleFields: Array<{
4040
required: false,
4141
description: 'Custom filename for the exported QR code',
4242
example: 'my_custom_name'
43+
},
44+
{
45+
name: 'frameFontFamily',
46+
required: false,
47+
description: 'Font family for the frame text (must be one of the predefined options)',
48+
example: 'Poppins'
4349
}
4450
]
4551
4652
const vCardFields: Array<{
47-
name: keyof VCardCSVData
53+
name: string
4854
required: boolean
4955
description: string
5056
example: string
@@ -75,17 +81,23 @@ const vCardFields: Array<{
7581
required: false,
7682
description: 'Custom filename for the exported QR code',
7783
example: 'john_doe_contact'
84+
},
85+
{
86+
name: 'frameFontFamily',
87+
required: false,
88+
description: 'Font family for the frame text (must be one of the predefined options)',
89+
example: 'Roboto'
7890
}
7991
]
8092
81-
const simpleCsvExample = `url,frameText,fileName
82-
https://example.com,Visit us,site
83-
https://github.com/user,GitHub,github
84-
https://linkedin.com,LinkedIn,linkedin`
93+
const simpleCsvExample = `url,frameText,fileName,frameFontFamily
94+
https://example.com,Visit us,site,Poppins
95+
https://github.com/user,GitHub,github,Roboto
96+
https://linkedin.com,LinkedIn,linkedin,`
8597
86-
const vCardCsvExample = `firstName,lastName,org,email,frameText
87-
John,Doe,Acme,john@ex.com,Contact
88-
Jane,Smith,Tech,jane@ex.com,Manager`
98+
const vCardCsvExample = `firstName,lastName,org,email,frameText,frameFontFamily
99+
John,Doe,Acme,john@ex.com,Contact,Roboto
100+
Jane,Smith,Tech,jane@ex.com,Manager,`
89101
</script>
90102

91103
<template>

src/components/QRCodeCreate.vue

Lines changed: 73 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,16 +36,18 @@ import { getNumericCSSValue } from '@/utils/formatting'
3636
import {
3737
allFramePresets,
3838
defaultFramePreset,
39+
FONT_OPTIONS,
40+
loadGoogleFont,
3941
type FramePreset,
4042
type FrameStyle
4143
} from '@/utils/framePresets'
4244
import { allQrCodePresets, defaultPreset, type Preset } from '@/utils/qrCodePresets'
4345
import {
4446
CUSTOM_LOADED_PRESET_KEYS,
45-
LAST_LOADED_LOCALLY_PRESET_KEY,
46-
LOADED_FROM_FILE_PRESET_KEY,
4747
hasStoredQRConfig,
4848
isLocalStorageEnabled,
49+
LAST_LOADED_LOCALLY_PRESET_KEY,
50+
LOADED_FROM_FILE_PRESET_KEY,
4951
loadQRConfig,
5052
saveQRConfig,
5153
serializeQRConfig,
@@ -378,6 +380,17 @@ const frameSettings = computed(() => ({
378380
position: frameTextPosition.value,
379381
style: frameStyle.value
380382
}))
383+
384+
function onFontFamilyChange(value: string): Promise<void> {
385+
// value may be a label name (e.g. "Poppins" from CSV) or a full CSS value (e.g. "'Poppins', sans-serif" from UI)
386+
const font = FONT_OPTIONS.find((f) => f.value === value || f.label === value)
387+
const resolvedValue = font ? font.value : value
388+
frameStyle.value = { ...frameStyle.value, fontFamily: resolvedValue || undefined }
389+
if (font?.googleFontName) {
390+
return loadGoogleFont(font.googleFontName)
391+
}
392+
return Promise.resolve()
393+
}
381394
//#endregion
382395
383396
//#region /* Frame text autofill */ Fill if empty */
@@ -539,7 +552,7 @@ function downloadQRImage(format: 'png' | 'svg' | 'jpg') {
539552
el,
540553
formatConfig.filename,
541554
{ ...getExportDimensions(), ...formatConfig.extraOptions },
542-
styledBorderRadiusFormatted.value
555+
exportBorderRadius.value
543556
)
544557
} else {
545558
generateBatchQRCodes(format)
@@ -587,6 +600,14 @@ function applyQRConfig(config: QRCodeConfig, key?: string) {
587600
frameTextPosition.value = config.frame.position || 'bottom'
588601
frameStyle.value = { ...frameStyle.value, ...config.frame.style }
589602
603+
const restoredFontFamily = config.frame.style.fontFamily
604+
if (restoredFontFamily) {
605+
const font = FONT_OPTIONS.find((f) => f.value === restoredFontFamily)
606+
if (font?.googleFontName) {
607+
loadGoogleFont(font.googleFontName)
608+
}
609+
}
610+
590611
const framePreset: FramePreset = {
591612
name: key || LAST_LOADED_LOCALLY_PRESET_KEY,
592613
style: config.frame.style,
@@ -672,6 +693,7 @@ const exportMode = ref(ExportMode.Single)
672693
const dataStringsFromCsv = ref<string[]>([])
673694
const frameTextsFromCsv = ref<string[]>([])
674695
const fileNamesFromCsv = ref<string[]>([])
696+
const fontFamiliesFromCsv = ref<string[]>([])
675697
676698
const inputFileForBatchEncoding = ref<File | null>(null)
677699
const fileInput = ref<HTMLInputElement | null>(null)
@@ -709,6 +731,7 @@ const resetData = () => {
709731
dataStringsFromCsv.value = []
710732
frameTextsFromCsv.value = []
711733
fileNamesFromCsv.value = []
734+
fontFamiliesFromCsv.value = []
712735
isValidCsv.value = true
713736
resetBatchExportProgress()
714737
isBatchExportSuccess.value = false
@@ -727,6 +750,10 @@ watch(previewRowIndex, (newIndex) => {
727750
) {
728751
data.value = dataStringsFromCsv.value[newIndex]
729752
frameText.value = frameTextsFromCsv.value[newIndex] || defaultFrameText.value
753+
const fontFamily = fontFamiliesFromCsv.value[newIndex]
754+
if (fontFamily !== undefined) {
755+
onFontFamilyChange(fontFamily)
756+
}
730757
}
731758
})
732759
@@ -778,6 +805,7 @@ const onBatchInputFileUpload = (event: Event) => {
778805
dataStringsFromCsv.value = batchResult.urls
779806
frameTextsFromCsv.value = batchResult.frameTexts
780807
fileNamesFromCsv.value = batchResult.fileNames
808+
fontFamiliesFromCsv.value = batchResult.fontFamilies
781809
showFrame.value = batchResult.hasCustomFrameText
782810
isValidCsv.value = true
783811
previewRowIndex.value = 0 // Reset preview to first row on new upload
@@ -786,6 +814,10 @@ const onBatchInputFileUpload = (event: Event) => {
786814
if (batchResult.urls.length > 0) {
787815
data.value = batchResult.urls[0]
788816
frameText.value = batchResult.frameTexts[0] || defaultFrameText.value
817+
const firstFontFamily = batchResult.fontFamilies[0]
818+
if (firstFontFamily) {
819+
onFontFamilyChange(firstFontFamily)
820+
}
789821
}
790822
}
791823
@@ -837,22 +869,26 @@ async function generateBatchQRCodes(format: 'png' | 'svg' | 'jpg') {
837869
currentExportedQrCodeIndex.value = index
838870
const url = dataStringsFromCsv.value[index]
839871
const currentFrameText = frameTextsFromCsv.value[index]
872+
const currentFontFamily = fontFamiliesFromCsv.value[index]
840873
data.value = url
841874
frameText.value = currentFrameText
875+
if (currentFontFamily !== undefined) {
876+
await onFontFamilyChange(currentFontFamily)
877+
}
842878
await sleep(1000)
843879
let dataUrl: string = ''
844880
if (format === 'png') {
845-
dataUrl = await getPngElement(el, getExportDimensions(), styledBorderRadiusFormatted.value)
881+
dataUrl = await getPngElement(el, getExportDimensions(), exportBorderRadius.value)
846882
} else if (format === 'jpg') {
847883
const jpgBgcolor =
848884
styleBackground.value === 'transparent' ? '#ffffff' : styleBackground.value
849885
dataUrl = await getJpgElement(
850886
el,
851887
{ ...getExportDimensions(), bgcolor: jpgBgcolor },
852-
styledBorderRadiusFormatted.value
888+
exportBorderRadius.value
853889
)
854890
} else {
855-
dataUrl = await getSvgString(el, getExportDimensions(), styledBorderRadiusFormatted.value)
891+
dataUrl = await getSvgString(el, getExportDimensions(), exportBorderRadius.value)
856892
}
857893
createZipFile(zip, dataUrl, index, format)
858894
numQrCodesCreated++
@@ -1425,6 +1461,26 @@ const updateDataFromModal = (newData: string) => {
14251461
placeholder="16px"
14261462
/>
14271463
</div>
1464+
<div class="sm:col-span-2">
1465+
<label for="frame-font-family" class="mb-1 block text-sm">{{
1466+
t('Font family')
1467+
}}</label>
1468+
<select
1469+
id="frame-font-family"
1470+
class="w-full text-input"
1471+
:value="frameStyle.fontFamily ?? ''"
1472+
@change="onFontFamilyChange(($event.target as HTMLSelectElement).value)"
1473+
>
1474+
<option
1475+
v-for="font in FONT_OPTIONS"
1476+
:key="font.value"
1477+
:value="font.value"
1478+
:style="font.value ? { fontFamily: font.value } : {}"
1479+
>
1480+
{{ font.label }}
1481+
</option>
1482+
</select>
1483+
</div>
14281484
</div>
14291485
</div>
14301486
</template>
@@ -1626,6 +1682,17 @@ const updateDataFromModal = (newData: string) => {
16261682
{{ fileNamesFromCsv[previewRowIndex] }}
16271683
</code>
16281684
</div>
1685+
<div v-if="fontFamiliesFromCsv[previewRowIndex]">
1686+
<span
1687+
class="text-xs font-medium text-gray-500 dark:text-gray-400"
1688+
>{{ $t('Font family') }}</span
1689+
>
1690+
<code
1691+
class="rounded bg-white px-2 py-1 font-mono text-sm dark:bg-gray-900"
1692+
>
1693+
{{ fontFamiliesFromCsv[previewRowIndex] }}
1694+
</code>
1695+
</div>
16291696
</div>
16301697
</div>
16311698
<div class="mt-2 flex items-center justify-between">

src/components/QRCodeFrame.vue

Lines changed: 13 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ interface FrameStyle {
66
borderWidth?: string
77
borderRadius?: string
88
padding?: string
9+
fontFamily?: string
910
}
1011
1112
interface Props {
@@ -44,16 +45,17 @@ withDefaults(defineProps<Props>(), {
4445
}"
4546
>
4647
<slot name="qr-code"></slot>
47-
<p
48-
:style="{
49-
color: frameStyle.textColor,
50-
margin: 0,
51-
textAlign: 'center',
52-
[textPosition === 'left' || textPosition === 'right' ? 'width' : 'maxWidth']: '200px',
53-
whiteSpace: 'pre-line'
54-
}"
55-
>
56-
{{ frameText }}
57-
</p>
48+
<p
49+
:style="{
50+
color: frameStyle.textColor,
51+
fontFamily: frameStyle.fontFamily || undefined,
52+
margin: 0,
53+
textAlign: 'center',
54+
[textPosition === 'left' || textPosition === 'right' ? 'width' : 'maxWidth']: '200px',
55+
whiteSpace: 'pre-line'
56+
}"
57+
>
58+
{{ frameText }}
59+
</p>
5860
</div>
5961
</template>

src/utils/csv.test.ts

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,53 @@ https://docs.example.com/,,documentation`
107107
})
108108
})
109109

110+
it('parses simple CSV with frameFontFamily column correctly', () => {
111+
const csvContent = `url,frameText,fileName,frameFontFamily
112+
https://example.com,Visit us,site,Poppins
113+
https://github.com,GitHub,github,Roboto
114+
https://linkedin.com,LinkedIn,linkedin,`
115+
116+
const result = parseCSV(csvContent)
117+
expect(result.isValid).toBe(true)
118+
expect(result.data).toHaveLength(3)
119+
expect(result.data[0]).toEqual({
120+
url: 'https://example.com',
121+
frameText: 'Visit us',
122+
fileName: 'site',
123+
frameFontFamily: 'Poppins'
124+
})
125+
expect(result.data[1]).toEqual({
126+
url: 'https://github.com',
127+
frameText: 'GitHub',
128+
fileName: 'github',
129+
frameFontFamily: 'Roboto'
130+
})
131+
expect(result.data[2]).toEqual({
132+
url: 'https://linkedin.com',
133+
frameText: 'LinkedIn',
134+
fileName: 'linkedin',
135+
frameFontFamily: undefined
136+
})
137+
})
138+
139+
it('parses vCard CSV with frameFontFamily column correctly', () => {
140+
const csvContent = `firstname,lastname,email,frameText,frameFontFamily
141+
John,Doe,john@example.com,Contact John,Poppins
142+
Jane,Smith,jane@example.com,Contact Jane,`
143+
144+
const result = parseCSV(csvContent)
145+
expect(result.isValid).toBe(true)
146+
expect(result.data).toHaveLength(2)
147+
expect(result.data[0]).toMatchObject({
148+
firstName: 'John',
149+
lastName: 'Doe',
150+
email: 'john@example.com',
151+
frameText: 'Contact John',
152+
frameFontFamily: 'Poppins'
153+
})
154+
expect((result.data[1] as any).frameFontFamily).toBeUndefined()
155+
})
156+
110157
it('handles empty CSV content', () => {
111158
const result = parseCSV('')
112159
expect(result.isValid).toBe(false)

src/utils/csv.ts

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ export interface SimpleCSVData {
22
url: string
33
frameText?: string
44
fileName?: string
5+
frameFontFamily?: string
56
}
67

78
export type VCardOptionalFields =
@@ -24,6 +25,7 @@ export interface VCardCSVData extends Partial<Record<VCardOptionalFields, string
2425
firstName: string
2526
lastName: string
2627
fileName?: string
28+
frameFontFamily?: string
2729
}
2830

2931
export type CSVData = SimpleCSVData | VCardCSVData
@@ -163,6 +165,12 @@ export const parseCSV = (csvContent: string): CSVParsingResult => {
163165
vCardData.fileName = values[fileNameIndex] || ''
164166
}
165167

168+
// Handle frameFontFamily separately (CSV column: frameFontFamily)
169+
const frameFontFamilyIndex = headers.indexOf('framefontfamily')
170+
if (frameFontFamilyIndex !== -1 && values[frameFontFamilyIndex]) {
171+
vCardData.frameFontFamily = values[frameFontFamilyIndex]
172+
}
173+
166174
data.push(vCardData as VCardCSVData)
167175
}
168176
} else {
@@ -187,7 +195,10 @@ export const parseCSV = (csvContent: string): CSVParsingResult => {
187195
values.push(currentValue.trim().replace(/^["']|["']$/g, ''))
188196

189197
const [url, frameText, fileName] = values
190-
data.push({ url, frameText, fileName })
198+
const frameFontFamilyIndex = headers.indexOf('framefontfamily')
199+
const frameFontFamily =
200+
frameFontFamilyIndex >= 0 ? values[frameFontFamilyIndex] || undefined : undefined
201+
data.push({ url, frameText, fileName, frameFontFamily })
191202
}
192203
}
193204

0 commit comments

Comments
 (0)