This commit is contained in:
j3d1 2026-08-24 21:35:38 +02:00
parent f87b689e27
commit 0c025db799
16 changed files with 555 additions and 105 deletions

View file

@ -149,6 +149,10 @@ class StorageLocationViewSet(viewsets.ModelViewSet):
serializer_class = StorageLocationSerializer serializer_class = StorageLocationSerializer
authentication_classes = [SignatureAuthentication] authentication_classes = [SignatureAuthentication]
permission_classes = [IsAuthenticated] permission_classes = [IsAuthenticated]
# Detail routes address a location by its owner-scoped id, not the internal row id. See
# docs/implementation.md#inventory-detail-routes-use-owner-scoped-ids.
lookup_field = 'id'
lookup_url_kwarg = 'pk'
def get_queryset(self): def get_queryset(self):
if type(self.request.user) == KnownIdentity and self.request.user.user.exists(): if type(self.request.user) == KnownIdentity and self.request.user.user.exists():

View file

@ -169,7 +169,27 @@ class ItemTag(models.Model):
inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE) inventory_item = models.ForeignKey(InventoryItem, on_delete=models.CASCADE)
class OwnerStorageLocationSequence(models.Model):
"""Tracks the last StorageLocation id handed out per owner for sequential, gapless allocation
(see StorageLocation.create_for_owner)."""
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='+', unique=True)
last_id = models.PositiveIntegerField(default=0)
@classmethod
def allocate(cls, *, owner):
with transaction.atomic():
seq, _ = cls.objects.select_for_update().get_or_create(owner=owner)
seq.last_id += 1
seq.save(update_fields=['last_id'])
return seq.last_id
class StorageLocation(models.Model): class StorageLocation(models.Model):
internal_id = models.AutoField(primary_key=True)
# Externally visible id, sequential/gapless within the owner's own locations (see
# OwnerStorageLocationSequence), never internal_id; always allocate via create_for_owner, not
# .objects.create().
id = models.PositiveIntegerField(editable=False)
name = models.CharField(max_length=255) name = models.CharField(max_length=255)
description = models.TextField(null=True, blank=True) description = models.TextField(null=True, blank=True)
category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True, category = models.ForeignKey(Category, on_delete=models.CASCADE, null=True, blank=True,
@ -177,10 +197,23 @@ class StorageLocation(models.Model):
parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children') parent = models.ForeignKey('self', on_delete=models.CASCADE, null=True, blank=True, related_name='children')
owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations') owner = models.ForeignKey(ToolshedUser, on_delete=models.CASCADE, related_name='storage_locations')
class Meta:
constraints = [
models.UniqueConstraint(fields=['owner', 'id'], name='storagelocation_unique_owner_scoped_id'),
]
def __str__(self): def __str__(self):
parent = str(self.parent) + "/" if self.parent else "" parent = str(self.parent) + "/" if self.parent else ""
return parent + self.name return parent + self.name
@classmethod
def create_for_owner(cls, *, owner, **kwargs):
"""The only supported way to create a StorageLocation: atomically allocates the next id
for this owner's scope."""
with transaction.atomic():
next_id = OwnerStorageLocationSequence.allocate(owner=owner)
return cls.objects.create(owner=owner, id=next_id, **kwargs)
class WorkflowInstance(models.Model): class WorkflowInstance(models.Model):
slug = models.CharField(max_length=255) slug = models.CharField(max_length=255)

View file

@ -339,13 +339,17 @@ def import_locations(user, data):
if category_path: if category_path:
category = get_or_create_category(category_path) category = get_or_create_category(category_path)
location, _ = StorageLocation.objects.update_or_create( defaults = {
owner=user, name=name, parent=parent,
defaults={
'description': row.get('description', '') or '', 'description': row.get('description', '') or '',
'category': category, 'category': category,
}, }
) try:
location = StorageLocation.objects.get(owner=user, name=name, parent=parent)
for field, value in defaults.items():
setattr(location, field, value)
location.save(update_fields=list(defaults.keys()))
except StorageLocation.DoesNotExist:
location = StorageLocation.create_for_owner(owner=user, name=name, parent=parent, **defaults)
resolved_by_path[path] = location resolved_by_path[path] = location
imported += 1 imported += 1
except Exception as error: except Exception as error:

View file

@ -1,3 +1,4 @@
from django.core.exceptions import ObjectDoesNotExist
from rest_framework import serializers from rest_framework import serializers
from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming from authentication.models import KnownIdentity, ToolshedUser, FriendRequestIncoming, Group, GroupInviteIncoming
from authentication.serializers import OwnerSerializer, GroupOwnerSerializer from authentication.serializers import OwnerSerializer, GroupOwnerSerializer
@ -150,15 +151,50 @@ class CategorySerializer(serializers.ModelSerializer):
return resolve_category_handle(data.split("/")[-1]) return resolve_category_handle(data.split("/")[-1])
class OwnerScopedPrimaryKeyRelatedField(serializers.PrimaryKeyRelatedField):
"""Resolves/represents by the owner-scoped `id` rather than the model's internal pk, scoped to
the requesting user - StorageLocation.parent points at another StorageLocation, whose publicly
visible identity is now the owner-scoped id (see StorageLocation.create_for_owner), not
internal_id."""
def use_pk_only_optimization(self):
# False: to_representation needs the owner-scoped `id`, not just the internal pk that the
# PKOnlyObject optimization would otherwise limit us to.
return False
def get_queryset(self):
queryset = super().get_queryset()
request = self.context.get('request')
if request is not None and type(request.user) == KnownIdentity and request.user.user.exists():
return queryset.filter(owner=request.user.user.get())
return queryset.none()
def to_internal_value(self, data):
queryset = self.get_queryset()
try:
if isinstance(data, bool):
raise TypeError
return queryset.get(id=data)
except ObjectDoesNotExist:
self.fail('does_not_exist', pk_value=data)
except (TypeError, ValueError):
self.fail('incorrect_type', data_type=type(data).__name__)
def to_representation(self, value):
return value.id
class StorageLocationSerializer(serializers.ModelSerializer): class StorageLocationSerializer(serializers.ModelSerializer):
owner = OwnerSerializer(read_only=True) owner = OwnerSerializer(read_only=True)
category = serializers.CharField(required=False, allow_null=True, allow_blank=True) category = serializers.CharField(required=False, allow_null=True, allow_blank=True)
parent = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
allow_null=True)
path = serializers.SerializerMethodField() path = serializers.SerializerMethodField()
class Meta: class Meta:
model = StorageLocation model = StorageLocation
fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent'] fields = ['id', 'name', 'description', 'path', 'category', 'owner', 'parent']
read_only_fields = ['path'] read_only_fields = ['id', 'path']
@staticmethod @staticmethod
def get_path(obj): def get_path(obj):
@ -166,6 +202,9 @@ class StorageLocationSerializer(serializers.ModelSerializer):
return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name return StorageLocationSerializer.get_path(obj.parent) + "/" + obj.name
return obj.name return obj.name
def create(self, validated_data):
return StorageLocation.create_for_owner(**validated_data)
class ItemPropertySerializer(serializers.ModelSerializer): class ItemPropertySerializer(serializers.ModelSerializer):
property = PropertySerializer(read_only=True) property = PropertySerializer(read_only=True)
@ -195,6 +234,8 @@ class InventoryItemSerializer(serializers.ModelSerializer):
properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set') properties = ItemPropertySerializer(many=True, required=False, source='itemproperty_set')
category = CategorySerializer(required=False, allow_null=True) category = CategorySerializer(required=False, allow_null=True)
files = FileSerializer(many=True, read_only=True) files = FileSerializer(many=True, read_only=True)
storage_location = OwnerScopedPrimaryKeyRelatedField(queryset=StorageLocation.objects.all(), required=False,
allow_null=True)
class Meta: class Meta:
model = InventoryItem model = InventoryItem

