Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion client/src/components/BatchPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ export function BatchPanel() {
</td>
))}
<td className="border border-gray-200 px-1 py-0.5">
<div className="flex items-center justify-center gap-2">
<div className="flex items-center justify-center gap-4">
<button
className={
isPreviewed
Expand Down
128 changes: 128 additions & 0 deletions client/src/components/NumberField.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, afterEach, vi } from "vitest";
import { render, screen, cleanup } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { useState } from "react";

import { NumberField } from "./NumberField";

afterEach(() => {
cleanup();
});

/** Controlled wrapper so the displayed value reflects committed changes,
* mirroring how TextWidgetEditor drives NumberField through the store. */
function Harness({
initial = 90,
min = 10,
max = 150,
onCommit,
}: {
initial?: number;
min?: number;
max?: number;
onCommit?: (n: number) => void;
}) {
const [value, setValue] = useState(initial);
return (
<NumberField
label="Scale %"
value={value}
min={min}
max={max}
onChange={(n) => {
setValue(n);
onCommit?.(n);
}}
/>
);
}

describe("NumberField", () => {
it("clearing the field shows empty, not 0, and commits nothing", async () => {
const user = userEvent.setup();
const onCommit = vi.fn();
render(<Harness onCommit={onCommit} />);
const input = screen.getByRole("spinbutton") as HTMLInputElement;

await user.clear(input);

expect(input.value).toBe("");
expect(onCommit).not.toHaveBeenCalledWith(0);
});

it("lets you clear and type a fresh value without a leading zero", async () => {
const user = userEvent.setup();
const onCommit = vi.fn();
render(<Harness onCommit={onCommit} />);
const input = screen.getByRole("spinbutton") as HTMLInputElement;

await user.clear(input);
await user.type(input, "60");

expect(input.value).toBe("60");
expect(onCommit).toHaveBeenLastCalledWith(60);
});

it("clamps a committed value below the minimum", async () => {
const user = userEvent.setup();
const onCommit = vi.fn();
render(<Harness min={10} onCommit={onCommit} />);
const input = screen.getByRole("spinbutton") as HTMLInputElement;

await user.clear(input);
await user.type(input, "6");

expect(onCommit).toHaveBeenLastCalledWith(10);
});

it("clamps a committed value above the maximum", async () => {
const user = userEvent.setup();
const onCommit = vi.fn();
render(<Harness max={150} onCommit={onCommit} />);
const input = screen.getByRole("spinbutton") as HTMLInputElement;

await user.clear(input);
await user.type(input, "999");

expect(onCommit).toHaveBeenLastCalledWith(150);
});

it("settles the field to the committed (clamped) value on blur", async () => {
const user = userEvent.setup();
render(<Harness min={10} />);
const input = screen.getByRole("spinbutton") as HTMLInputElement;

await user.clear(input);
await user.type(input, "6");
expect(input.value).toBe("6"); // not fought while typing
await user.tab();

expect(input.value).toBe("10");
});

it("reflects an external value change when not being edited", async () => {
function Rerenderer() {
const [value, setValue] = useState(90);
return (
<>
<button onClick={() => setValue(120)}>set</button>
<NumberField
label="Scale %"
value={value}
min={10}
max={150}
onChange={() => {}}
/>
</>
);
}
const user = userEvent.setup();
render(<Rerenderer />);
const input = screen.getByRole("spinbutton") as HTMLInputElement;
expect(input.value).toBe("90");

await user.click(screen.getByText("set"));

expect(input.value).toBe("120");
});
});
66 changes: 66 additions & 0 deletions client/src/components/NumberField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { useEffect, useState } from "react";

type NumberFieldProps = {
label: string;
value: number;
min: number;
max: number;
className?: string;
onChange: (value: number) => void;
};

