diff --git a/app/common/src/main/java/stirling/software/common/util/FormUtils.java b/app/common/src/main/java/stirling/software/common/util/FormUtils.java index 67604d9f1b..f1429ea44f 100644 --- a/app/common/src/main/java/stirling/software/common/util/FormUtils.java +++ b/app/common/src/main/java/stirling/software/common/util/FormUtils.java @@ -1464,16 +1464,29 @@ public class FormUtils { return tooltipLabel; } - // Only check options for choice-type fields (combobox, listbox, radio) - if (CHOICE_FIELD_TYPES.contains(type) && options != null && !options.isEmpty()) { + // A clearly meaningful field name describes the whole field and matches + // the name shown in the editor, so it is the best label. + String humanized = cleanLabel(humanizeName(name)); + if (humanized != null && !looksGeneric(humanized)) { + return humanized; + } + + // Only when the name is an auto-generated identifier ("Field_5", "t12", a + // UUID) is an option more descriptive than the name. A human-typed name - + // even a generic-sounding word like "Choice" or "Name" - is preferred just + // below so the viewer label stays in sync with the editor's field name. This + // stops a radio group named "Choice" with options Yes/No reading as "Yes". + if (CHOICE_FIELD_TYPES.contains(type) + && options != null + && !options.isEmpty() + && looksAutoGenerated(name)) { String optionCandidate = cleanLabel(options.get(0)); if (optionCandidate != null && !looksGeneric(optionCandidate)) { return optionCandidate; } } - String humanized = cleanLabel(humanizeName(name)); - if (humanized != null && !looksGeneric(humanized)) { + if (humanized != null && !looksAutoGenerated(name)) { return humanized; } @@ -1510,6 +1523,29 @@ public class FormUtils { || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); } + /** + * True only for structurally auto-generated identifiers - "Field_5", "F12", "t3", bare numbers, + * UUIDs. Unlike {@link #looksGeneric}, this does NOT reject human-typed placeholder words such + * as "Choice", "Name" or "Value"; those are still usable labels and must not be discarded in + * favour of an option value, so the viewer label keeps matching the editor's field name. + */ + private boolean looksAutoGenerated(String value) { + if (value == null) return true; + + RegexPatternUtils patterns = RegexPatternUtils.getInstance(); + String simplified = patterns.getPunctuationPattern().matcher(value).replaceAll(" ").trim(); + if (simplified.isEmpty()) return true; + + String nospaces = WHITESPACE_PATTERN.matcher(simplified).replaceAll(""); + if (nospaces.length() >= 32 && HEX_UUID_PATTERN.matcher(nospaces).matches()) return true; + + return patterns.getPattern("^field(\\s*\\d+)?$", Pattern.CASE_INSENSITIVE) + .matcher(simplified) + .matches() + || patterns.getSimpleFormFieldPattern().matcher(simplified).matches() + || patterns.getOptionalTNumericPattern().matcher(simplified).matches(); + } + private String humanizeName(String name) { if (name == null) return null; diff --git a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java index c642b7b91e..c486f72406 100644 --- a/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java +++ b/app/common/src/test/java/stirling/software/common/util/FormUtilsEditingTest.java @@ -295,6 +295,42 @@ class FormUtilsEditingTest { } } + @Test + void extractFormFields_prefersFieldNameOverFirstOptionForChoiceLabel() throws IOException { + // A radio group named "Choice" with options Yes/No must be labelled + // "Choice" (its name), not "Yes" (its first option). Otherwise the label + // shown in the viewer disagrees with the field name shown in the editor. + byte[] saved; + try (PDDocument document = new PDDocument()) { + setupForm(document, PDRectangle.A4); + FormUtils.addNewFields( + document, + List.of( + newField( + "radio", + "Choice", + 60, + 700, + 16, + 16, + List.of("Yes", "No"), + null, + null))); + saved = save(document); + } + + try (PDDocument reloaded = Loader.loadPDF(saved)) { + FormUtils.FormFieldInfo choice = + FormUtils.extractFormFields(reloaded).stream() + .filter(f -> "Choice".equals(f.name())) + .findFirst() + .orElse(null); + assertNotNull(choice, "radio field should be extracted"); + assertEquals( + "Choice", choice.label(), "field name should win over the first option value"); + } + } + @Test void addNewFields_createsCombTextField() throws IOException { byte[] saved;