fix: derive choice field label from field name, not first option

This commit is contained in:
Anthony Stirling
2026-06-14 16:51:25 +01:00
parent 16c39c187d
commit 8a282c0d30
2 changed files with 76 additions and 4 deletions
@@ -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;
@@ -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;