From 07a8f3285cfa8184d7316bdd3739c1a5772ced9b Mon Sep 17 00:00:00 2001 From: chinapandaman Date: Sun, 11 Oct 2026 09:24:43 -0500 Subject: [PATCH 1/2] PPF-2056: how did this get neglected --- PyPDFForm/lib/hooks.py | 93 +++++++++++++------ ...0-PyPDFForm-Action-Plan-by-Effort-Level.md | 4 +- tests/test_functional.py | 46 +++++++++ 3 files changed, 116 insertions(+), 27 deletions(-) diff --git a/PyPDFForm/lib/hooks.py b/PyPDFForm/lib/hooks.py index 670d964a..4e288028 100644 --- a/PyPDFForm/lib/hooks.py +++ b/PyPDFForm/lib/hooks.py @@ -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. @@ -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 = [ @@ -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: diff --git a/ai_agent_archive/2026-10-10-PyPDFForm-Action-Plan-by-Effort-Level.md b/ai_agent_archive/2026-10-10-PyPDFForm-Action-Plan-by-Effort-Level.md index 36c60977..c0a3711a 100644 --- a/ai_agent_archive/2026-10-10-PyPDFForm-Action-Plan-by-Effort-Level.md +++ b/ai_agent_archive/2026-10-10-PyPDFForm-Action-Plan-by-Effort-Level.md @@ -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 diff --git a/tests/test_functional.py b/tests/test_functional.py index 1b6afb63..1b859b9a 100644 --- a/tests/test_functional.py +++ b/tests/test_functional.py @@ -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() From d56fabfe9f2283d6f5acc461d3a4e4b03ea0759e Mon Sep 17 00:00:00 2001 From: chinapandaman Date: Sun, 11 Oct 2026 09:25:06 -0500 Subject: [PATCH 2/2] PPF-2056: format --- ...-Code-Quality-Stability-Roadmap-and-Architecture-Review.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/ai_agent_archive/2026-10-10-PyPDFForm-Code-Quality-Stability-Roadmap-and-Architecture-Review.md b/ai_agent_archive/2026-10-10-PyPDFForm-Code-Quality-Stability-Roadmap-and-Architecture-Review.md index 379b1d61..ec1ed493 100644 --- a/ai_agent_archive/2026-10-10-PyPDFForm-Code-Quality-Stability-Roadmap-and-Architecture-Review.md +++ b/ai_agent_archive/2026-10-10-PyPDFForm-Code-Quality-Stability-Roadmap-and-Architecture-Review.md @@ -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()