/**
* A numeric input that's pleasant to hand-edit.
*
* A plain controlled `<input type="number" value={number}>` fights the user:
* clearing it yields `Number("") === 0` (so the field jumps to 0, and the
* server then has to defend against a 0-size font), and the leftover leading
* digit makes the next keystroke read as e.g. "06". Here we keep the visible
* text as a free-form draft so the field can be emptied and retyped, commit a
* clamped number to the parent only when the draft parses, and resync the draft
* to the committed value on blur — never while typing, so clamping can't snap a
* half-entered number out from under the cursor.
*/
export function NumberField({
label,
value,
min,
max,
className,
onChange,
}: NumberFieldProps) {
const [draft, setDraft] = useState(String(value));
const [focused, setFocused] = useState(false);

// Mirror an external value change (e.g. loading a label file) into the field,
// but stay out of the way while the user is actively editing it.
useEffect(() => {
if (!focused) setDraft(String(value));
}, [value, focused]);

const clamp = (n: number) => Math.min(max, Math.max(min, n));

return (
<label className="flex items-center gap-1 text-xs">
{label}
<input
type="number"
className={className}
min={min}
max={max}
value={draft}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
onChange={(e) => {
const raw = e.target.value;
setDraft(raw);
// Empty or non-numeric mid-edit: hold the last committed value so the
// preview never renders an invalid size. The field settles on blur.
if (raw.trim() === "") return;
const parsed = Number(raw);
if (!Number.isNaN(parsed)) onChange(clamp(parsed));
}}
/>
</label>
);
}
43 changes: 17 additions & 26 deletions client/src/components/TextWidgetEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useLabelStore } from "../state/useLabelStore";
import type { TextWidget, FontStyle, Alignment } from "../types/label";
import { NumberField } from "./NumberField";

