Measured it: on a 40mm label most names already span the width by about 1.4x, and everything above that did nothing at all. Cap the control at 2x with Normal / Large / Largest, and say plainly what the limit is. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Vunz4VLxAYDBnU5tLciLDC
451 lines
22 KiB
HTML
451 lines
22 KiB
HTML
{% extends "base.html" %}
|
||
{% block title %}Tape Printing - Admin{% endblock %}
|
||
|
||
{% block extra_css %}
|
||
<link rel="stylesheet" href="{{ url_for('static', filename='css/labels-bricklink.css') }}">
|
||
{% endblock %}
|
||
|
||
{% block content %}
|
||
<div class="bl">
|
||
{% set section = 'labels' %}
|
||
{% set bar_sub = 'Brother VC-500W continuous tape' %}
|
||
{% set crumbs = [{'name': 'My Labels', 'url': url_for('labels.index')}, {'name': 'Tape printing'}] %}
|
||
{% include 'admin/labels/_nav.html' %}
|
||
|
||
<form method="POST" action="{{ url_for('tape.index') }}" id="tapeForm">
|
||
<input type="hidden" name="ids" value="{{ ids }}">
|
||
{# Always submitted, so an unticked checkbox can be told apart from a page
|
||
opened with no options at all. Without it, a default-on checkbox could
|
||
never be turned off. #}
|
||
<input type="hidden" name="_opts" value="1">
|
||
{# Which parts share a physical label: "1,2|3". Rewritten by the Combine
|
||
and Split buttons under the preview. Empty means "group by drawer". #}
|
||
<input type="hidden" name="groups" id="groupSpec" value="{{ request.values.get('groups', '') }}">
|
||
|
||
<div class="row g-2 mt-0">
|
||
<!-- Options -->
|
||
<div class="col-lg-4">
|
||
<div class="bl-panel">
|
||
<div class="bl-panel-head"><span>Label size</span></div>
|
||
<div class="bl-panel-body">
|
||
<label class="fld">Size</label>
|
||
<select name="size_mode" class="w-100 mb-2" id="sizeSelect">
|
||
{% for key, text in size_modes %}
|
||
<option value="{{ key }}" {% if key == options.size_mode %}selected{% endif %}>{{ text }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
{% if options.is_fixed %}
|
||
<div class="k mb-2" style="text-transform:none;">
|
||
Labels are {{ label_width_mm|int }}mm wide and lie across the tape, so each one
|
||
only uses its height in tape length.
|
||
</div>
|
||
<label class="d-flex align-items-center gap-2 mb-1" style="font-size:12px;">
|
||
<input type="checkbox" name="group_drawers" value="1"
|
||
{% if options.group_drawers %}checked{% endif %}>
|
||
Combine parts that share a drawer
|
||
</label>
|
||
<div class="k mb-2" style="text-transform:none;">
|
||
Two part types in one drawer become a single 40 × 20mm label.
|
||
Matched on the drawer / location field.
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bl-panel">
|
||
<div class="bl-panel-head"><span>Tape & layout</span></div>
|
||
<div class="bl-panel-body">
|
||
<label class="fld">Tape loaded in the printer</label>
|
||
<select name="tape" class="w-100 mb-2" id="tapeSelect">
|
||
{% for key, preset in tapes.items() %}
|
||
<option value="{{ key }}" {% if key == options.tape %}selected{% endif %}>{{ preset.name }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
<div class="k mb-2" style="text-transform:none;">{{ tape.note }}</div>
|
||
|
||
<div class="row g-2">
|
||
<div class="col-6">
|
||
<label class="fld">Copies each</label>
|
||
<input type="number" name="copies" min="1" max="99" class="w-100"
|
||
value="{{ options.copies }}">
|
||
</div>
|
||
<div class="col-6">
|
||
<label class="fld">Text size
|
||
<span class="num" id="fontOut">{{ '%.2f'|format(options.font_scale) }}×</span>
|
||
</label>
|
||
<input type="number" name="font_scale" step="0.05" min="0.5" max="2"
|
||
class="w-100" value="{{ options.font_scale }}" id="fontNum">
|
||
</div>
|
||
<div class="col-12">
|
||
<input type="range" min="0.5" max="2" step="0.05" class="w-100"
|
||
value="{{ options.font_scale }}" id="fontRange">
|
||
<div class="d-flex gap-1 flex-wrap">
|
||
{% for preset, text in [(1.0, 'Normal'), (1.4, 'Large'), (2.0, 'Largest')] %}
|
||
<button type="button" class="bl-btn bl-btn-sm font-preset"
|
||
data-scale="{{ preset }}">{{ text }}</button>
|
||
{% endfor %}
|
||
</div>
|
||
<div class="k mt-1" style="text-transform:none;">
|
||
The part name is sized first and the image gives way to
|
||
it, so turning this up shrinks the picture rather than
|
||
clipping the text. The whole name always prints: it wraps
|
||
and settles at the largest size that fits.
|
||
<strong>40mm is 40mm</strong> — once the name spans the
|
||
label, turning the dial further changes nothing, and most
|
||
names hit that by about 1.4×. Untick <em>Part image</em>
|
||
to give the text the whole width.
|
||
</div>
|
||
</div>
|
||
<div class="col-6">
|
||
<label class="fld">Image width %</label>
|
||
<input type="number" name="image_percent" min="0" max="60"
|
||
class="w-100" value="{{ options.image_percent }}">
|
||
</div>
|
||
<div class="col-6">
|
||
<label class="fld">Padding (mm)</label>
|
||
<input type="number" name="padding_mm" step="0.1" min="0" max="10"
|
||
class="w-100" value="{{ options.padding_mm }}">
|
||
</div>
|
||
{% if not options.is_fixed %}
|
||
<div class="col-6">
|
||
<label class="fld">Fixed length (mm)</label>
|
||
<input type="number" name="fixed_length_mm" step="1" min="12" max="430"
|
||
class="w-100" value="{{ options.fixed_length_mm or '' }}"
|
||
placeholder="auto">
|
||
</div>
|
||
{% endif %}
|
||
<div class="col-6">
|
||
<label class="fld">Align</label>
|
||
<select name="align" class="w-100">
|
||
<option value="left" {% if options.align == 'left' %}selected{% endif %}>Left</option>
|
||
<option value="center" {% if options.align == 'center' %}selected{% endif %}>Centre</option>
|
||
</select>
|
||
</div>
|
||
</div>
|
||
|
||
<hr class="my-2">
|
||
{% if not options.is_fixed %}
|
||
<label class="d-flex align-items-center gap-2 mb-1" style="font-size:12px;">
|
||
<input type="checkbox" name="rotate" value="1" {% if options.rotate %}checked{% endif %}>
|
||
Rotate for tape feed
|
||
</label>
|
||
<div class="k mb-2" style="text-transform:none;">
|
||
If your print comes out sideways, untick this. To make the change
|
||
permanent, set it in <a href="{{ url_for('labels.settings') }}">Settings</a>.
|
||
</div>
|
||
{% else %}
|
||
{# Fixed sizes always lie across the tape, so there is nothing to
|
||
choose - carry the value through so the form round-trips. #}
|
||
<input type="hidden" name="rotate" value="{{ '1' if options.rotate else '' }}">
|
||
{% endif %}
|
||
<label class="d-flex align-items-center gap-2" style="font-size:12px;">
|
||
<input type="checkbox" name="outline" value="1" {% if options.outline %}checked{% endif %}>
|
||
Outline each label
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bl-panel">
|
||
<div class="bl-panel-head"><span>Show on label</span></div>
|
||
<div class="bl-panel-body">
|
||
{% for value, text in field_options %}
|
||
<label class="d-flex align-items-center gap-2" style="font-size:12px;">
|
||
<input type="checkbox" name="fields" value="{{ value }}"
|
||
{% if value in options.fields %}checked{% endif %}> {{ text }}
|
||
</label>
|
||
{% endfor %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Preview and actions -->
|
||
<div class="col-lg-8">
|
||
<div class="bl-panel">
|
||
<div class="bl-panel-head">
|
||
<span>Printer</span>
|
||
<span>
|
||
{% if printer.host %}
|
||
<span class="num">{{ printer.host }}:{{ printer.port }}</span>
|
||
{% else %}
|
||
<span class="bl-tag warn">Not configured</span>
|
||
{% endif %}
|
||
</span>
|
||
</div>
|
||
<div class="bl-panel-body">
|
||
{% if printer.host %}
|
||
<div class="d-flex gap-2 flex-wrap align-items-center">
|
||
<button type="button" class="bl-btn" id="testBtn">
|
||
<i class="bi bi-plug"></i> Test connection
|
||
</button>
|
||
<button type="submit" formaction="{{ url_for('tape.print_tape') }}"
|
||
class="bl-btn bl-btn-go">
|
||
<i class="bi bi-printer"></i> Print {{ plan|length }} label{{ '' if plan|length == 1 else 's' }} to tape
|
||
</button>
|
||
<button type="submit" formaction="{{ url_for('tape.download_pdf') }}"
|
||
class="bl-btn">
|
||
<i class="bi bi-file-pdf"></i> Download PDF
|
||
</button>
|
||
</div>
|
||
<div class="k mt-2" style="text-transform:none;">
|
||
Jobs are sent one at a time: LIM waits for the printer to
|
||
finish each label before sending the next, because the
|
||
VC-500W answers "busy" to anything that arrives mid-print.
|
||
So a big run takes a while - leave the page alone until it
|
||
comes back rather than refreshing, or you'll print twice.
|
||
</div>
|
||
<div id="testResult" class="mt-2" style="font-size:12px;"></div>
|
||
{% else %}
|
||
<p class="mb-2">
|
||
No tape printer address set yet. Put the VC-500W's IP address into
|
||
<a href="{{ url_for('labels.settings') }}">Label Settings</a> and it can be
|
||
printed to directly over the network.
|
||
</p>
|
||
<button type="submit" formaction="{{ url_for('tape.download_pdf') }}" class="bl-btn">
|
||
<i class="bi bi-file-pdf"></i> Download PDF instead
|
||
</button>
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
|
||
<div class="bl-panel">
|
||
<div class="bl-panel-head">
|
||
<span>Preview — {{ plan|length }} label{{ '' if plan|length == 1 else 's' }}
|
||
from {{ labels|length }} part{{ '' if labels|length == 1 else 's' }}</span>
|
||
<button type="submit" class="bl-btn bl-btn-sm">Refresh preview</button>
|
||
</div>
|
||
<div class="bl-panel-body">
|
||
{% if not options.fields %}
|
||
<div class="mb-2"><span class="bl-tag warn">Nothing selected</span>
|
||
Every field is unticked, so the labels will print blank.
|
||
Tick something under <em>Show on label</em>.</div>
|
||
{% endif %}
|
||
|
||
{% if options.is_fixed and not tape_fits %}
|
||
<div class="mb-2"><span class="bl-tag err">Tape too narrow</span>
|
||
A {{ label_width_mm|int }}mm label needs 50mm tape.
|
||
{{ tape.name }} is {{ tape.printable_mm }}mm printable, so labels
|
||
will be scaled down. Load 50mm tape, or pick a different size.</div>
|
||
{% endif %}
|
||
|
||
<div class="d-flex gap-2 flex-wrap align-items-center mb-2" id="groupBar"
|
||
data-plan="{{ group_spec }}">
|
||
<span class="k" style="text-transform:none;">Tick labels, then:</span>
|
||
<button type="button" class="bl-btn bl-btn-sm" id="combineBtn">
|
||
<i class="bi bi-box-arrow-in-down"></i> Combine onto one label
|
||
</button>
|
||
<button type="button" class="bl-btn bl-btn-sm" id="splitBtn">
|
||
<i class="bi bi-scissors"></i> Split apart
|
||
</button>
|
||
<button type="button" class="bl-btn bl-btn-sm" id="resetGroupsBtn">
|
||
<i class="bi bi-arrow-counterclockwise"></i> Back to drawers
|
||
</button>
|
||
</div>
|
||
<div class="k mb-2" style="text-transform:none;">
|
||
For customs and MOC drawers, where the parts have no drawer in
|
||
common. A combined label grows 5mm for each part past the second,
|
||
and hand-made groups are kept whatever the size setting says.
|
||
</div>
|
||
|
||
<div class="bl-tape-preview">
|
||
{% for item in plan[:12] %}
|
||
<div class="mb-2 plan-card" data-ids="{{ item.labels | map(attribute='id') | join(',') }}">
|
||
<label class="d-flex align-items-center gap-2 mb-1" style="font-size:12px;">
|
||
<input type="checkbox" class="plan-pick">
|
||
<span>{{ item.labels | map(attribute='display_title') | join(' + ') }}</span>
|
||
</label>
|
||
<img alt="{{ item.labels | map(attribute='display_title') | join(' + ') }}"
|
||
src="{{ url_for('tape.preview',
|
||
ids=item.labels | map(attribute='id') | join(','),
|
||
tape=options.tape, _opts='1',
|
||
size_mode=options.size_mode,
|
||
group_drawers='1' if options.group_drawers else '',
|
||
font_scale=options.font_scale,
|
||
image_percent=options.image_percent,
|
||
padding_mm=options.padding_mm,
|
||
align=options.align,
|
||
outline='1' if options.outline else '',
|
||
fixed_length_mm=options.fixed_length_mm or '',
|
||
fields=options.fields) }}">
|
||
{% if item.width_mm %}
|
||
<div class="k" style="text-transform:none;margin-top:2px;">
|
||
{{ item.width_mm|int }} × {{ item.height_mm|int }}mm
|
||
{%- if item.labels|length > 1 %} ·
|
||
{{ item.labels|length }} parts in {{ item.drawer or 'one drawer' }}
|
||
{%- elif item.drawer %} · {{ item.drawer }}{% endif %}
|
||
</div>
|
||
{% endif %}
|
||
</div>
|
||
{% endfor %}
|
||
</div>
|
||
|
||
{% if plan|length > 12 %}
|
||
<div class="k mt-2" style="text-transform:none;">
|
||
Showing the first 12 of {{ plan|length }}. All of them print.
|
||
</div>
|
||
{% endif %}
|
||
<div class="k mt-2" style="text-transform:none;">
|
||
313 dpi on {{ tape.tape_mm }}mm tape ({{ tape.printable_mm }}mm printable).
|
||
{% if options.is_fixed %}
|
||
Labels lie across the tape, so this run uses about
|
||
<strong>{{ (tape_used_mm / 10)|round(1) }}cm</strong> of tape
|
||
({{ options.copies }} cop{{ 'y' if options.copies == 1 else 'ies' }} each).
|
||
{% else %}
|
||
Preview shown flat; the job is rotated if "Rotate for tape feed" is ticked.
|
||
{% endif %}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
// One click, one print run. A double-click used to queue the batch twice,
|
||
// which on tape costs real money.
|
||
document.getElementById('tapeForm')?.addEventListener('submit', function (ev) {
|
||
const btn = ev.submitter;
|
||
if (btn && btn.formAction && btn.formAction.indexOf('/print') !== -1) {
|
||
if (btn.dataset.sent) { ev.preventDefault(); return; }
|
||
btn.dataset.sent = '1';
|
||
btn.innerHTML = '<i class="bi bi-hourglass-split"></i> Printing…';
|
||
}
|
||
});
|
||
|
||
document.getElementById('testBtn')?.addEventListener('click', function () {
|
||
const out = document.getElementById('testResult');
|
||
out.innerHTML = '<span class="bl-tag">Probing…</span>';
|
||
const body = new URLSearchParams();
|
||
fetch('{{ url_for("tape.test_printer") }}', {method: 'POST', body: body})
|
||
.then(r => r.json())
|
||
.then(d => {
|
||
if (!d.ok) {
|
||
out.innerHTML = '<span class="bl-tag err">No answer</span> ' +
|
||
(d.error || 'Unknown error');
|
||
return;
|
||
}
|
||
const dash = '—';
|
||
const rows = [
|
||
['Model', d.model || dash],
|
||
['State', (d.state || dash) + (d.state_message ? ' (' + d.state_message + ')' : '')],
|
||
['Accepts', (d.formats || []).join(', ') || dash],
|
||
['Will send as', d.usable_format || dash],
|
||
['Resolution', (d.resolutions || []).join(', ') || dash]
|
||
];
|
||
out.innerHTML = '<span class="bl-tag ok">Connected</span> ' +
|
||
(d.advice || '') +
|
||
'<table class="bl-table mt-2"><tbody>' +
|
||
rows.map(r => '<tr><td style="width:120px;font-weight:600;">' +
|
||
r[0] + '</td><td>' + r[1] + '</td></tr>').join('') +
|
||
'</tbody></table>';
|
||
})
|
||
.catch(e => { out.innerHTML = '<span class="bl-tag err">Failed</span> ' + e; });
|
||
});
|
||
|
||
// Changing the tape or the label size re-renders the preview.
|
||
['tapeSelect', 'sizeSelect'].forEach(id => {
|
||
document.getElementById(id)?.addEventListener('change',
|
||
() => document.getElementById('tapeForm').submit());
|
||
});
|
||
|
||
// Text size: slider, box and presets all drive the one value.
|
||
(function () {
|
||
const num = document.getElementById('fontNum');
|
||
const range = document.getElementById('fontRange');
|
||
const out = document.getElementById('fontOut');
|
||
if (!num || !range) { return; }
|
||
const show = v => { if (out) { out.textContent = Number(v).toFixed(2) + '×'; } };
|
||
num.addEventListener('input', () => { range.value = num.value; show(num.value); });
|
||
range.addEventListener('input', () => { num.value = range.value; show(range.value); });
|
||
range.addEventListener('change', () => document.getElementById('tapeForm').submit());
|
||
document.querySelectorAll('.font-preset').forEach(btn => {
|
||
btn.addEventListener('click', () => {
|
||
num.value = range.value = btn.dataset.scale;
|
||
show(btn.dataset.scale);
|
||
document.getElementById('tapeForm').submit();
|
||
});
|
||
});
|
||
})();
|
||
|
||
// Combining parts onto one label by hand.
|
||
//
|
||
// The grouping lives in the hidden `groups` field as "1,2|3". These buttons
|
||
// rewrite it from what is ticked and resubmit, so the preview always shows
|
||
// the real physical labels rather than a guess.
|
||
//
|
||
// The starting point is the whole current plan (data-plan), not just the
|
||
// cards on screen - only the first 12 labels are previewed, and a spec built
|
||
// from those alone would quietly un-group everything after them.
|
||
(function () {
|
||
const spec = document.getElementById('groupSpec');
|
||
const bar = document.getElementById('groupBar');
|
||
const form = document.getElementById('tapeForm');
|
||
if (!spec || !bar || !form) { return; }
|
||
|
||
const plan = () => (bar.dataset.plan || '')
|
||
.split('|').filter(Boolean).map(g => g.split(',').filter(Boolean));
|
||
|
||
function tickedIds() {
|
||
const ids = new Set();
|
||
document.querySelectorAll('.plan-card').forEach(card => {
|
||
if (card.querySelector('.plan-pick')?.checked) {
|
||
(card.dataset.ids || '').split(',').filter(Boolean)
|
||
.forEach(id => ids.add(id));
|
||
}
|
||
});
|
||
return ids;
|
||
}
|
||
|
||
function write(groups) {
|
||
spec.value = groups.filter(g => g.length).map(g => g.join(',')).join('|');
|
||
form.submit();
|
||
}
|
||
|
||
document.getElementById('combineBtn')?.addEventListener('click', () => {
|
||
const picked = tickedIds();
|
||
if (picked.size < 2) {
|
||
alert('Tick two or more labels to combine them onto one.');
|
||
return;
|
||
}
|
||
const groups = [];
|
||
const merged = [];
|
||
let placed = false;
|
||
plan().forEach(group => {
|
||
const mine = group.filter(id => picked.has(id));
|
||
const rest = group.filter(id => !picked.has(id));
|
||
if (mine.length) {
|
||
merged.push(...mine);
|
||
// Keep the combined label where the first ticked part was,
|
||
// so the preview does not jump to the end of a long list.
|
||
if (!placed) { groups.push(merged); placed = true; }
|
||
}
|
||
if (rest.length) { groups.push(rest); }
|
||
});
|
||
write(groups);
|
||
});
|
||
|
||
document.getElementById('splitBtn')?.addEventListener('click', () => {
|
||
const picked = tickedIds();
|
||
if (!picked.size) {
|
||
alert('Tick the labels you want split apart first.');
|
||
return;
|
||
}
|
||
const groups = [];
|
||
plan().forEach(group => {
|
||
const rest = group.filter(id => !picked.has(id));
|
||
group.filter(id => picked.has(id)).forEach(id => groups.push([id]));
|
||
if (rest.length) { groups.push(rest); }
|
||
});
|
||
write(groups);
|
||
});
|
||
|
||
document.getElementById('resetGroupsBtn')?.addEventListener('click', () => {
|
||
spec.value = '';
|
||
form.submit();
|
||
});
|
||
})();
|
||
</script>
|
||
{% endblock %}
|