Skip to content

Commit ec28a00

Browse files
committed
feat: add PDFField.rename() method for renaming form fields
Adds ability to rename form fields by changing their partial name. The method validates that: - New name is not empty - New name doesn't contain periods (which are hierarchy separators) - No sibling field (terminal or non-terminal) has the same name Includes comprehensive tests for various rename scenarios. Hopding/pdf-lib#1748 Coded by an LLM.
1 parent 6a04db5 commit ec28a00

2 files changed

Lines changed: 186 additions & 0 deletions

File tree

src/api/form/PDFField.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,10 @@ import {
1515
assertOrUndefined,
1616
} from '../../utils/index.js';
1717
import { type Color, colorToComponents, setFillingColor } from '../colors.js';
18+
import {
19+
FieldAlreadyExistsError,
20+
InvalidFieldNamePartError,
21+
} from '../errors.js';
1822
import { ImageAlignment } from '../image/index.js';
1923
import { drawImage, rotateInPlace } from '../operations.js';
2024
import PDFDocument from '../PDFDocument.js';
@@ -133,6 +137,73 @@ export default class PDFField {
133137
return this.acroField.getFullyQualifiedName() ?? '';
134138
}
135139

140+
/**
141+
* Rename this field by changing its partial name. The partial name is the
142+
* last component of the fully qualified name.
143+
*
144+
* For example, if a field's fully qualified name is `person.name.first`
145+
* and you call `rename('given')`, the new fully qualified name will be
146+
* `person.name.given`.
147+
*
148+
* ```js
149+
* const field = form.getField('employee.name')
150+
* console.log(field.getName()) // 'employee.name'
151+
* field.rename('fullName')
152+
* console.log(field.getName()) // 'employee.fullName'
153+
* ```
154+
*
155+
* Note: this only changes the partial name, not the field's position in
156+
* the hierarchy. To move a field to a different parent, you would need to
157+
* create a new field and copy its properties.
158+
*
159+
* @param newPartialName The new partial name for this field.
160+
* @throws `InvalidFieldNamePartError` if the name contains periods.
161+
* @throws `FieldAlreadyExistsError` if a sibling field already has this name.
162+
*/
163+
rename(newPartialName: string): void {
164+
assertIs(newPartialName, 'newPartialName', ['string']);
165+
166+
if (newPartialName.length === 0) {
167+
throw new Error('Field name must not be empty');
168+
}
169+
170+
if (newPartialName.includes('.')) {
171+
throw new InvalidFieldNamePartError(newPartialName);
172+
}
173+
174+
const currentName = this.getName();
175+
const parent = this.acroField.getParent();
176+
const parentFullName = parent?.getFullyQualifiedName();
177+
const newFullName = parentFullName
178+
? `${parentFullName}.${newPartialName}`
179+
: newPartialName;
180+
181+
// If name isn't changing, nothing to do
182+
if (newFullName === currentName) return;
183+
184+
const form = this.doc.getForm();
185+
186+
// Check for conflict with existing terminal field
187+
const existingField = form.getFieldMaybe(newFullName);
188+
if (existingField) {
189+
throw new FieldAlreadyExistsError(newFullName);
190+
}
191+
192+
// Check for conflict with existing non-terminal (container) field.
193+
// A non-terminal with this name exists if any field's fully qualified
194+
// name starts with the new name followed by a period.
195+
const prefix = newFullName + '.';
196+
const allFields = form.getFields();
197+
for (let i = 0, len = allFields.length; i < len; i++) {
198+
if (allFields[i]!.getName().startsWith(prefix)) {
199+
throw new FieldAlreadyExistsError(newFullName);
200+
}
201+
}
202+
203+
// All checks passed - rename the field
204+
this.acroField.setPartialName(newPartialName);
205+
}
206+
136207
/**
137208
* Get all widgets for this field. Each widget represents a visual instance
138209
* of the field on a page. Most fields have only one widget, but some fields

tests/api/form/PDFForm.spec.ts

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1735,4 +1735,119 @@ describe('PDFForm', () => {
17351735
expect(retrievedFields).toEqual([]);
17361736
});
17371737
});
1738+
1739+
describe('PDFField.rename()', () => {
1740+
it('can rename a root-level field', async () => {
1741+
const pdfDoc = await PDFDocument.create();
1742+
const form = pdfDoc.getForm();
1743+
const field = form.createTextField('oldName');
1744+
field.setText('test value');
1745+
1746+
expect(field.getName()).toBe('oldName');
1747+
field.rename('newName');
1748+
expect(field.getName()).toBe('newName');
1749+
expect(field.getText()).toBe('test value'); // Value preserved
1750+
});
1751+
1752+
it('can rename a nested field', async () => {
1753+
const pdfDoc = await PDFDocument.create();
1754+
const form = pdfDoc.getForm();
1755+
const field = form.createTextField('person.name.first');
1756+
1757+
expect(field.getName()).toBe('person.name.first');
1758+
field.rename('given');
1759+
expect(field.getName()).toBe('person.name.given');
1760+
});
1761+
1762+
it('throws if new name contains a period', async () => {
1763+
const pdfDoc = await PDFDocument.create();
1764+
const form = pdfDoc.getForm();
1765+
const field = form.createTextField('myField');
1766+
1767+
expect(() => field.rename('new.name')).toThrow(
1768+
'Field name contains invalid component',
1769+
);
1770+
});
1771+
1772+
it('throws if new name is empty', async () => {
1773+
const pdfDoc = await PDFDocument.create();
1774+
const form = pdfDoc.getForm();
1775+
const field = form.createTextField('myField');
1776+
1777+
expect(() => field.rename('')).toThrow('Field name must not be empty');
1778+
});
1779+
1780+
it('throws if a sibling field already has the same name', async () => {
1781+
const pdfDoc = await PDFDocument.create();
1782+
const form = pdfDoc.getForm();
1783+
form.createTextField('fieldA');
1784+
const fieldB = form.createTextField('fieldB');
1785+
1786+
expect(() => fieldB.rename('fieldA')).toThrow(
1787+
'A field already exists with the specified name',
1788+
);
1789+
});
1790+
1791+
it('throws if renaming would conflict with nested sibling', async () => {
1792+
const pdfDoc = await PDFDocument.create();
1793+
const form = pdfDoc.getForm();
1794+
form.createTextField('parent.child1');
1795+
const field = form.createTextField('parent.child2');
1796+
1797+
expect(() => field.rename('child1')).toThrow(
1798+
'A field already exists with the specified name',
1799+
);
1800+
});
1801+
1802+
it('throws if renaming would conflict with a non-terminal field', async () => {
1803+
const pdfDoc = await PDFDocument.create();
1804+
const form = pdfDoc.getForm();
1805+
// Creates non-terminal 'person' containing terminal 'person.name'
1806+
form.createTextField('person.name');
1807+
const field = form.createTextField('otherField');
1808+
1809+
// Renaming to 'person' should fail because 'person' exists as non-terminal
1810+
expect(() => field.rename('person')).toThrow(
1811+
'A field already exists with the specified name',
1812+
);
1813+
});
1814+
1815+
it('no-ops if renaming to the same name', async () => {
1816+
const pdfDoc = await PDFDocument.create();
1817+
const form = pdfDoc.getForm();
1818+
const field = form.createTextField('myField');
1819+
1820+
// Should not throw
1821+
field.rename('myField');
1822+
expect(field.getName()).toBe('myField');
1823+
});
1824+
1825+
it('preserves field properties after rename', async () => {
1826+
const pdfDoc = await PDFDocument.create();
1827+
const form = pdfDoc.getForm();
1828+
const field = form.createTextField('original');
1829+
field.setText('Hello World');
1830+
field.enableReadOnly();
1831+
field.enableRequired();
1832+
1833+
field.rename('renamed');
1834+
1835+
expect(field.getName()).toBe('renamed');
1836+
expect(field.getText()).toBe('Hello World');
1837+
expect(field.isReadOnly()).toBe(true);
1838+
expect(field.isRequired()).toBe(true);
1839+
});
1840+
1841+
it('allows the renamed field to be retrieved by new name', async () => {
1842+
const pdfDoc = await PDFDocument.create();
1843+
const form = pdfDoc.getForm();
1844+
const field = form.createTextField('oldName');
1845+
1846+
field.rename('newName');
1847+
1848+
expect(() => form.getField('oldName')).toThrow();
1849+
// getField returns a new wrapper, so compare by ref
1850+
expect(form.getField('newName').ref).toBe(field.ref);
1851+
});
1852+
});
17381853
});

0 commit comments

Comments
 (0)