Design & print beautiful labels, with mail-merge superpowers.

A mini Canva for labels — full Hebrew/RTL support, pixel-perfect print output, CSV & Excel mail-merge, barcodes, QR codes, and Avery presets.

Your mailing list never leaves your computer. Your rows are read, merged and printed right here in your browser. That is true either way below — the only thing that changes is where your designs live.

Use it as a guest

Nothing at all is stored on my server. Everything lives in this browser.

  • No account, no email, no cookie
  • Projects, designs and images stay on this computer
  • This browser only — clearing site data erases it

Sign in with Google

Your designs and templates are saved to your account and follow you to any device.

  • Designs, templates and images sync everywhere
  • Your table still stays in your browser by default
  • Optional: back tables up, encrypted, if you ask for it

Already started as a guest on this device? Your work is still here — pick up where you left off.

Your data stays yours

Names, addresses and prices are processed in your browser and never sent to the server — not even to draw a preview.

Backups, only if you ask

Signed in, you can opt in to back your tables up to your account, encrypted, so they follow you between devices. Off unless you turn it on.

Mail merge

Import CSV or paste from Excel/Sheets. Merge fields flow into every label.

Hebrew & RTL

Niqqud, mixed direction, and diacritics render and print perfectly.

Canva-style editor

Drag, resize, rotate, snap, align, layers, and undo/redo.

Exact print

mm-precise @page output with calibration offsets for any printer.

Codes

QR & barcodes (Code128/EAN-13/UPC-A) driven by merge fields, as crisp SVG.

Templates

Avery presets, starter designs, and save your own reusable templates.

