Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
e8bc207
feat: add label designer print page
akira69 May 6, 2026
6688b8b
feat: wire Print Label button to new label designer
akira69 May 6, 2026
2e88b34
i18n: add label designer translation keys (en/de)
akira69 May 6, 2026
fd13ef3
fix: improve QR custom URL UX and markup parsing
akira69 May 6, 2026
ced7d68
feat: add configurable color swatch token for label designer templates
akira69 May 6, 2026
f60f350
fix: refine designer template inputs and QR field layout
akira69 May 6, 2026
947300e
feat: add filament-color inverse markup for label designer
akira69 May 6, 2026
0e9b769
fix: preserve inverse text colors in print labels
akira69 May 15, 2026
6f690dd
fix: map id token to correct spool data
akira69 May 15, 2026
b003fb6
fix: show 'Saved presets' placeholder instead of auto-selecting first…
akira69 May 15, 2026
1a4e9d8
fix: prevent advanced and standard label tabs from polluting each other
akira69 May 15, 2026
1b117a8
fix: harden label designer security, validation, and performance
akira69 May 15, 2026
bb75ab1
refactor: eliminate duplication in form field setting - consolidate b…
akira69 May 15, 2026
9490570
fix: prevent stale label preview renders on tab switch
akira69 May 15, 2026
63a1455
fix: honor logo justification when scale-to-fit is enabled
akira69 May 15, 2026
300ed67
fix: resolve tab contamination and stale qrContainer reference in pri…
akira69 May 18, 2026
75e5533
fix: clear zoom transform for printing and tighten activateTab style …
akira69 May 19, 2026
d4936e6
chore: add E2E smoke screenshots for advanced label designer
akira69 May 19, 2026
388ac9b
chore: untrack _pr screenshots (local-only; not for upstream merge)
akira69 May 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
54 changes: 53 additions & 1 deletion frontend/src/i18n/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -451,7 +451,59 @@
"colPurchaseDate": "Kaufdatum",
"colLastUsed": "Zuletzt verwendet",
"colTotalWeight": "Gesamtgewicht",
"colEmptyWeight": "Leergewicht"
"colEmptyWeight": "Leergewicht",
"labelDesigner": "Label Designer",
"dsPresets": "Vorlagen",
"dsPresetsSaved": "Gespeicherte Vorlagen",
"dsPresetsLoad": "Laden",
"dsPresetsDelete": "Ausgewählte Vorlage löschen",
"dsPresetsName": "Vorlagenname",
"dsPresetsSaveNew": "Als Neu speichern",
"dsPresetsOverwrite": "Überschreiben",
"dsLogo": "Logo",
"dsLogoSize": "Logohöhe (mm)",
"dsLogoSpace": "Verfügbare Höhe (mm)",
"dsLogoManual": "Max. Logohöhe (mm)",
"dsLogoFit": "In Begrenzung einpassen",
"dsLabel": "Etikett",
"dsMargin": "Rand (mm)",
"dsBorder": "Druckrahmen",
"dsTitle": "Titel",
"dsTitleSize": "Max. Schriftgröße (mm)",
"dsTitleMargin": "Rand (mm)",
"dsTitleFit": "Breite anpassen",
"dsTitle2Show": "Untertitel",
"dsDividerAbove": "Linie oben",
"dsDividerBelow": "Linie unten",
"dsJustification": "Ausrichtung",
"dsAlign": "Ausrichtung",
"dsHAlign": "Horizontale Ausrichtung",
"dsVAlign": "Vertikale Ausrichtung",
"dsTemplate": "Format",
"dsTitleFormat": "Titelformat",
"dsSubtitleFormat": "Untertitelformat",
"dsInfoFormat": "Informationsformat",
"dsSideColumnFormat": "Seitenformat",
"dsQrMode": "Modus",
"dsQrNone": "Keiner",
"dsQrSimple": "Einfach",
"dsQrIcon": "Logo",
"dsQrColorLogo": "Farbiges Logo",
"dsQrPosition": "Position",
"dsQrLink": "Linktyp",
"dsQrSpoolUrl": "Spul-URL",
"dsQrCustomUrl": "Eigene URL",
"dsQrCustomBase": "Basis-URL",
"dsQrCustomBaseHint": "Die Spulen-ID wird automatisch angehängt.",
"dsInfo": "Informationen",
"dsInfoSize": "Textgröße (mm)",
"dsInfo2Show": "Seitenspalte",
"dsInfo2Vsep": "Vertikaler Trenner",
"dsLeft": "Links",
"dsRight": "Rechts",
"dsTop": "Oben",
"dsMid": "Mitte",
"dsBot": "Unten"
},
"colors": {
"title": "Farben verwalten",
Expand Down
54 changes: 53 additions & 1 deletion frontend/src/i18n/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -449,7 +449,59 @@
"colPurchaseDate": "Purchase Date",
"colLastUsed": "Last Used",
"colTotalWeight": "Total Weight",
"colEmptyWeight": "Empty Weight"
"colEmptyWeight": "Empty Weight",
"labelDesigner": "Label Designer",
"dsPresets": "Presets",
"dsPresetsSaved": "Saved presets",
"dsPresetsLoad": "Load",
"dsPresetsDelete": "Delete selected preset",
"dsPresetsName": "Preset name",
"dsPresetsSaveNew": "Save as New",
"dsPresetsOverwrite": "Overwrite",
"dsLogo": "Logo",
"dsLogoSize": "Logo height (mm)",
"dsLogoSpace": "Space height (mm)",
"dsLogoManual": "Max logo height (mm)",
"dsLogoFit": "Scale to fit bounds",
"dsLabel": "Label",
"dsMargin": "Margin (mm)",
"dsBorder": "Print Border",
"dsTitle": "Title",
"dsTitleSize": "Max font size (mm)",
"dsTitleMargin": "Margin (mm)",
"dsTitleFit": "Fit to width",
"dsTitle2Show": "Subtitle",
"dsDividerAbove": "Line above",
"dsDividerBelow": "Line below",
"dsJustification": "Justification",
"dsAlign": "Alignment",
"dsHAlign": "Horizontal align",
"dsVAlign": "Vertical align",
"dsTemplate": "Format",
"dsTitleFormat": "Title Format",
"dsSubtitleFormat": "Subtitle Format",
"dsInfoFormat": "Information Format",
"dsSideColumnFormat": "Side Column Format",
"dsQrMode": "Mode",
"dsQrNone": "None",
"dsQrSimple": "Simple",
"dsQrIcon": "Logo",
"dsQrColorLogo": "Color Logo",
"dsQrPosition": "Position",
"dsQrLink": "Link mode",
"dsQrSpoolUrl": "Spool URL",
"dsQrCustomUrl": "Custom URL",
"dsQrCustomBase": "Custom URL base",
"dsQrCustomBaseHint": "Spool ID is appended automatically.",
"dsInfo": "Information",
"dsInfoSize": "Text size (mm)",
"dsInfo2Show": "Side Column",
"dsInfo2Vsep": "Vertical separator",
"dsLeft": "Left",
"dsRight": "Right",
"dsTop": "Top",
"dsMid": "Mid",
"dsBot": "Bot"
},
"colors": {
"title": "Manage Colors",
Expand Down
227 changes: 227 additions & 0 deletions frontend/src/lib/label-template.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,227 @@
/**
* Label template parser for the Advanced Label Designer.
*
* Template syntax:
* {token} — simple dot-path substitution; resolves to "?" if missing
* {prefix{token}suffix} — optional block: rendered as prefix+value+suffix if token is not "?"
* omitted entirely when token resolves to "?"
* **bold** — <strong> text
* *italic* — <em> text (single asterisk, not part of **)
* ==inverse== — inverted text (black bg, white text)
* @@inverse@@ — inverted text using filament color with automatic black/white text
* [size=120]text[/size] — inline relative size in percent (50..300)
* [size=120%]text[/size] — same as above; percent sign is optional
* {color_swatch[8]} — inline color bar using filament.color_hex; width is in ch units (default 1)
* \n — line-break (<br>)
*
* SpoolData is a flat object passed from the print page; the "extra" key holds
* extra-field values keyed by field key.
*/

export interface SpoolData {
id: string | number
'filament.name': string
'filament.material': string
'filament.color': string
'filament.color_hex': string
'filament.manufacturer': string
'filament.extruder_temp': string | number
'filament.bed_temp': string | number
'filament.weight': string | number
extra?: Record<string, string>
[key: string]: unknown
}

const SWATCH_MARKER_RE = /^\[\[FM_SWATCH\|(\d{1,3})\|(#[0-9A-F]{6})\]\]$/
const MAX_TEMPLATE_CHARS = 8000
const MAX_MARKUP_CHARS = 12000

export function normalizeHexColor(raw: unknown): string | null {

if (raw === undefined || raw === null) return null
const hex = String(raw).trim().replace(/^#/, '')
if (!hex) return null
if (/^[0-9a-fA-F]{3}$/.test(hex)) {
const [a, b, c] = hex.split('')
return `#${(a + a + b + b + c + c).toUpperCase()}`
}
if (/^[0-9a-fA-F]{6}$/.test(hex)) return `#${hex.toUpperCase()}`
return null
}

export function getReadableTextColor(backgroundHex: string | null): '#000' | '#fff' {
if (!backgroundHex) return '#fff'
const hex = backgroundHex.replace('#', '')
const rgb = [0, 2, 4].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255)
const linear = rgb.map((channel) => (channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4))
const luminance = (0.2126 * linear[0]) + (0.7152 * linear[1]) + (0.0722 * linear[2])
const contrastWithBlack = (luminance + 0.05) / 0.05
const contrastWithWhite = 1.05 / (luminance + 0.05)
return contrastWithBlack >= contrastWithWhite ? '#000' : '#fff'
}

function getFilamentColorTheme(data: SpoolData): { background: string; foreground: '#000' | '#fff' } {
const background = normalizeHexColor(data['filament.color_hex']) ?? '#000000'
return { background, foreground: getReadableTextColor(background) }
}

function parseColorSwatchToken(token: string): number | null {
const m = token.trim().match(/^color(?:-|_)swatch(?:\[(\d{1,3})\])?$/i)
if (!m) return null
const width = m[1] ? Number(m[1]) : 1
return Math.max(1, Math.min(40, width))
}

function renderColorSwatchMarker(token: string, data: SpoolData): string | null {
const widthCh = parseColorSwatchToken(token)
if (widthCh === null) return null
const hex = normalizeHexColor(data['filament.color_hex'])
if (!hex) return ''
return `[[FM_SWATCH|${widthCh}|${hex}]]`
}

/** Resolve a dot-path token against the spool data object. */
function resolveToken(token: string, data: SpoolData): string {
if (token.startsWith('extra.')) {
const key = token.slice(6)
const val = data.extra?.[key]
return val !== undefined && val !== '' ? String(val) : '?'
}
const val = (data as Record<string, unknown>)[token]
if (val === undefined || val === null || val === '') return '?'
return String(val)
}

/** Expand {token} and {prefix{token}suffix} placeholders to plain text. */
export function renderTemplateText(template: string, data: SpoolData): string {
const boundedTemplate = template.length > MAX_TEMPLATE_CHARS
? template.slice(0, MAX_TEMPLATE_CHARS)
: template
// Match both optional-block {{inner}} style and simple {token}
// Process longest matches first (optional blocks) before simple tokens.
return boundedTemplate.replace(
/{(?:[^{}]|{[^{}]*})*}/g,
(match) => {
// Optional block: {prefix{token}suffix}
const optional = match.match(/^\{(.*?)\{([^{}]+)\}(.*?)\}$/)
if (optional) {
const [, prefix, token, suffix] = optional
const swatchMarker = renderColorSwatchMarker(token, data)
if (swatchMarker !== null) return swatchMarker === '' ? '' : prefix + swatchMarker + suffix
const resolved = resolveToken(token, data)
return resolved === '?' ? '' : prefix + resolved + suffix
}
// Simple token: {token}
const token = match.slice(1, -1)
const swatchMarker = renderColorSwatchMarker(token, data)
if (swatchMarker !== null) return swatchMarker
const resolved = resolveToken(token, data)
return resolved === '?' ? '' : resolved
}
)
}

/** Apply **bold**, *italic*, ==inverse==, @@inverse@@ and [size=..] inline markup. */
function applyMarkup(text: string, frag: DocumentFragment | HTMLElement, data: SpoolData): void {
// Regex: match swatch marker, [size=NNN]...[/size] (case-insensitive),
// bold (**…**), italic (*…*), inverse (==…==), filament inverse (@@…@@)
const regex = /(\[\[FM_SWATCH\|\d{1,3}\|#[0-9A-F]{6}\]\]|\[size=\d{1,3}%?\][\s\S]*?\[\/size\]|\*\*[\s\S]*?\*\*|\*(?!\*)([\s\S]*?)\*(?!\*)|==[\s\S]*?==|@@[\s\S]*?@@)/gi
let last = 0

const appendPlainText = (raw: string, container: DocumentFragment | HTMLElement) => {
// Split on newlines and insert <br>
const lines = raw.split('\n')
lines.forEach((line, i) => {
if (line) container.appendChild(document.createTextNode(line))
if (i < lines.length - 1) container.appendChild(document.createElement('br'))
})
}

let match: RegExpExecArray | null
while ((match = regex.exec(text)) !== null) {
// Text before this match
if (match.index > last) {
appendPlainText(text.slice(last, match.index), frag)
}

const part = match[0]

const swatch = part.match(SWATCH_MARKER_RE)
if (swatch) {
const [, widthCh, hex] = swatch
const el = document.createElement('span')
el.style.display = 'inline-block'
el.style.width = `${Number(widthCh)}ch`
el.style.height = '0.82em'
el.style.backgroundColor = hex
el.style.borderRadius = '0.14em'
el.style.border = '1px solid rgba(0,0,0,0.28)'
el.style.verticalAlign = 'baseline'
el.style.margin = '0 0.2ch'
frag.appendChild(el)
} else if (/^\[size=/i.test(part) && /\[\/size\]$/i.test(part)) {
const sized = part.match(/^\[size=(\d{1,3})%?\]([\s\S]*?)\[\/size\]$/i)
if (sized) {
const [, rawPct, inner] = sized
const pct = Math.max(50, Math.min(300, Number(rawPct)))
const el = document.createElement('span')
el.style.fontSize = `${pct}%`
applyMarkup(inner, el, data)
frag.appendChild(el)
} else {
appendPlainText(part, frag)
}
} else if (part.startsWith('**') && part.endsWith('**')) {
const inner = part.slice(2, -2)
const el = document.createElement('strong')
applyMarkup(inner, el, data)
frag.appendChild(el)
} else if (part.startsWith('==') && part.endsWith('==')) {
const inner = part.slice(2, -2)
const el = document.createElement('span')
el.style.backgroundColor = '#000'
el.style.color = '#fff'
el.style.padding = '0 0.6mm'
el.style.display = 'inline-block'
applyMarkup(inner, el, data)
frag.appendChild(el)
} else if (part.startsWith('@@') && part.endsWith('@@')) {
const inner = part.slice(2, -2)
const theme = getFilamentColorTheme(data)
const el = document.createElement('span')
el.style.backgroundColor = theme.background
el.style.color = theme.foreground
el.style.padding = '0 0.6mm'
el.style.display = 'inline-block'
applyMarkup(inner, el, data)
frag.appendChild(el)
} else if (part.startsWith('*') && part.endsWith('*')) {
const inner = part.slice(1, -1)
const el = document.createElement('em')
applyMarkup(inner, el, data)
frag.appendChild(el)
}

last = match.index + part.length
}

// Remaining text after last match
if (last < text.length) {
appendPlainText(text.slice(last), frag)
}
}

/**
* Parse a template string with spool data and return a DocumentFragment
* ready to append into the DOM.
*/
export function parseTemplate(template: string, data: SpoolData): DocumentFragment {
const plainText = renderTemplateText(template, data)
const frag = document.createDocumentFragment()
if (plainText.length > MAX_MARKUP_CHARS) {
frag.appendChild(document.createTextNode(plainText.slice(0, MAX_MARKUP_CHARS)))
return frag
}
applyMarkup(plainText, frag, data)
return frag
}
24 changes: 22 additions & 2 deletions frontend/src/pages/spools/[id]/index.astro
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const { id } = Astro.params
<button id="btn-move" class="fm-btn fm-btn-outline" data-i18n="spools.move">
Move
</button>
<a href={`/spools/print?ids=${id}&back=${id}`} id="btn-print-label" class="fm-btn fm-btn-outline" style="border-color: #10b981; color: #10b981; text-decoration: none; display: flex; align-items: center; justify-content: center;">
<a href={`/spools/${id}/print`} id="btn-print-label" class="fm-btn fm-btn-outline" style="border-color: #10b981; color: #10b981; text-decoration: none; display: flex; align-items: center; justify-content: center;">
<span data-i18n="spools.printLabel">Print Label</span>
</a>
</div>
Expand Down Expand Up @@ -419,8 +419,28 @@ const { id } = Astro.params
` : ''}
`

const printUrl = `/spools/${spool.id}/print?designation=${encodeURIComponent(spool.filament?.designation || '')}&mfr=${encodeURIComponent(spool.filament?.manufacturer?.name || '')}&type=${encodeURIComponent(spool.filament?.material_type || '')}&subtype=${encodeURIComponent(spool.filament?.material_subgroup || '')}&color=${encodeURIComponent(spool.filament?.manufacturer_color_name || spool.filament?.colors?.[0]?.color?.name || '')}&mfr_id=${encodeURIComponent(spool.filament?.manufacturer?.id || '')}&hex_code=${encodeURIComponent(spool.filament?.colors?.[0]?.color?.hex_code || '')}&extruder_temp=${encodeURIComponent(spool.filament?.settings_extruder_temp ?? '')}&bed_temp=${encodeURIComponent(spool.filament?.settings_bed_temp ?? '')}&weight=${encodeURIComponent(spool.filament?.weight ?? '')}&diameter=${encodeURIComponent(spool.filament?.diameter_mm ?? '')}&finish=${encodeURIComponent(spool.filament?.finish_type || '')}&density=${encodeURIComponent(spool.filament?.density_g_cm3 ?? '')}&price=${encodeURIComponent(spool.filament?.price ?? '')}`

// Store extra fields for label printing (spool + filament)
const efForPrint: {key: string, label: string, value: string, source: string}[] = []
if (spool.custom_fields) {
const flat = flattenObject(spool.custom_fields)
for (const [key, value] of Object.entries(flat)) {
const sysDef = spoolSystemFieldMap[key]
efForPrint.push({ key: `spool.${key}`, label: sysDef ? sysDef.label : key, value: String(value), source: 'spool' })
}
}
if (spool.filament?.custom_fields) {
const flat = flattenObject(spool.filament.custom_fields)
for (const [key, value] of Object.entries(flat)) {
const sysDef = filamentSystemFieldMap[key]
efForPrint.push({ key: `filament.${key}`, label: sysDef ? sysDef.label : key, value: String(value), source: 'filament' })
}
}
sessionStorage.setItem('filaman-label-extra-fields', JSON.stringify(efForPrint))

const printBtn = document.getElementById('btn-print-label') as HTMLAnchorElement
if (printBtn) printBtn.href = `/spools/print?ids=${spool.id}&back=${spool.id}`
if (printBtn) printBtn.href = printUrl

document.getElementById('actions-section')!.classList.remove('hidden')

Expand Down
Loading