From eb049be5679d3a8bc463b15e7ccf95a98375a8c6 Mon Sep 17 00:00:00 2001 From: Rudi Szabo Date: Sat, 27 Jun 2026 11:00:29 +0200 Subject: [PATCH 1/4] fix: guard against 0 / unfriendly font-scale editing (#49) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two halves of the same bug seen in the field: Server: a non-positive or non-numeric fontScale 500'd /api/preview (and print) — labelle sizes the font as round(line_height * fontScale/100) and PIL rejects a size of 0. Add `_font_size_ratio()` to fall back to the default for any unusable value, so a malformed label file or direct API call can't crash the render. Client: a plain `` made hand-editing the scale hostile — clearing the field yielded Number("") === 0 (the very value that crashed the server), and the leftover digit turned the next keystroke into e.g. "06". Extract a `NumberField` that keeps the visible text as a draft so the field can be emptied and retyped, commits a clamped number only when the draft parses, and resyncs on blur (never mid-type). Reused for both the Scale and Frame inputs. Bump to 1.8.3 (PATCH). Co-Authored-By: Claude Opus 4.8 (1M context) --- client/src/components/NumberField.test.tsx | 128 +++++++++++++++++++++ client/src/components/NumberField.tsx | 66 +++++++++++ client/src/components/TextWidgetEditor.tsx | 43 +++---- package.json | 2 +- server/label_builder.py | 27 ++++- server/tests/test_label_builder.py | 41 +++++++ 6 files changed, 279 insertions(+), 28 deletions(-) create mode 100644 client/src/components/NumberField.test.tsx create mode 100644 client/src/components/NumberField.tsx diff --git a/client/src/components/NumberField.test.tsx b/client/src/components/NumberField.test.tsx new file mode 100644 index 0000000..1b347a0 --- /dev/null +++ b/client/src/components/NumberField.test.tsx @@ -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 ( + { + 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(); + 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(); + 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(); + 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(); + 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(); + 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 ( + <> + + {}} + /> + + ); + } + const user = userEvent.setup(); + render(); + const input = screen.getByRole("spinbutton") as HTMLInputElement; + expect(input.value).toBe("90"); + + await user.click(screen.getByText("set")); + + expect(input.value).toBe("120"); + }); +}); diff --git a/client/src/components/NumberField.tsx b/client/src/components/NumberField.tsx new file mode 100644 index 0000000..8d5cf74 --- /dev/null +++ b/client/src/components/NumberField.tsx @@ -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 `` 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 ( + + ); +} diff --git a/client/src/components/TextWidgetEditor.tsx b/client/src/components/TextWidgetEditor.tsx index ba51a8f..7f7df8d 100644 --- a/client/src/components/TextWidgetEditor.tsx +++ b/client/src/components/TextWidgetEditor.tsx @@ -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); @@ -30,33 +31,23 @@ export function TextWidgetEditor({ widget }: { widget: TextWidget }) { - + update(widget.id, { fontScale })} + /> - + update(widget.id, { frameWidthPx })} + />