View file

@ -49,11 +49,11 @@ class InventoryTestMixin(CategoryTestMixin, TagTestMixin, PropertyTestMixin):
class LocationTestMixin: class LocationTestMixin:
def prepare_locations(self): def prepare_locations(self):
self.f['loc1'] = StorageLocation.objects.create(name='loc1', owner=self.f['local_user1']) self.f['loc1'] = StorageLocation.create_for_owner(name='loc1', owner=self.f['local_user1'])
self.f['loc2'] = StorageLocation.objects.create(name='loc2', owner=self.f['local_user1'], self.f['loc2'] = StorageLocation.create_for_owner(name='loc2', owner=self.f['local_user1'],
category=self.f['cat1']) category=self.f['cat1'])
self.f['loc3'] = StorageLocation.objects.create(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1']) self.f['loc3'] = StorageLocation.create_for_owner(name='loc3', owner=self.f['local_user1'], parent=self.f['loc1'])
self.f['loc4'] = StorageLocation.objects.create(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'], self.f['loc4'] = StorageLocation.create_for_owner(name='loc4', owner=self.f['local_user1'], parent=self.f['loc1'],
category=self.f['cat1']) category=self.f['cat1'])

Binary file not shown.

View file

@ -32,13 +32,156 @@
<script> <script>
import * as BIcons from "bootstrap-icons-vue"; import * as BIcons from "bootstrap-icons-vue";
import {loadAnyDCode} from "../../vendor/anyd-qr.js"; import {loadAnyDCode, toGrayFrame} from "../../vendor/anyd-qr.js";
// Same file as above, but as a plain URL (Vite's `?url` suffix skips bundling it into this
// module) - the decode worker built in createDecodeWorker() below needs a fetchable/importable
// URL for its own, independent `import()`, since a worker has no access to this module's own
// import bindings.
import anydUrl from "../../vendor/anyd-qr.js?url";
import cameraManager from "@/cameraManager.js"; import cameraManager from "@/cameraManager.js";
// How long a detected code's overlay box stays drawn before fading, so it doesn't linger once // How long a detected code's overlay box stays drawn before fading, so it doesn't linger once
// the code has moved out of frame. // the code has moved out of frame.
const OVERLAY_CLEAR_MS = 2000; const OVERLAY_CLEAR_MS = 2000;
// Capture-loop tuning, matching prototypes/anydcode-wasm's advanced camera demo's own
// defaults/findings (see its parameter-sweep-findings.md) but at a rate suited to a
// battery-conscious inventory-scanning UI rather than that demo's max-responsiveness dial.
const CAPTURE_FPS = 4;
const CAPTURE_DOWNSCALE = 2;
// Milliseconds to suppress re-emitting a symbology+text pair already reported - independent of
// (and shorter than) COOLDOWN_MS below, since it throttles noisy duplicate "scan" events even
// on ticks where a candidate did get decoded (e.g. because the worker was otherwise idle).
const EMIT_DEDUPE_MS = 800;
// Location-based cooldown: skip re-decoding a candidate whose box overlaps a recent successful
// decode while the worker has other work pending, so a code sitting still in frame doesn't keep
// re-paying decode2d's cost every tick. Ported the IoU-overlap idea (not the code) from
// prototypes/anydcode-wasm/packages/qr-wasm/advanced_demo/pipeline.js's createCooldownTracker.
const COOLDOWN_MS = 1500;
const COOLDOWN_IOU_THRESHOLD = 0.3;
// A crop bigger than this (px, longer side) is almost always oversampled for its module count,
// so shrink it before decode2d - cheaper, and no less reliable for a close-up code - capped at
// MAX_DECODE_DOWNSCALE so a genuinely complex/high-version code that happens to fill the frame
// isn't pushed below a safe pixels-per-module floor. Values as validated by the advanced demo's
// parameter sweep (see its parameter-sweep-findings.md).
const DECODE_TARGET_CROP_SIZE = 400;
const MAX_DECODE_DOWNSCALE = 3;
// A queued decode job older than this is dropped unsent rather than dispatched - by the time the
// (single) worker would reach it, the frame it came from is stale enough that decoding it is
// wasted work. MAX_DECODE_QUEUE bounds how many candidates can be waiting at once; there's only
// one worker, so this is a queue depth, not a pool size.
const MAX_QUEUE_AGE_MS = 1000;
const MAX_DECODE_QUEUE = 4;
function boundingBox(corners) {
let x0 = Infinity, y0 = Infinity, x1 = -Infinity, y1 = -Infinity;
for (const [x, y] of corners) {
if (x < x0) x0 = x;
if (y < y0) y0 = y;
if (x > x1) x1 = x;
if (y > y1) y1 = y;
}
return {x0, y0, x1, y1};
}
// Intersection-over-union of two `{x0,y0,x1,y1}` boxes, 0 (no overlap) to 1 (identical).
function iou(a, b) {
const x0 = Math.max(a.x0, b.x0);
const y0 = Math.max(a.y0, b.y0);
const x1 = Math.min(a.x1, b.x1);
const y1 = Math.min(a.y1, b.y1);
const interArea = Math.max(0, x1 - x0) * Math.max(0, y1 - y0);
if (interArea === 0) return 0;
const areaA = (a.x1 - a.x0) * (a.y1 - a.y0);
const areaB = (b.x1 - b.x0) * (b.y1 - b.y0);
return interArea / (areaA + areaB - interArea);
}
// Crop a sub-frame out of `luma` (row-major, `width`x`height`), expanding by `pad` pixels and
// clamping to the source bounds, so a tight `locate()` box still leaves the decoder its quiet
// zone.
function cropGray(width, height, luma, box, pad) {
const x0 = Math.max(0, Math.floor(box.x0 - pad));
const y0 = Math.max(0, Math.floor(box.y0 - pad));
const x1 = Math.min(width, Math.ceil(box.x1 + pad));
const y1 = Math.min(height, Math.ceil(box.y1 + pad));
const w = Math.max(1, x1 - x0);
const h = Math.max(1, y1 - y0);
const out = new Uint8Array(w * h);
for (let row = 0; row < h; row++) {
const srcStart = (y0 + row) * width + x0;
out.set(luma.subarray(srcStart, srcStart + w), row * w);
}
return {width: w, height: h, luma: out, offsetX: x0, offsetY: y0};
}
// Pick how much to shrink this candidate's crop before decode2d(), from its own detected size -
// `1` (no change) for the common small/typical case; only a candidate detected as unusually
// large gets downscaled, and only by as much as its size actually calls for.
function chooseDecodeDownscale(box, targetSize, maxDownscale) {
const size = Math.max(box.x1 - box.x0, box.y1 - box.y0);
return Math.min(maxDownscale, Math.max(1, Math.floor(size / targetSize)));
}
// Crop `box` (expanded by `pad`, clamped to the frame) for decoding, downscaling by
// `decodeDownscale` in the same step when that's `> 1`. At `1` this is exactly `cropGray`; a
// real downscale instead re-samples from `sourceCanvas` (the RGBA source) so the browser's own
// image scaling does the antialiased averaging a manual subsample of an already-grayscale array
// would not.
function cropForDecode(width, height, luma, box, pad, decodeDownscale, sourceCanvas, scratchCanvas, scratchCtx) {
if (decodeDownscale === 1) {
return {...cropGray(width, height, luma, box, pad), decodeDownscale};
}
const x0 = Math.max(0, Math.floor(box.x0 - pad));
const y0 = Math.max(0, Math.floor(box.y0 - pad));
const x1 = Math.min(width, Math.ceil(box.x1 + pad));
const y1 = Math.min(height, Math.ceil(box.y1 + pad));
const srcW = Math.max(1, x1 - x0);
const srcH = Math.max(1, y1 - y0);
const dstW = Math.max(1, Math.round(srcW / decodeDownscale));
const dstH = Math.max(1, Math.round(srcH / decodeDownscale));
if (scratchCanvas.width !== dstW || scratchCanvas.height !== dstH) {
scratchCanvas.width = dstW;
scratchCanvas.height = dstH;
}
scratchCtx.drawImage(sourceCanvas, x0, y0, srcW, srcH, 0, 0, dstW, dstH);
const cropLuma = toGrayFrame(scratchCtx.getImageData(0, 0, dstW, dstH)).luma;
return {width: dstW, height: dstH, luma: cropLuma, offsetX: x0, offsetY: y0, decodeDownscale};
}
// Builds the off-main-thread decode2d()/decode1d() half of the pipeline: a single Worker (its
// source assembled from a template string, not a separate file, to keep this pipeline contained
// in this component) that dynamically imports the same anyd-qr.js bundle already used on the
// main thread for locate(), so a slow decode never blocks capture, detection, or this
// component's own overlay/UI rendering. It gets its own wasm instance - a worker's wasm memory
// isn't shared with the main thread - via that import's own `loadAnyDCode()` cache, exactly like
// the main thread's `anydPromise` below.
function createDecodeWorker() {
const absoluteAnydUrl = new URL(anydUrl, window.location.href).href;
const source = `
import { loadAnyDCode } from ${JSON.stringify(absoluteAnydUrl)};
const anydPromise = loadAnyDCode();
self.onmessage = async (event) => {
const {jobId, width, height, luma} = event.data;
try {
const anyd = await anydPromise;
const codes = anyd.decodeImage({width, height, luma: new Uint8Array(luma)});
self.postMessage({type: "result", jobId, codes});
} catch (err) {
self.postMessage({type: "error", jobId, message: err?.message ?? String(err)});
}
};
`;
const blobUrl = URL.createObjectURL(new Blob([source], {type: "application/javascript"}));
const worker = new Worker(blobUrl, {type: "module"});
URL.revokeObjectURL(blobUrl);
return worker;
}
export default { export default {
name: "CameraScanner", name: "CameraScanner",
components: { components: {
@ -173,22 +316,182 @@ export default {
this.localSelectedCameraId = cameraManager.getSelectedCameraId() || ""; this.localSelectedCameraId = cameraManager.getSelectedCameraId() || "";
const anyd = await this.anydPromise; const anyd = await this.anydPromise;
this.scanner = anyd.createCameraScanner(this.$refs.video, { this.detectWasm = anyd.raw;
fps: 4, this.seen = new Map();
downscale: 2, this.cooldownEntries = [];
onDecode: (codes) => { clearInterval(this.captureTimer);
this.$emit("scan", codes); this.captureTimer = setInterval(() => this.captureTick(), 1000 / CAPTURE_FPS);
this.drawOverlay(codes);
},
onError: (err) => console.error("[camera scan]", err),
});
this.scanner.start();
this.cameraRunning = true; this.cameraRunning = true;
} catch (e) { } catch (e) {
this.$emit("error", `Camera error: ${e.message ?? e}`); this.$emit("error", `Camera error: ${e.message ?? e}`);
} }
}, },
// The tick driving the whole pipeline: grab+downscale the current video frame, run
// `locate()` right here on the main thread (cheap enough not to need a worker of its
// own), then hand each candidate crop off to the decode worker rather than decoding it
// inline - see createDecodeWorker()'s doc comment for why.
captureTick() {
const video = this.$refs.video;
if (!video || !video.videoWidth || !video.videoHeight || !this.detectWasm) {
return;
}
const width = Math.max(1, Math.round(video.videoWidth / CAPTURE_DOWNSCALE));
const height = Math.max(1, Math.round(video.videoHeight / CAPTURE_DOWNSCALE));
if (this.captureCanvas.width !== width || this.captureCanvas.height !== height) {
this.captureCanvas.width = width;
this.captureCanvas.height = height;
}
this.captureCtx.drawImage(video, 0, 0, width, height);
const frame = toGrayFrame(this.captureCtx.getImageData(0, 0, width, height));
let candidates;
try {
candidates = this.detectWasm.locate(width, height, frame.luma);
} catch (err) {
this.$emit("error", `Camera error: ${err.message ?? err}`);
return;
}
if (!Array.isArray(candidates)) {
return;
}
const now = performance.now();
for (const candidate of candidates) {
if (!candidate?.corners) continue;
const box = boundingBox(candidate.corners);
const nativeBox = {
x0: box.x0 * CAPTURE_DOWNSCALE,
y0: box.y0 * CAPTURE_DOWNSCALE,
x1: box.x1 * CAPTURE_DOWNSCALE,
y1: box.y1 * CAPTURE_DOWNSCALE,
};
// Something decoded near here recently, and the worker has other work pending -
// skip re-decoding this candidate rather than spend it re-reading a code that's
// (almost certainly) still just sitting there. If the worker/queue is otherwise
// idle, decode anyway: there's no contention to save, and it keeps this
// location's cooldown entry from going stale between real hits.
if ((this.decodeWorkerBusy || this.decodeQueue.length > 0) && this.findCooldownNear(nativeBox, now)) {
continue;
}
const decodeDownscale = chooseDecodeDownscale(box, DECODE_TARGET_CROP_SIZE, MAX_DECODE_DOWNSCALE);
const crop = cropForDecode(
width, height, frame.luma, box, 4, decodeDownscale,
this.captureCanvas, this.decodeScratchCanvas, this.decodeScratchCtx);
this.enqueueDecodeJob(crop, decodeDownscale, now);
}
},
enqueueDecodeJob(crop, decodeDownscale, now) {
this.pruneStaleJobs(now);
if (this.decodeQueue.length >= MAX_DECODE_QUEUE) {
return;
}
this.decodeQueue.push({
jobId: ++this.jobCounter,
generation: this.captureGeneration,
enqueuedAt: now,
payload: {jobId: this.jobCounter, width: crop.width, height: crop.height, luma: crop.luma.buffer},
transfer: [crop.luma.buffer],
offsetX: crop.offsetX,
offsetY: crop.offsetY,
downscale: CAPTURE_DOWNSCALE,
decodeDownscale,
});
this.dispatchNextJob();
},
// FIFO order means the front of the queue is always the oldest, so the first
// non-expired entry means nothing behind it can be expired either - one pass from the
// front is sufficient.
pruneStaleJobs(now) {
while (this.decodeQueue.length > 0 && now - this.decodeQueue[0].enqueuedAt > MAX_QUEUE_AGE_MS) {
this.decodeQueue.shift();
}
},
dispatchNextJob() {
if (this.decodeWorkerBusy) {
return;
}
this.pruneStaleJobs(performance.now());
const job = this.decodeQueue.shift();
if (!job) {
return;
}
this.decodeWorkerBusy = true;
this.pendingJob = job;
this.decodeWorker.postMessage(job.payload, job.transfer);
},
// The worker only ever saw this one candidate's crop, so any `box` it reports is
// relative to that crop's own pixels - coarser than the capture frame's if this
// candidate got its own decode-time downscale. Scale back up by that factor to land in
// the crop's own space, then apply its offset, then the capture downscale in effect
// when this job was dispatched, to land on native video pixels.
handleWorkerMessage(event) {
const msg = event.data;
const job = this.pendingJob;
this.decodeWorkerBusy = false;
this.pendingJob = null;
if (msg.type === "error") {
console.error("[camera scan]", msg.message);
} else if (job && job.jobId === msg.jobId && job.generation === this.captureGeneration) {
const now = performance.now();
const codes = (msg.codes ?? []).map((code) => ({
...code,
box: code.box && {
x0: (code.box.x0 * job.decodeDownscale + job.offsetX) * job.downscale,
y0: (code.box.y0 * job.decodeDownscale + job.offsetY) * job.downscale,
x1: (code.box.x1 * job.decodeDownscale + job.offsetX) * job.downscale,
y1: (code.box.y1 * job.decodeDownscale + job.offsetY) * job.downscale,
},
}));
for (const code of codes) {
if (code.box) this.rememberCooldown(code.box, now);
}
const dedupeNow = Date.now();
const fresh = codes.filter((code) => {
const key = `${code.type}|${code.text ?? ""}`;
const lastSeen = this.seen.get(key);
if (lastSeen !== undefined && dedupeNow - lastSeen < EMIT_DEDUPE_MS) {
return false;
}
this.seen.set(key, dedupeNow);
return true;
});
if (fresh.length > 0) {
this.$emit("scan", fresh);
this.drawOverlay(fresh);
}
}
this.dispatchNextJob();
},
rememberCooldown(box, now) {
const existing = this.cooldownEntries.find((e) => iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
if (existing) {
existing.box = box;
existing.decodedAt = now;
} else {
this.cooldownEntries.push({box, decodedAt: now});
}
// Prune well past the cooldown window so a long session doesn't accumulate forever.
this.cooldownEntries = this.cooldownEntries.filter((e) => now - e.decodedAt < COOLDOWN_MS * 4);
},
findCooldownNear(box, now) {
return this.cooldownEntries.some(
(e) => now - e.decodedAt < COOLDOWN_MS && iou(e.box, box) >= COOLDOWN_IOU_THRESHOLD);
},
async switchCamera(cameraId) { async switchCamera(cameraId) {
if (!this.cameraRunning) return; if (!this.cameraRunning) return;
try { try {
@ -200,8 +503,15 @@ export default {
}, },
stopCamera() { stopCamera() {
this.scanner?.stop(); clearInterval(this.captureTimer);
this.scanner = null; this.captureTimer = null;
this.decodeQueue = [];
// Bumped so a decode job dispatched before this stop - still in flight in the
// worker - is recognized as stale and discarded once its result arrives (see
// handleWorkerMessage), rather than applied against whatever's running by then.
// decodeWorkerBusy/pendingJob are deliberately left alone: they resolve themselves
// when that in-flight result lands.
this.captureGeneration++;
cameraManager.closeStream(); cameraManager.closeStream();
if (this.$refs.video) { if (this.$refs.video) {
this.$refs.video.srcObject = null; this.$refs.video.srcObject = null;
@ -235,8 +545,28 @@ export default {
// in-flight load; loadAnyDCode also memoizes, so later calls elsewhere in the app are // in-flight load; loadAnyDCode also memoizes, so later calls elsewhere in the app are
// free too. // free too.
this.anydPromise = loadAnyDCode(); this.anydPromise = loadAnyDCode();
this.scanner = null; this.detectWasm = null;
this.clearOverlayTimer = null; this.clearOverlayTimer = null;
this.captureTimer = null;
this.captureGeneration = 0;
this.seen = new Map();
this.cooldownEntries = [];
// decode2d()/decode1d() run in this worker, off the main thread, so a slow decode never
// stalls locate()/capture or this component's own rendering - see createDecodeWorker().
// Long-lived for the component's lifetime (not recreated per start/stop) so switching
// cameras or restarting the scan doesn't pay wasm-instantiation cost again.
this.decodeWorker = createDecodeWorker();
this.decodeWorker.onmessage = (event) => this.handleWorkerMessage(event);
this.decodeWorkerBusy = false;
this.pendingJob = null;
this.jobCounter = 0;
this.decodeQueue = [];
this.captureCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
this.captureCtx = this.captureCanvas.getContext("2d");
this.decodeScratchCanvas = typeof OffscreenCanvas !== "undefined" ? new OffscreenCanvas(1, 1) : document.createElement("canvas");
this.decodeScratchCtx = this.decodeScratchCanvas.getContext("2d");
}, },
mounted() { mounted() {
window.addEventListener("resize", this.syncOverlaySize); window.addEventListener("resize", this.syncOverlaySize);
@ -260,6 +590,7 @@ export default {
this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize); this.$refs.video?.removeEventListener("loadedmetadata", this.onVideoResize);
this.$refs.video?.removeEventListener("resize", this.onVideoResize); this.$refs.video?.removeEventListener("resize", this.onVideoResize);
clearTimeout(this.clearOverlayTimer); clearTimeout(this.clearOverlayTimer);
this.decodeWorker.terminate();
}, },
} }
</script> </script>

View file

@ -90,12 +90,12 @@
background: #fff; background: #fff;
} }
/* Corner badge flagging *why* a layout that's otherwise available (fields filled in) still /* Corner badge flagging *why* a layout failed to render - a capacity error (label.js's "bigger
failed to render - e.g. label.js's "bigger code than the tape allows" or "doesn't fit on this code than the tape allows"/"doesn't fit on this tape") or label-layouts.js's templateContent
tape" errors (see redraw()'s catch). Shows the short label directly (e.g. "too big") rather than throwing "missing data" because a field it needs isn't filled in yet (see redraw()'s catch).
just an icon, so the reason reads at a glance; the full sentence is still the title tooltip. Not Shows the short label directly (e.g. "too big", "missing data") rather than just an icon, so the
shown for the more common "fields not filled in yet" case (see isSelectable/unavailableReason) reason reads at a glance; the full sentence is still the title tooltip. Layered on top of the
since that's already conveyed by the greyed-out thumbnail. */ greyed-out/unselectable state isSelectable/unavailableReason already apply for the same case. */
.template-thumb-warning { .template-thumb-warning {
position: absolute; position: absolute;
top: .35rem; top: .35rem;
@ -276,17 +276,8 @@ export default {
if (!canvas) { if (!canvas) {
continue; continue;
} }
const content = templateContent(t, this.fields);
if (!this.isAvailable(t) || !content) {
canvas.width = 1;
canvas.height = 1;
delete this.failed[t.id];
delete this.warned[t.id];
delete this.qrInfo[t.id];
delete this.textInfo[t.id];
continue;
}
try { try {
const content = templateContent(t, this.fields);
const {warnings, qrInfo, textInfo} = this.tape const {warnings, qrInfo, textInfo} = this.tape
? drawLabel(canvas, this.tape, content, "along") ? drawLabel(canvas, this.tape, content, "along")
: drawFallbackLabel(canvas, content, "along"); : drawFallbackLabel(canvas, content, "along");

View file

@ -86,14 +86,39 @@ export const LABEL_TEMPLATES = [
}, },
{ {
id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.", id: "rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortUrl","itemId"], tags: ["external"], required_vars: ["shortUrl", "itemId"], tags: ["external"],
layout: [{type: "rmqr", content: c => c.shortUrl},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}] layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, {
type: "text",
content: c => c.itemId?.toString().padStart(4, "0")
}]
}, },
{ {
id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.", id: "mqr-tokem-id", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortId","itemId"], tags: ["internal"], required_vars: ["shortId", "itemId"], tags: ["internal"],
layout: [{type: "mqr", content: c => c.shortId},GAP,{type: "text", content: c => c.itemId?.toString().padStart(4, "0")}] layout: [{type: "mqr", content: c => c.shortId}, GAP, {
},...QR_ONLY_TEMPLATES, type: "text",
content: c => c.itemId?.toString().padStart(4, "0")
}]
},
{
id: "location-rmqr-url", name: "rMQR Token", description: "The code with the encoded text printed next to it.",
required_vars: ["shortUrl", "locationId"], tags: ["external"],
layout: [{type: "rmqr", content: c => c.shortUrl}, GAP, {
type: "text",
content: c => c.locationId?.toString().padStart(4, "0")
}]
},
{
id: "location-mqr-tokem-id",
name: "rMQR Token",
description: "The code with the encoded text printed next to it.",
required_vars: ["shortId", "locationId"],
tags: ["internal"],
layout: [{type: "mqr", content: c => c.shortId}, GAP, {
type: "text",
content: c => c.locationId?.toString().padStart(4, "0")
}]
}, ...QR_ONLY_TEMPLATES,
{ {
id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.", id: "qr-text", name: "QR code + text", description: "The code with the encoded text printed next to it.",
@ -110,7 +135,7 @@ export const LABEL_TEMPLATES = [
id: "id-qr-text-vertical", name: "ID + QR code + text below", id: "id-qr-text-vertical", name: "ID + QR code + text below",
description: "The code with the encoded text printed below it.", description: "The code with the encoded text printed below it.",
required_vars: ["itemId", "text", "userHandle"], required_vars: ["itemId", "text", "userHandle"],
layout: [[{type: "text", content: c => "Item: "+c.itemId}, GAP, { layout: [[{type: "text", content: c => "Item: " + c.itemId}, GAP, {
type: "qr", type: "qr",
content: c => c.text content: c => c.text
}, GAP, {type: "text", content: c => c.userHandle}]] }, GAP, {type: "text", content: c => c.userHandle}]]
@ -148,6 +173,17 @@ export const LABEL_TEMPLATES = [
required_vars: ["userHandle", "itemId"], tags: ["internal"], required_vars: ["userHandle", "itemId"], tags: ["internal"],
layout: [{type: "text", content: c => [c.userHandle, c.itemId]}] layout: [{type: "text", content: c => [c.userHandle, c.itemId]}]
}, },
{
id: "location-id", name: "Location ID", description: "Just the bare storage location id, as text only.",
required_vars: ["locationId"], tags: ["internal"],
layout: [{type: "text", content: c => c.locationId}]
},
{
id: "owner-id-text-location", name: "Owner + location ID",
description: "The owner's handle and the location id, as two lines of text - no code.",
required_vars: ["userHandle", "locationId"], tags: ["internal"],
layout: [{type: "text", content: c => [c.userHandle, c.locationId]}]
},
{ {
id: "item-url-qr-handle", name: "Item URL + handle", id: "item-url-qr-handle", name: "Item URL + handle",
description: "Scannable item URL, with the item's compact handle printed alongside.", description: "Scannable item URL, with the item's compact handle printed alongside.",
@ -272,14 +308,15 @@ export function templateIsAvailable(t, fields) {
} }
// Resolves t's content leaves against fields into a tree for label.js's // Resolves t's content leaves against fields into a tree for label.js's
// drawLabel/drawFallbackLabel, or null if nothing to render yet. // drawLabel/drawFallbackLabel. Throws the same {message, short} shape as label.js's own
// capacity errors (see docs/implementation.md#missing-data-is-a-templatecontent-error) when a
// leaf isn't resolved yet, rather than returning null, so a caller's single catch around
// drawLabel/drawFallbackLabel handles both without a separate isAvailable pre-check.
export function templateContent(t, fields) { export function templateContent(t, fields) {
const tree = mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)}); if (!templateIsAvailable(t, fields)) {
let hasContent = false; const err = new Error("This layout needs more fields filled in before it can be drawn.");
walkLeaves(tree, leaf => { err.short = "missing data";
if (leaf.type !== "empty" && leaf.value && (!Array.isArray(leaf.value) || leaf.value.some(Boolean))) { throw err;
hasContent = true;
} }
}); return mapTree(t.layout, leaf => leaf.type === "empty" ? leaf : {...leaf, value: leaf.content(fields)});
return hasContent ? tree : null;
} }

View file

@ -81,14 +81,18 @@ const TEXT_REFERENCE_PX = 100; /* font size text leaves measure their natural a
// Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap // Below 10px, a general-purpose sans-serif gets illegible, so drawTextLeaf switches to a bitmap
// font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why // font (Tom Thumb/Silkscreen) instead; see the empirical rationale (font choice, `scale`, and why
// sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. // sizes aren't dpi-adjusted) at docs/implementation.md#pixel-font-selection. The 10px+ tier names
const PIXEL_FONT_TIERS = [ // Inter explicitly (see ../scss/_label-fonts.scss) rather than falling back to the CSS generic
{belowPx: 8, family: "Tom Thumb", scale: 3.2}, // "sans-serif" keyword, which resolves to a different real font per browser/OS and would make the
{belowPx: 10, family: "Silkscreen"}, // same label print differently depending on where it was rendered from.
const FONT_TIERS = [
{belowPx: 8, family: "Tom Thumb", scale: 3.2, pixel: true},
{belowPx: 10, family: "Silkscreen", pixel: true},
{belowPx: Infinity, family: "Inter"},
]; ];
function fontFamilyFor(fontPx) { function fontFamilyFor(fontPx) {
return PIXEL_FONT_TIERS.find(t => fontPx < t.belowPx) ?? {family: "sans-serif"}; return FONT_TIERS.find(t => fontPx < t.belowPx);
} }
// Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility // Below this font size (px) or physical height (mm) - Tom Thumb's real-Chromium-tested legibility
@ -291,11 +295,12 @@ function checkTextSizes(node, referencePx, pxPerMm, warnings, textInfo) {
} }
} }
// Always measures in sans-serif at the reference size, since the eventual font (see // Always measures in Inter at the reference size, since the eventual font (see FONT_TIERS) isn't
// PIXEL_FONT_TIERS) isn't known until layoutTree sizes the box this aspect ratio feeds into; the // known until layoutTree sizes the box this aspect ratio feeds into; the resulting mismatch (when
// resulting mismatch is invisible in practice, and checkTextSizes still catches real failures. // a pixel font tier ends up chosen instead) is invisible in practice, and checkTextSizes still
// catches real failures.
function measureTextBlock(ctx, lines, referencePx) { function measureTextBlock(ctx, lines, referencePx) {
ctx.font = `${referencePx}px sans-serif`; ctx.font = `${referencePx}px "Inter"`;
const width = Math.max(...lines.map(line => ctx.measureText(line).width)); const width = Math.max(...lines.map(line => ctx.measureText(line).width));
const height = referencePx * 1.15 * lines.length; const height = referencePx * 1.15 * lines.length;
return {width, height}; return {width, height};
@ -342,20 +347,18 @@ function drawQrLeaf(ctx, node) {
// there's no "too small to draw" case left to special-case here. // there's no "too small to draw" case left to special-case here.
function drawTextLeaf(ctx, node, referencePx) { function drawTextLeaf(ctx, node, referencePx) {
const fontPx = effectiveFontPx(node, referencePx); const fontPx = effectiveFontPx(node, referencePx);
const {family, scale = 1} = fontFamilyFor(fontPx); const {family, scale = 1, pixel: isPixelFont = false} = fontFamilyFor(fontPx);
const isPixelFont = family !== "sans-serif";
// Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts // Position (not size - fontPx is already a whole pixel) still needs snapping for pixel fonts
// to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; sans-serif is // to stay grid-aligned, since node.box.x/y are ordinary (fractional) layout math; Inter is
// left exact since anti-aliasing handles fractional positions fine. // left exact since anti-aliasing handles fractional positions fine.
const snap = isPixelFont ? Math.round : (v) => v; const snap = isPixelFont ? Math.round : (v) => v;
// `scale` (Tom Thumb only, see PIXEL_FONT_TIERS above) corrects the size handed to ctx.font // `scale` (Tom Thumb only, see FONT_TIERS above) corrects the size handed to ctx.font for its
// for its real ink; fontPx itself stays the logical size used for centering/stacking math. // real ink; fontPx itself stays the logical size used for centering/stacking math.
ctx.font = `${fontPx * scale}px "${family}"`; ctx.font = `${fontPx * scale}px "${family}"`;
// Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM // Canvas text silently falls back if drawn before a not-yet-loaded font resolves, unlike DOM
// text. See docs/implementation.md#canvas-font-loading. // text. See docs/implementation.md#canvas-font-loading. Every tier now names a real,
if (isPixelFont) { // self-hosted webfont (see FONT_TIERS above), so this always applies, not just to pixel fonts.
document.fonts.load(ctx.font); document.fonts.load(ctx.font);
}
ctx.textAlign = "center"; ctx.textAlign = "center";
const centerX = snap(node.box.x + node.box.width / 2); const centerX = snap(node.box.x + node.box.width / 2);
const lineHeight = node.box.height / node.lines.length; const lineHeight = node.box.height / node.lines.length;
@ -527,9 +530,9 @@ export function drawFallbackLabel(canvas, content, orientation = "along") {
// print label should show/encode; keyed by `kind` so each kind's format is defined in one place. // print label should show/encode; keyed by `kind` so each kind's format is defined in one place.
export const LABEL_CONTENT_BUILDERS = { export const LABEL_CONTENT_BUILDERS = {
// The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just // The self-contained Item URL (see docs/design-in-progress/items-labels.md), built from just
// the prefill's {userHandle, id}; the short link (Print.vue's `shortUrl`) needs an async store // the prefill's {userHandle, item}; the short link (Print.vue's `shortUrl`) needs an async store
// lookup, so it stays a separate field/template rather than being baked in here. // lookup, so it stays a separate field/template rather than being baked in here.
"item": ({userHandle, id}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${id}`, "item": ({userHandle, item}) => `${window.location.origin}/i/${encodeHandleForUrl(userHandle)}/${item}`,
// Storage locations have no long-form URL route yet (see router.js), so `text` starts blank; // Storage locations have no long-form URL route yet (see router.js), so `text` starts blank;
// the short link and any future location template still work via the base vars below. // the short link and any future location template still work via the base vars below.
}; };
@ -542,7 +545,7 @@ export function buildLabelContent(prefill) {
return build ? build(prefill.components) : ""; return build ? build(prefill.components) : "";
} }
// Splits a prefill's {userHandle, id} into label-layouts.js's user/domain base vars (the same way // Splits a prefill's {userHandle, item/location} into label-layouts.js's user/domain base vars (the same way
// store.js's lookupServer does), tagging on whichever id field the resource's templates key // store.js's lookupServer does), tagging on whichever id field the resource's templates key
// required_vars by. // required_vars by.
function splitUserHandle(userHandle) { function splitUserHandle(userHandle) {
@ -560,19 +563,19 @@ function splitUserHandle(userHandle) {
// computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field // computed live elsewhere (see DERIVED_VARS, Print.vue's `shortUrl`), and omitting a field
// (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable. // (rather than leaving it present-but-empty) signals "not available" to templateIsAvailable.
const LABEL_FIELD_BUILDERS = { const LABEL_FIELD_BUILDERS = {
"item": ({userHandle, id}) => { "item": ({userHandle, item}) => {
const split = splitUserHandle(userHandle); const split = splitUserHandle(userHandle);
if (!split || !id) { if (!split || !item) {
return {}; return {};
} }
return {...split, itemId: String(id)}; return {...split, itemId: String(item)};
}, },
"storage-location": ({userHandle, id}) => { "storage-location": ({userHandle, location}) => {
const split = splitUserHandle(userHandle); const split = splitUserHandle(userHandle);
if (!split || !id) { if (!split || !location) {
return {}; return {};
} }
return {...split, locationId: String(id)}; return {...split, locationId: String(location)};
}, },
}; };

View file

@ -139,7 +139,9 @@ const routes = [{path: '/', component: Dashboard, meta: {requiresAuth: true}}, {
meta: {requiresAuth: true}, meta: {requiresAuth: true},
props: route => { props: route => {
const {kind, ...components} = route.query; const {kind, ...components} = route.query;
return {prefill: kind ? {kind, components} : null}; // queryFields lets any base var (e.g. ?text=...) be set directly, without going through
// a `kind` builder - see Print.vue's varValues.
return {prefill: kind ? {kind, components} : null, queryFields: components};
} }
}, { }, {
path: '/scan', path: '/scan',

View file

@ -94,6 +94,7 @@ $body-color: $gray-700;
@import "dropdown"; @import "dropdown";
@import "pixel-fonts"; @import "pixel-fonts";
@import "pixel-fonts-candidates"; @import "pixel-fonts-candidates";
@import "label-fonts";
#root, body, html { #root, body, html {
height: 100%; height: 100%;

View file

@ -154,7 +154,7 @@ export default {
// See docs/implementation.md#print-link-shape-for-personal-items. // See docs/implementation.md#print-link-shape-for-personal-items.
printLinkFor(item) { printLinkFor(item) {
if (!item.owner) return null if (!item.owner) return null
return {path: '/print', query: {kind: 'item', userHandle: item.owner, id: item.id}} return {path: '/print', query: {kind: 'item', userHandle: item.owner, item: item.id}}
}, },
}, },
async mounted() { async mounted() {

View file

@ -51,7 +51,7 @@
Delete Delete
</button> </button>
<button v-if="canPrint" class="btn btn-secondary" <button v-if="canPrint" class="btn btn-secondary"
@click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, id}})"> @click="$router.push({path: '/print', query: {kind: 'item', userHandle: decodedHandle, item: id}})">
<b-icon-printer></b-icon-printer> <b-icon-printer></b-icon-printer>
Print label Print label
</button> </button>

View file

@ -57,7 +57,7 @@
</div> </div>
<div class="card-body"> <div class="card-body">
<p class="text-muted small mb-3"> <p class="text-muted small mb-3">
Nine pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text" Ten pixel/bitmap-style fonts found in frontend/public, rendered live from the "Text"
field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen field above at a range of sizes so they can be judged the same way Tom Thumb/Silkscreen
were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web were. <strong>CodersCrux</strong> and <strong>712Serif</strong> fail to load as web
fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below fonts at all - Chromium's OTS sanitizer rejects their cmap table - so their cells below
@ -86,7 +86,7 @@
<h5 class="card-title mb-0">Label preview</h5> <h5 class="card-title mb-0">Label preview</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<div class="label-preview mb-3" v-show="selectedContent"> <div class="label-preview mb-3" v-show="fallbackReady">
<canvas ref="fallbackCanvas"></canvas> <canvas ref="fallbackCanvas"></canvas>
</div> </div>
@ -144,7 +144,7 @@
Connect a printer to preview and print a label. Connect a printer to preview and print a label.
</p> </p>
<template v-else> <template v-else>
<div class="preview-row mb-3" v-show="selectedContent"> <div class="preview-row mb-3" v-show="labelBitmap">
<div class="ruler-v"> <div class="ruler-v">
<div class="ruler-v-corner"></div> <div class="ruler-v-corner"></div>
<div class="ruler ruler-v-ticks" <div class="ruler ruler-v-ticks"
@ -306,6 +306,12 @@ export default {
prefill: { prefill: {
type: Object, type: Object,
default: null default: null
},
// Every query param other than `kind` (router.js), so any base var - e.g. ?text=... - can
// be set directly without needing a `kind` builder in label-layouts.js.
queryFields: {
type: Object,
default: () => ({})
} }
}, },
data() { data() {
@ -327,13 +333,14 @@ export default {
// Scan-reliability messages from label.js's drawLabel/drawFallbackLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk. // Scan-reliability messages from label.js's drawLabel/drawFallbackLabel (too few px per QR module, or too small in mm) - the label still rendered/prints fine, just flagged as a risk.
warnings: [], warnings: [],
// One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. // One input per BASE_VARS entry; derived vars (userHandle/itemUrl/itemHandle) are calculated-only (see the `fields` computed), never stored here. Prefilled from query params but left editable. queryFields is applied last so an explicit ?text=... etc. always wins over a `kind` builder's computed value.
varValues: { varValues: {
...Object.fromEntries(BASE_VARS.map(v => [v, ""])), ...Object.fromEntries(BASE_VARS.map(v => [v, ""])),
text: buildLabelContent(this.prefill), text: buildLabelContent(this.prefill),
// Defaults to this page's own origin; editable since any frontend can resolve any handle, so a label needn't point back at this one. // Defaults to this page's own origin; editable since any frontend can resolve any handle, so a label needn't point back at this one.
webdomain: window.location.origin, webdomain: window.location.origin,
...buildLabelFields(this.prefill), ...buildLabelFields(this.prefill),
...Object.fromEntries(BASE_VARS.filter(v => v in this.queryFields).map(v => [v, this.queryFields[v]])),
}, },
copies: 1, copies: 1,
selectedTemplate: LABEL_TEMPLATES[0].id, selectedTemplate: LABEL_TEMPLATES[0].id,
@ -347,7 +354,7 @@ export default {
brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png", brotherCliExample: "brother_ql --model QL-000 --printer usb://0000:0000 print --label 00 label.png",
niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png", niimbotCliExample: "niimprint --model b00 --conn usb print --density 3 --image label.png",
// Nine pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real PIXEL_FONT_TIERS. // Ten pixel/bitmap font candidates for legibility testing only (see the disabled card above); not used by label.js's real FONT_TIERS.
candidateFonts: [ candidateFonts: [
{family: "Pixelon"}, {family: "Pixelon"},
{family: "Pixelbasel"}, {family: "Pixelbasel"},
@ -358,6 +365,7 @@ export default {
{family: "6pxExpert"}, {family: "6pxExpert"},
{family: "Jersey10"}, {family: "Jersey10"},
{family: "Jersey15"}, {family: "Jersey15"},
{family: "Terminus"},
], ],
candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20], candidateSizes: [5, 6, 7, 8, 10, 12, 16, 20],
candidateZoom: 4, candidateZoom: 4,
@ -395,9 +403,6 @@ export default {
currentTemplate() { currentTemplate() {
return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0]; return LABEL_TEMPLATES.find(t => t.id === this.selectedTemplate) || LABEL_TEMPLATES[0];
}, },
selectedContent() {
return templateContent(this.currentTemplate, this.fields);
},
deviceRows() { deviceRows() {
if (!this.blob) { if (!this.blob) {
return []; return [];
@ -625,8 +630,7 @@ export default {
redraw() { redraw() {
this.labelBitmap = null; this.labelBitmap = null;
const content = this.selectedContent; if (!this.tape) {
if (!this.tape || !content) {
return; return;
} }
const canvas = this.$refs.labelCanvas; const canvas = this.$refs.labelCanvas;
@ -636,6 +640,7 @@ export default {
this.resizeObserver.observe(canvas.parentElement); this.resizeObserver.observe(canvas.parentElement);
let textSizesPx, warnings; let textSizesPx, warnings;
try { try {
const content = templateContent(this.currentTemplate, this.fields);
({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation)); ({textSizesPx, warnings} = drawLabel(canvas, this.tape, content, this.orientation));
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
@ -653,10 +658,6 @@ export default {
redrawFallback() { redrawFallback() {
this.fallbackReady = false; this.fallbackReady = false;
const content = this.selectedContent;
if (!content) {
return;
}
const canvas = this.$refs.fallbackCanvas; const canvas = this.$refs.fallbackCanvas;
if (!canvas) { if (!canvas) {
return; return;
@ -664,6 +665,7 @@ export default {
this.resizeObserver.observe(canvas.parentElement); this.resizeObserver.observe(canvas.parentElement);
let warnings; let warnings;
try { try {
const content = templateContent(this.currentTemplate, this.fields);
({warnings} = drawFallbackLabel(canvas, content, this.orientation)); ({warnings} = drawFallbackLabel(canvas, content, this.orientation));
} catch (e) { } catch (e) {
this.error = e.message; this.error = e.message;
@ -739,7 +741,7 @@ export default {
} }
}, },
// Renders the "Text" field at exact size/family with no layout snapping, so the font itself is judged; ink-centered vertically (like label.js's drawTextLeaf) so unreliable metrics don't clip. // Renders the "Text" field at exact size/family with no layout snapping, so the font itself is judged; ink-centered vertically (like label.js's drawTextLeaf) so unreliable metrics don't clip. Thresholded through canvasToBitmap/bitmapToCanvas afterwards, same as redraw()'s real label preview, so a candidate is judged on the same 1-bit dots the printer would actually fire, not the anti-aliased canvas render.
drawCandidateCell(canvas, family, fontPx) { drawCandidateCell(canvas, family, fontPx) {
if (!canvas) { if (!canvas) {
return; return;
@ -758,6 +760,7 @@ export default {
ctx.fillRect(0, 0, canvas.width, canvas.height); ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#000"; ctx.fillStyle = "#000";
ctx.fillText(text, 2, (canvas.height + up - down) / 2); ctx.fillText(text, 2, (canvas.height + up - down) / 2);
bitmapToCanvas(canvas, canvasToBitmap(canvas));
canvas.style.width = `${canvas.width * this.candidateZoom}px`; canvas.style.width = `${canvas.width * this.candidateZoom}px`;
canvas.style.height = `${canvas.height * this.candidateZoom}px`; canvas.style.height = `${canvas.height * this.candidateZoom}px`;
}, },

View file

@ -136,7 +136,7 @@ export default {
// Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's // Routes to Print.vue with this location's raw identity, same shape as Inventory.vue's
// printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations. // printLinkFor. See docs/implementation.md#print-link-shape-for-storage-locations.
printLinkFor(location) { printLinkFor(location) {
return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, id: location.id}} return {path: '/print', query: {kind: 'storage-location', userHandle: location.owner, location: location.id}}
}, },
}, },
async mounted() { async mounted() {