export function TextWidgetEditor({ widget }: { widget: TextWidget }) {
const update = useLabelStore((s) => s.updateWidget);
Expand Down Expand Up @@ -30,33 +31,23 @@ export function TextWidgetEditor({ widget }: { widget: TextWidget }) {
</select>
</label>

<label className="flex items-center gap-1 text-xs">
Scale %
<input
type="number"
className="input text-xs w-16"
min={10}
max={150}
value={widget.fontScale}
onChange={(e) =>
update(widget.id, { fontScale: Number(e.target.value) })
}
/>
</label>
<NumberField
label="Scale %"
className="input text-xs w-16"
min={10}
max={150}
value={widget.fontScale}
onChange={(fontScale) => update(widget.id, { fontScale })}
/>

<label className="flex items-center gap-1 text-xs">
Frame
<input
type="number"
className="input text-xs w-14"
min={0}
max={20}
value={widget.frameWidthPx}
onChange={(e) =>
update(widget.id, { frameWidthPx: Number(e.target.value) })
}
/>
</label>
<NumberField
label="Frame"
className="input text-xs w-14"
min={0}
max={20}
value={widget.frameWidthPx}
onChange={(frameWidthPx) => update(widget.id, { frameWidthPx })}
/>

<label className="flex items-center gap-1 text-xs">
Align
Expand Down
36 changes: 35 additions & 1 deletion server/label_builder.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import math
import os
from io import BytesIO

Expand Down Expand Up @@ -31,6 +32,39 @@ def mm_to_payload_px(mm: float, margin: float) -> float:
return max(0, (mm * PIXELS_PER_MM) - margin * 2)


# Text scale as a percentage; matches the client default (DEFAULT_FONT_SCALE in
# client/src/lib/constants.ts). Used as the fallback when a widget carries an
# unusable fontScale.
DEFAULT_FONT_SCALE = 90


def _font_size_ratio(widget: dict) -> float:
"""A text widget's fontScale (%) as a positive ratio for TextRenderEngine.

labelle sizes the font as round(line_height * ratio) and PIL rejects a
size of 0, so a non-positive or non-numeric fontScale would 500 the whole
render. The UI produces exactly that the instant the Scale field is cleared
(Number("") === 0), and a malformed label file or direct API call can carry
it too — so fall back to the default rather than crash. See #49.

NaN and Infinity get the same treatment: Flask's JSON parser accepts the
`NaN`/`Infinity` literals, and both slip past a plain `<= 0` check (then
blow up in PIL's int conversion), so guard with `math.isfinite`.

Note: a tiny *positive* fontScale can still round to 0 px on a small tape
(or with many lines) and raise. That's out of reach from the UI (min 10)
and tape-dependent, so it isn't normalized here; see #49 for the residual.
"""
raw = widget.get("fontScale", DEFAULT_FONT_SCALE)
try:
scale = float(raw)
except (TypeError, ValueError):
scale = DEFAULT_FONT_SCALE
if not math.isfinite(scale) or scale <= 0:
scale = DEFAULT_FONT_SCALE
return scale / 100.0


# Cut-mark pattern. CUT_MARK_ON pixels on, CUT_MARK_OFF off, repeated for the
# tape's full height. A column of dotted pixels painted into the trailing
# margin of each batch label (except the last) so the user can tear/cut
Expand Down Expand Up @@ -101,7 +135,7 @@ def _build_render_engines(
text_lines=text.split("\n"),
font_file_name=font_path,
frame_width_px=widget.get("frameWidthPx", 0),
font_size_ratio=widget.get("fontScale", 90) / 100.0,
font_size_ratio=_font_size_ratio(widget),
align=Direction(widget.get("align", "left")),
)
)
Expand Down
54 changes: 54 additions & 0 deletions server/tests/test_label_builder.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from PIL import Image

from label_builder import (
DEFAULT_FONT_SCALE,
_build_render_engines,
mm_to_payload_px,
preview_label,
Expand Down Expand Up @@ -117,6 +118,59 @@ def test_invalid_barcode_type_falls_back_to_default(self):
assert isinstance(engines[0], BarcodeRenderEngine)


class TestFontScaleGuard:
"""A non-positive or non-numeric fontScale must not crash rendering.

labelle computes the pixel font size as round(line_height * fontScale/100)
and PIL's ImageFont rejects a size of 0, so a fontScale of 0 — which the UI
produces the moment the Scale field is cleared (Number("") === 0), and which
a malformed label file or direct API call can also carry — would otherwise
500 the whole preview/print. We fall back to the default instead. See #49."""

def test_zero_font_scale_falls_back_to_default(self):
widgets = [{"type": "text", "text": "Hello", "id": "1", "fontScale": 0}]
engines = _build_render_engines(widgets)
assert engines[0].font_size_ratio == DEFAULT_FONT_SCALE / 100.0

def test_negative_font_scale_falls_back_to_default(self):
widgets = [{"type": "text", "text": "Hello", "id": "1", "fontScale": -20}]
engines = _build_render_engines(widgets)
assert engines[0].font_size_ratio == DEFAULT_FONT_SCALE / 100.0

def test_non_numeric_font_scale_falls_back_to_default(self):
widgets = [{"type": "text", "text": "Hello", "id": "1", "fontScale": None}]
engines = _build_render_engines(widgets)
assert engines[0].font_size_ratio == DEFAULT_FONT_SCALE / 100.0

def test_nan_and_infinity_fall_back_to_default(self):
# Flask's JSON parser accepts the NaN/Infinity literals, and both slip
# past a plain `<= 0` check before exploding in PIL's int conversion.
for bad in (float("nan"), float("inf"), float("-inf")):
widgets = [{"type": "text", "text": "Hi", "id": "1", "fontScale": bad}]
engines = _build_render_engines(widgets)
assert engines[0].font_size_ratio == DEFAULT_FONT_SCALE / 100.0

def test_preview_with_nan_font_scale_does_not_raise(self):
widgets = [{"type": "text", "text": "Hi", "id": "1", "fontScale": float("nan")}]
result = preview_label(widgets, {"tapeSizeMm": 6})
assert result[:8] == b"\x89PNG\r\n\x1a\n"

def test_valid_font_scale_is_preserved(self):
widgets = [{"type": "text", "text": "Hello", "id": "1", "fontScale": 50}]
engines = _build_render_engines(widgets)
assert engines[0].font_size_ratio == 0.5

def test_missing_font_scale_uses_default(self):
widgets = [{"type": "text", "text": "Hello", "id": "1"}]
engines = _build_render_engines(widgets)
assert engines[0].font_size_ratio == DEFAULT_FONT_SCALE / 100.0

def test_preview_with_zero_font_scale_does_not_raise(self):
widgets = [{"type": "text", "text": "Hello", "id": "1", "fontScale": 0}]
result = preview_label(widgets, {"tapeSizeMm": 12})
assert result[:8] == b"\x89PNG\r\n\x1a\n"


class TestPreviewLabel:
def test_returns_valid_png_bytes(self):
widgets = [{"type": "text", "text": "Test", "id": "1"}]
Expand Down
Loading