`; return {doc, labels, pages}; } window.buildPrintDoc = buildPrintDoc; async function doPrint(calibrationOverride, opts){ if (!App.project) return; const labels0=computeLabels(); if (!labels0.length){ toast('Nothing to print — add data or enable repeat mode'); return; } const dest = (opts && opts.dest) || 'printer'; if (!printScaleAcked()){ if (!await openPrintChecklist({dest})) { track('print.cancelled', {kind:dest}); return; } } else if (dest === 'pdf'){ toast('In the dialog choose “Save as PDF” as the destination — and keep the scale at 100%.', {timeout:7000}); } const calib = calibrationOverride || SETTINGS.settings.calibration || {x:0,y:0}; const {doc, labels, pages} = await buildPrintDoc(calib); let iframe=$('#printFrame'); if (iframe) iframe.remove(); iframe=el('iframe',{id:'printFrame', class:'ls-private', style:{position:'fixed',right:'0',bottom:'0',width:'0',height:'0',border:'0'}}); document.body.appendChild(iframe); iframe.srcdoc=doc; iframe.onload=async ()=>{ const win=iframe.contentWindow; try{ await win.document.fonts.ready; }catch(e){} const imgs=Array.from(win.document.images); await Promise.all(imgs.map(im=> im.complete?Promise.resolve():new Promise(res=>{ im.onload=im.onerror=res; }))); await new Promise(r=>setTimeout(r,150)); win.focus(); win.print(); recordPrint(labels.length, pages.length); }; } function recordPrint(count, pages){ track('print.run', {labels:count, pages, per_page:presetFacts(design().page).perPage, preset:(presetById(design().page.preset)||{}).id, elements:design().elements.length, copies:O().copiesEach||1, repeat:O().repeatMode?1:0}); try{ const hist=JSON.parse(localStorage.getItem('ls_printlog_'+MODE+'_'+App.project.id)||'[]'); hist.unshift({at:new Date().toISOString(), count, pages, output:deepClone(O())}); localStorage.setItem('ls_printlog_'+MODE+'_'+App.project.id, JSON.stringify(hist.slice(0,20))); }catch(e){} } function openCalibration(){ const cur=SETTINGS.settings.calibration||{x:0,y:0}; const xIn=el('input',{class:'num-inp',type:'number',step:'0.1',value:String(cur.x||0)}); const yIn=el('input',{class:'num-inp',type:'number',step:'0.1',value:String(cur.y||0)}); const body=el('div',{}, el('p',{style:{fontSize:'13px',color:'var(--muted)'},text:'Print a test sheet, measure how far the crosshair is from the true corner of a label, and enter the X/Y offset in mm. Applied to all future prints.'}), el('div',{class:'scale-warn',style:{marginTop:'10px'}}, el('i',{'data-lucide':'ruler'}), el('div',{}, el('b',{text:'Print the test sheet at 100% / Actual size'}), el('span',{text:'If you print it scaled, you will measure the scaling and bake it into every future print. ' + 'Use plain paper — no need to burn a label sheet on this.'}))), el('div',{class:'prop-grid2',style:{marginTop:'10px'}}, el('div',{},el('label',{style:{fontSize:'12px'},text:'X offset (mm)'}),xIn), el('div',{},el('label',{style:{fontSize:'12px'},text:'Y offset (mm)'}),yIn)), ); const m=modal({title:'Print calibration', body, size:'sm', foot:[ el('button',{class:'btn',text:'Print test sheet',onclick:()=>printCalibrationSheet()}), el('button',{class:'btn primary',text:'Save offsets',onclick:async()=>{ SETTINGS.settings.calibration={x:+xIn.value||0,y:+yIn.value||0}; await saveWsSettings(); toast('Calibration saved'); m.close(); }})]}); } function scaleCheckRuler(pg){ const BAR = 100; const H = 6; const EDGE = 4; const [pw, ph] = pg.size; if (pw < BAR + 20) return null; const lastRowBottom = pg.marginTop + pg.rows*pg.labelH + Math.max(0, pg.rows-1)*pg.gutterY; const bandBelow = (ph - EDGE) - lastRowBottom; const bandAbove = pg.marginTop - EDGE; let top; if (bandBelow >= H) top = lastRowBottom + (bandBelow - H)/2; else if (bandAbove >= H) top = EDGE + (bandAbove - H)/2; else return null; const left = (pw - BAR)/2; const wrap = el('div',{style:{position:'absolute', left:round2(left)+'mm', top:round2(top)+'mm', width:BAR+'mm', height:H+'mm'}}); let ticks = ''; for (let i=0;i<=BAR;i+=10){ const tall = (i===0 || i===BAR); ticks += `
`; } wrap.innerHTML = `
` + ticks + `
` + `This bar must measure exactly 100 mm — if it is shorter, your printer scaled the page.
`; return wrap; } async function printCalibrationSheet(){ if (!printScaleAcked()){ if (!await openPrintChecklist({dest:'calibration'})) return; } track('print.calibration', {per_page:presetFacts(design().page).perPage, preset:(presetById(design().page.preset)||{}).id}); const pg=design().page; const host=el('div'); const pageEl=el('div',{class:'print-page'}); const labels=[]; for(let r=0;r{ const {x,y}=labelPos(cell.col,cell.row); const wrap=el('div',{class:'print-label',style:{left:x+'mm',top:y+'mm',width:pg.labelW+'mm',height:pg.labelH+'mm',outline:'0.2mm solid #999'}}); wrap.innerHTML=`
${i+1}
`; pageEl.appendChild(wrap); }); const ruler = scaleCheckRuler(pg); if (ruler) pageEl.appendChild(ruler); host.appendChild(pageEl); const doc=`${host.innerHTML}`; let iframe=$('#printFrame'); if(iframe) iframe.remove(); iframe=el('iframe',{id:'printFrame',class:'ls-private',style:{position:'fixed',right:'0',bottom:'0',width:'0',height:'0',border:'0'}}); document.body.appendChild(iframe); iframe.srcdoc=doc; iframe.onload=()=>{ iframe.contentWindow.focus(); iframe.contentWindow.print(); }; } /* templates / import-export / polish */ function mkText(x,y,w,h,runs,opts){ return Object.assign({id:uid(),type:'text',x,y,w,h,rotation:0,locked:false,hidden:false,name:'Text', runs, fit:'shrinkWrap', minSize:6, align:'left', valign:'top', direction:'auto', hideLineIfBlank:false, style:{font:'Heebo',size:11,color:'#1f2937',lineHeight:1.25,letterSpacing:0}}, opts||{}); } function starterDesigns(){ const addr = pageFromPreset(presetById('avery-5160')); const ship = pageFromPreset(presetById('avery-5163')); const badge= pageFromPreset(presetById('name-badge')); const round= pageFromPreset(presetById('round-60mm')); return [ {name:'Address label (Hebrew sample)', page:addr, els:[ mkText(4,3,58,7,[{f:'First Name',style:{bold:true}},{t:' '},{f:'Last Name',style:{bold:true}}],{style:{font:'Heebo',size:12,color:'#1f2937',lineHeight:1.2}}), mkText(4,10,58,6,[{f:'Street'}],{style:{font:'Heebo',size:9,color:'#374151',lineHeight:1.2},hideLineIfBlank:true}), mkText(4,16,58,6,[{f:'City'}],{style:{font:'Heebo',size:9,color:'#374151',lineHeight:1.2}}), ]}, {name:'Shipping label', page:ship, els:[ {id:uid(),type:'shape',shape:'rect',x:0,y:0,w:ship.labelW,h:8,rotation:0,fill:'#4f46e5',stroke:null,strokeWidth:0,name:'Bar'}, mkText(4,1.5,70,5,[{t:'SHIP TO'}],{style:{font:'Inter',size:9,color:'#ffffff',bold:true,lineHeight:1}}), mkText(4,12,70,8,[{f:'First Name',style:{bold:true}},{t:' '},{f:'Last Name',style:{bold:true}}],{style:{font:'Inter',size:14,color:'#111827'}}), mkText(4,22,70,7,[{f:'Street'}],{style:{font:'Inter',size:11,color:'#374151'},hideLineIfBlank:true}), mkText(4,30,70,7,[{f:'City'}],{style:{font:'Inter',size:11,color:'#374151'}}), {id:uid(),type:'barcode',x:72,y:30,w:26,h:16,rotation:0,format:'code128',value:{kind:'merge',field:'sku'},showText:true,name:'Barcode'}, ]}, {name:'Name badge', page:badge, els:[ {id:uid(),type:'shape',shape:'rect',x:0,y:0,w:badge.labelW,h:12,rotation:0,fill:'#4f46e5',stroke:null,strokeWidth:0,name:'Header'}, mkText(0,2,badge.labelW,8,[{t:'HELLO my name is'}],{align:'center',style:{font:'Inter',size:11,color:'#ffffff',bold:true}}), mkText(4,22,badge.labelW-8,20,[{f:'First Name'}],{align:'center',valign:'middle',fit:'shrink',style:{font:'Varela Round',size:30,color:'#111827'}}), mkText(4,44,badge.labelW-8,8,[{f:'City'}],{align:'center',style:{font:'Inter',size:11,color:'#6b7280'}}), ]}, {name:'Product / jar label', page:round, els:[ mkText(6,14,48,10,[{f:'First Name'}],{align:'center',fit:'shrink',style:{font:'Suez One',size:18,color:'#111827'}}), {id:uid(),type:'shape',shape:'rect',x:12,y:26,w:36,h:0.6,rotation:0,fill:'#4f46e5',name:'Rule'}, mkText(6,30,48,8,[{f:'City'}],{align:'center',style:{font:'Assistant',size:10,color:'#4b5563'}}), ]}, {name:'Barcode asset tag', page:pageFromPreset(presetById('avery-5167')), els:[ mkText(2,1,40,4,[{t:'ASSET '},{f:'sku'}],{style:{font:'Courier Prime',size:8,color:'#111827',bold:true}}), {id:uid(),type:'barcode',x:2,y:5,w:40,h:6,rotation:0,format:'code128',value:{kind:'merge',field:'sku'},showText:false,name:'Barcode'}, ]}, {name:'Return address', page:pageFromPreset(presetById('avery-5167')), els:[ mkText(2,1.5,40,4,[{f:'First Name',style:{bold:true}},{t:' '},{f:'Last Name',style:{bold:true}}],{style:{font:'Heebo',size:8,lineHeight:1.1}}), mkText(2,5,40,4,[{f:'Street'}],{style:{font:'Heebo',size:7,color:'#374151',lineHeight:1.1},hideLineIfBlank:true}), mkText(2,8.5,40,4,[{f:'City'}],{style:{font:'Heebo',size:7,color:'#374151',lineHeight:1.1}}), ]}, ].map(t=>({name:t.name, design:{version:1, page:t.page, labelStyle:{background:null,border:null}, elements:t.els}})); } async function openTemplateGallery(){ const body=el('div'); const grid=el('div',{class:'tpl-grid'}); body.appendChild(el('div',{style:{marginBottom:'10px'}}, el('button',{class:'btn sm',html:icon('upload','ic-sm')+' Import project (.json)',onclick:()=>importProject()}))); body.appendChild(el('h3',{style:{fontSize:'13px',margin:'6px 0'},text:'Starter templates'})); body.appendChild(grid); const dbWrap=el('div'); body.appendChild(dbWrap); const m=modal({title:'New from template', body, size:'lg'}); starterDesigns().forEach(t=>{ const card=el('div',{class:'tpl-card', onclick:async()=>{ m.close(); await createFromDesign(t.name, t.design); }}); const thumb=el('div',{class:'thumb'}); card.appendChild(thumb); card.appendChild(el('div',{class:'tpl-name',text:t.name})); grid.appendChild(card); rasterizeLabel(t.design, columns().length?sampleRow():{}, 3).then(png=>{ thumb.appendChild(el('img',{src:png})); }).catch(()=>{ thumb.innerHTML=icon('layout-template','ic'); refreshIcons(); }); }); try{ const templates=await Store.listTemplates(); if (templates.length){ dbWrap.appendChild(el('h3',{style:{fontSize:'13px',margin:'14px 0 6px'},text:'Saved templates'})); const g2=el('div',{class:'tpl-grid'}); dbWrap.appendChild(g2); templates.forEach(t=>{ const card=el('div',{class:'tpl-card', onclick:async()=>{ m.close(); await createFromDesign(t.name, t.design_json); }}); const thumb=el('div',{class:'thumb'}); if(t.thumbnail) thumb.appendChild(el('img',{src:t.thumbnail})); else thumb.innerHTML=icon('layout-template','ic'); card.appendChild(thumb); card.appendChild(el('div',{class:'tpl-name',style:{display:'flex',alignItems:'center'}}, el('span',{class:'grow',text:t.name}), el('button',{class:'btn icon sm danger',html:icon('trash-2','ic-sm'),onclick:async(e)=>{ e.stopPropagation(); await Store.deleteTemplate(t.id); openTemplateGallery(); m.close(); }}))); g2.appendChild(card); }); } }catch(e){} refreshIcons(); } async function createFromDesign(name, designObj){ const id=await createProject(name); App.project.design=normalizeDesign(deepClone(designObj)); App.project.design.wizardDone = true; track('template.use', {elements:(App.project.design.elements||[]).length, preset:(presetById(App.project.design.page.preset)||{}).id}); Ed.history=[]; Ed.selection=[]; markDirty('design'); await runSave(); App.emit('project:loaded', App.project); setTab('design'); toast('Created from template'); } async function saveAsTemplate(){ if (!App.project){ toast('Open a project first'); return; } const name=await promptModal('Template name', App.project.name+' template'); if (name==null) return; let thumb=null; try{ thumb=await rasterizeLabel(design(), canvasRow(), 4); }catch(e){} try{ await Store.saveTemplate({name, design_json:design(), thumbnail:thumb}); track('template.save', {elements:design().elements.length, preset:(presetById(design().page.preset)||{}).id}); toast('Saved as template'); }catch(e){ toast('Could not save template: '+e.message); } } function exportProject(){ const p=App.project; if(!p) return; const data={_type:'labelstudio.project', version:1, name:p.name, data:p.data, design:p.design, output:p.output}; const blob=new Blob([JSON.stringify(data,null,2)],{type:'application/json'}); const a=el('a',{href:URL.createObjectURL(blob), download:(p.name||'project')+'.json'}); document.body.appendChild(a); a.click(); a.remove(); track('project.export', {rows:p.data.rows.length, columns:p.data.columns.length, elements:(p.design.elements||[]).length}); } function importProject(){ const fi=el('input',{type:'file',accept:'.json,application/json'}); fi.addEventListener('change', async ()=>{ const f=fi.files[0]; if(!f) return; try{ const txt=await f.text(); const d=JSON.parse(txt); if (d._type!=='labelstudio.project') throw new Error('Not a Label Studio project file'); const id=await createProject(d.name||'Imported'); App.project.data=normalizeData(d.data); App.project.design=normalizeDesign(d.design); App.project.output=Object.assign(App.project.output, d.output||{}); markDirty('data'); markDirty('design'); markDirty('output'); await runSave(); App.emit('project:loaded', App.project); await reloadHome(); track('project.import', {rows:App.project.data.rows.length, columns:App.project.data.columns.length, elements:design().elements.length}); toast('Project imported'); }catch(e){ toast('Import failed: '+e.message); } }); fi.click(); } function showPrintHistory(){ const hist=(()=>{ try{ return JSON.parse(localStorage.getItem('ls_printlog_'+MODE+'_'+App.project.id)||'[]'); }catch(e){ return []; } })(); const body=el('div'); if (!hist.length) body.appendChild(el('p',{style:{color:'var(--muted)'},text:'No print history yet.'})); hist.forEach(h=>{ body.appendChild(el('div',{class:'row',style:{padding:'8px',borderBottom:'1px solid var(--line2)'}}, el('span',{class:'grow',text:`Printed ${h.count} labels on ${new Date(h.at).toLocaleDateString()} (${h.pages} pages)`}), el('button',{class:'btn sm',text:'Reprint',onclick:()=>{ Object.assign(O(),h.output); markOutputChanged(); doPrint(); }}))); }); modal({title:'Print history', body, size:'sm'}); } function enhanceHome(){ const row=$('#pane-home .row'); if(!row || row.dataset.enhanced) return; row.dataset.enhanced='1'; const imp=el('button',{class:'btn',html:icon('upload','ic-sm')+' Import',onclick:importProject}); row.insertBefore(imp, $('#newFromTpl')); } App.on('project:loaded', ()=>{ enhanceHome(); }); /* boot */ if (document.readyState==='loading') document.addEventListener('DOMContentLoaded', boot); else boot();