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
93 changes: 67 additions & 26 deletions PyPDFForm/lib/hooks.py
Original file line number Diff line number Diff line change
Expand Up @@ -314,7 +314,28 @@ def update_text_field_max_length(annot: DictionaryObject, val: int) -> None:
annot[NameObject(MaxLen)] = NumberObject(val)


def update_check_radio_size(annot: DictionaryObject, val: float) -> None:
def _normalize_geometry_value(val: int | float) -> float:
"""
Normalizes scalar geometry to a float before modifying an annotation.

Args:
val (int | float): The coordinate or dimension to normalize.

Returns:
float: The normalized geometry value.

Raises:
TypeError: If the value is not an integer or float, or is a boolean.
"""
# bool subclasses int, so the numeric type check alone would accept booleans.
if isinstance(val, bool) or not isinstance(val, (int, float)):
# TODO: Refactor this raise into a centralized exception raiser when one is introduced.
msg = "Geometry values must be integers or floats; booleans are invalid."
raise TypeError(msg)
return float(val)


def update_check_radio_size(annot: DictionaryObject, val: int | float) -> None:
"""
Updates the size of a check box or radio button annotation.

Expand All @@ -325,8 +346,12 @@ def update_check_radio_size(annot: DictionaryObject, val: float) -> None:
Args:
annot (DictionaryObject): The annotation dictionary for the check box or
radio button.
val (float): The new size (width and height) for the check box or radio button.
val (int | float): The new size (width and height) for the check box or radio button.

Raises:
TypeError: If the size is not an integer or float, or is a boolean.
"""
val = _normalize_geometry_value(val)
rect = annot[Rect]
# scale from bottom left
new_rect = [
Expand Down Expand Up @@ -379,72 +404,88 @@ def update_field_readonly(annot: DictionaryObject, val: bool) -> None:
_update_field_flag(annot, READ_ONLY, val)


def update_field_x(annot: DictionaryObject, val: float) -> None:
def update_field_x(annot: DictionaryObject, val: int | float) -> None:
"""
Updates the X-coordinate (horizontal position) of a form field annotation.

This function modifies the 'Rect' entry of the annotation dictionary to change
the left X-coordinate of the field and adjusts the right X-coordinate by the
same delta to maintain the field's width. Non-float values are ignored.
same delta to maintain the field's width. Integer and float inputs are
normalized to floats before updating the rectangle.

Args:
annot (DictionaryObject): The annotation dictionary for the form field.
val (float): The new X-coordinate for the left edge of the field.
val (int | float): The new X-coordinate for the left edge of the field.

Raises:
TypeError: If the coordinate is not an integer or float, or is a boolean.
"""
if isinstance(val, float):
diff = val - annot[Rect][0]
annot[NameObject(Rect)][0] = FloatObject(val)
annot[NameObject(Rect)][2] = FloatObject(annot[NameObject(Rect)][2] + diff)
val = _normalize_geometry_value(val)
diff = val - annot[Rect][0]
annot[NameObject(Rect)][0] = FloatObject(val)
annot[NameObject(Rect)][2] = FloatObject(annot[NameObject(Rect)][2] + diff)


def update_field_y(annot: DictionaryObject, val: float) -> None:
def update_field_y(annot: DictionaryObject, val: int | float) -> None:
"""
Updates the Y-coordinate (vertical position) of a form field annotation.

This function modifies the 'Rect' entry of the annotation dictionary to change
the bottom Y-coordinate of the field and adjusts the top Y-coordinate by the
same delta to maintain the field's height. Non-float values are ignored.
same delta to maintain the field's height. Integer and float inputs are
normalized to floats before updating the rectangle.

Args:
annot (DictionaryObject): The annotation dictionary for the form field.
val (float): The new Y-coordinate for the bottom edge of the field.
val (int | float): The new Y-coordinate for the bottom edge of the field.

Raises:
TypeError: If the coordinate is not an integer or float, or is a boolean.
"""
if isinstance(val, float):
diff = val - annot[Rect][1]
annot[NameObject(Rect)][1] = FloatObject(val)
annot[NameObject(Rect)][3] = FloatObject(annot[NameObject(Rect)][3] + diff)
val = _normalize_geometry_value(val)
diff = val - annot[Rect][1]
annot[NameObject(Rect)][1] = FloatObject(val)
annot[NameObject(Rect)][3] = FloatObject(annot[NameObject(Rect)][3] + diff)


def update_field_width(annot: DictionaryObject, val: float) -> None:
def update_field_width(annot: DictionaryObject, val: int | float) -> None:
"""
Updates the width of a form field annotation.

This function modifies the 'Rect' entry of the annotation dictionary to set
the new width of the field, adjusting the rightmost coordinate while keeping
the leftmost coordinate fixed. Non-float values are ignored.
the leftmost coordinate fixed. Integer and float inputs are normalized to
floats before updating the rectangle.

Args:
annot (DictionaryObject): The annotation dictionary for the form field.
val (float): The new width of the field.
val (int | float): The new width of the field.

Raises:
TypeError: If the width is not an integer or float, or is a boolean.
"""
if isinstance(val, float):
annot[NameObject(Rect)][2] = FloatObject(annot[Rect][0] + val)
val = _normalize_geometry_value(val)
annot[NameObject(Rect)][2] = FloatObject(annot[Rect][0] + val)


def update_field_height(annot: DictionaryObject, val: float) -> None:
def update_field_height(annot: DictionaryObject, val: int | float) -> None:
"""
Updates the height of a form field annotation.

This function modifies the 'Rect' entry of the annotation dictionary to set
the new height of the field, adjusting the topmost coordinate while keeping
the bottommost coordinate fixed. Non-float values are ignored.
the bottommost coordinate fixed. Integer and float inputs are normalized to
floats before updating the rectangle.

Args:
annot (DictionaryObject): The annotation dictionary for the form field.
val (float): The new height of the field.
val (int | float): The new height of the field.

Raises:
TypeError: If the height is not an integer or float, or is a boolean.
"""
if isinstance(val, float):
annot[NameObject(Rect)][3] = FloatObject(annot[Rect][1] + val)
val = _normalize_geometry_value(val)
annot[NameObject(Rect)][3] = FloatObject(annot[Rect][1] + val)


def update_field_tooltip(annot: DictionaryObject, val: str) -> None:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,11 @@ These changes have narrow scope and can usually be reviewed independently. Their

### E1-03 — Accept integer coordinates wherever numeric geometry is supported

- [ ] **Work:** Correct the geometry hooks in `PyPDFForm/lib/hooks.py` so accepted integer coordinates are normalized and serialized like equivalent floats. Apply the change consistently to relevant coordinate and dimension properties, including supported per-option geometry.
- [x] **Work:** Correct the geometry hooks in `PyPDFForm/lib/hooks.py` so accepted integer coordinates are normalized and serialized like equivalent floats. Apply the change consistently to relevant coordinate and dimension properties, including supported per-option geometry.
- **Done when:** Setting `x = 123` changes the reopened field to 123; equivalent integer and float inputs agree. Invalid types fail without silently retaining the old geometry.
- **Basis and dependencies:** R10. Keep this focused on the documented numeric API; broader geometry validation belongs to E4-01.
- **Completed:** October 11, 2026, [PPF-2056](https://github.com/chinapandaman/PyPDFForm/issues/2056). Scalar integers and floats share one normalization helper for `x`, `y`, `width`, `height`, and checkbox/radio `size`. Scalar radio updates apply to every option. Invalid types, including booleans, raise `TypeError` inside the hooks before changing the annotation rectangle; a comment identifies the raise for a future centralized exception raiser. `None` still queues no update. Scope remains scalar-only: per-option list updates were unsupported before this change and now raise rather than being silently ignored.
- **Validation:** Reconfirmed the defect at pre-change commit `928cc13c`: integer `x = 123` reopened as `73.3365`, while float `x = 123.0` persisted. Added a happy path and an unhappy path test in `tests/test_functional.py`, covering reopened text/checkbox/radio geometry, integer/float byte equivalence, zero coordinates, preserved positions or dimensions, checkbox/radio size, and deferred rejection of strings, booleans, and lists. `make test-all` passed all 383 tests with 100% configured statement coverage, and `make linting` passed using the local `./venv` with standard `zlib`. The development container and cross-platform CI matrix were not run locally.

### E1-04 — Enforce choice-index bounds in schema and runtime validation

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -751,9 +751,7 @@ from PyPDFForm import PdfWrapper
buffer = BytesIO()
canvas = Canvas(buffer)
canvas.drawString(30, 700, "KEEP_FIRST_PAGE")
canvas.acroForm.textfield(
name="field1", x=30, y=600, width=100, height=20
)
canvas.acroForm.textfield(name="field1", x=30, y=600, width=100, height=20)
canvas.showPage()
canvas.drawString(30, 700, "EXCLUDED_SECRET_SECOND_PAGE")
canvas.showPage()
Expand Down
46 changes: 46 additions & 0 deletions tests/test_functional.py
Original file line number Diff line number Diff line change
Expand Up @@ -1146,3 +1146,49 @@ def test_rebuild_acroform_fields():

assert get_widget_key(reader.root_object[AcroForm][FieldsConst][0], False) == "foo"
assert get_widget_key(reader.root_object[AcroForm][FieldsConst][1], False) == "bar"


def test_scalar_geometry(template_stream, template_with_radiobutton_stream):
attributes = {"x": 123, "y": 0, "width": 80, "height": 40}
for template, field_name in (
(template_stream, "test"),
(template_stream, "check"),
(template_with_radiobutton_stream, "radio_1"),
):
form = PdfWrapper(template)
float_form = PdfWrapper(template)
expected = {
name: getattr(form.widgets[field_name], name) for name in attributes
}
updates = attributes.copy()
if field_name != "test":
updates["size"] = 30

for attribute, value in updates.items():
setattr(form.widgets[field_name], attribute, value)
setattr(float_form.widgets[field_name], attribute, float(value))
for name in ("width", "height") if attribute == "size" else (attribute,):
original = expected[name]
expected[name] = (
[value] * len(original) if isinstance(original, list) else value
)

stream = form.read()
assert stream == float_form.read()
reopened = PdfWrapper(stream)
for name, expected_value in expected.items():
assert getattr(reopened.widgets[field_name], name) == pytest.approx(
expected_value
)


def test_invalid_scalar_geometry(template_stream):
for attribute in ("x", "y", "width", "height", "size"):
for value in ("123", True, [123]):
form = PdfWrapper(template_stream)
setattr(form.widgets["check"], attribute, value)

with pytest.raises(
TypeError, match="Geometry values must be integers or floats"
):
form.read()
Loading