// Rows in, printed pages out. // // This is the half of the generator a preview cannot check. Two things live // here for that reason: the CSV columns, which are a contract with whoever // builds the file, and the PAGE ORDER of a two-sided card, which decides // whether card 3's back lands on card 3 or on card 4. Both are the kind of // wrong you only find once the stock is used up. // // Nothing here knows how a code is drawn or how big a label is. That is // utils/codes.js and the component's CSS respectively. // Rendering thousands of codes locks the tab, so stop at a number a person // would actually feed a label printer in one run. The cap counts CARDS, not // pages - a two-sided run of 500 is 1000 pages and that is fine. export const MAX_LABELS = 500 export const MAX_COPIES = 1000 const CONTENT_HEADERS = ['content', 'text', 'data', 'value', 'url', 'qr'] const LABEL_HEADERS = ['label', 'name', 'caption', 'description'] const COPIES_HEADERS = ['copies', 'qty', 'quantity', 'count'] const BACK_HEADERS = ['back', 'backcontent', 'reverse'] const BACKLABEL_HEADERS = ['backlabel', 'backcaption', 'backname'] /** Split one CSV line, honoring quoted fields and "" escapes. */ export function splitCsvLine(line) { const fields = [] let current = '' let inQuotes = false for (let i = 0; i < line.length; i++) { const char = line[i] if (inQuotes) { if (char === '"' && line[i + 1] === '"') { current += '"'; i++ } else if (char === '"') { inQuotes = false } else { current += char } } else if (char === '"') { inQuotes = true } else if (char === ',') { fields.push(current); current = '' } else { current += char } } fields.push(current) return fields.map(field => field.trim()) } /** * Parse the label CSV into rows. * * Columns: content (required), label, copies, back, backlabel. A header row is * optional; without one the order is the order above. back/backlabel are only * read when the back side is set to take its own content per row. */ export function parseCsv(text) { const lines = String(text || '').split(/\r?\n/).filter(line => line.trim() !== '') if (!lines.length) return [] let columns = { content: 0, label: 1, copies: 2, back: 3, backlabel: 4 } let start = 0 const first = splitCsvLine(lines[0]).map(field => field.toLowerCase()) if (first.some(field => CONTENT_HEADERS.includes(field))) { // Named header: map by name so column order does not matter. const indexOf = names => first.findIndex(field => names.includes(field)) columns = { content: indexOf(CONTENT_HEADERS), label: indexOf(LABEL_HEADERS), copies: indexOf(COPIES_HEADERS), back: indexOf(BACK_HEADERS), backlabel: indexOf(BACKLABEL_HEADERS), } start = 1 } const at = (fields, index) => (index >= 0 ? (fields[index] || '') : '') const rows = [] for (let i = start; i < lines.length; i++) { const fields = splitCsvLine(lines[i]) const content = at(fields, columns.content) if (!content) continue const copies = columns.copies >= 0 ? parseInt(fields[columns.copies], 10) : 1 rows.push({ content, label: at(fields, columns.label), copies: Number.isFinite(copies) && copies > 0 ? Math.min(copies, MAX_COPIES) : 1, back: at(fields, columns.back), backlabel: at(fields, columns.backlabel), }) } return rows } /** The back page belonging to one row, per the chosen back source. */ function backPageFor(row, back) { const fallback = back.text || '' switch (back.source) { // The same payload again, so either face of a card scans. Worth it on a // badge that can end up in a holder either way round. case 'code': return { content: row.content, label: row.label || '', side: 'back' } // Per-row back: the CSV carries it. A row that left it empty still gets a // page, because a card printer counts pages and a missing one shifts every // back after it onto the wrong front. case 'column': return { content: row.back || '', label: row.backlabel || fallback, side: 'back' } case 'blank': return { content: '', label: '', side: 'back' } case 'text': default: return { content: '', label: fallback, side: 'back' } } } /** * Expand rows by their copies count and, when a back side is enabled, pair * each card with its back page. * * `back.order` is the one that matters on the floor: * interleave - front, back, front, back. What a duplex card printer's driver * expects: it takes pages two at a time. * grouped - every front, then every back. For a single-sided printer, * where the stack comes out, gets flipped and goes back in. * Getting this backwards prints readable cards with the wrong names on the * back, which is worse than a jam because nothing looks broken. */ export function buildPages(rows, options = {}) { const { back = {}, max = MAX_LABELS } = options const fronts = [] const backs = [] let dropped = 0 for (const row of rows || []) { const wanted = Number.isFinite(row.copies) && row.copies > 0 ? Math.min(row.copies, MAX_COPIES) : 1 for (let i = 0; i < wanted; i++) { if (fronts.length >= max) { dropped += 1; continue } fronts.push({ content: row.content, label: row.label || '', side: 'front' }) if (back.enabled) backs.push(backPageFor(row, back)) } } let pages = fronts if (back.enabled) { pages = back.order === 'grouped' ? [...fronts, ...backs] : fronts.flatMap((front, index) => [front, backs[index]]) } return { pages, fronts, backs, dropped } }