diff --git a/CHANGELOG.md b/CHANGELOG.md index a92afd5eb..395ee4e2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,7 @@ This can also be enabled programmatically with `warnings.simplefilter('default', ## [2.8.6] - Not released yet ### Added +* support for interactive PDF form fields (AcroForms), including `text_field()` and `checkbox()` - _cf._ [issue #257](https://github.com/py-pdf/fpdf2/issues/257) * support for SVG `` and `` elements - _cf._ [issue #1580](https://github.com/py-pdf/fpdf2/issues/1580) - thanks to @Ani07-05 ### Fixed * the `A5` value that could be specified as page `format` to the `FPDF` constructor was slightly incorrect, and the corresponding page dimensions have been fixed. This could lead to a minor change in your documents dimensions if you used this `A5` page format. - _cf._ [issue #1699](https://github.com/py-pdf/fpdf2/issues/1699) diff --git a/debug_forms_output.pdf b/debug_forms_output.pdf new file mode 100644 index 000000000..0f8d30cef Binary files /dev/null and b/debug_forms_output.pdf differ diff --git a/docs/Forms.md b/docs/Forms.md new file mode 100644 index 000000000..12acc8088 --- /dev/null +++ b/docs/Forms.md @@ -0,0 +1,214 @@ +# Interactive Forms (AcroForms) + +`fpdf2` supports creating interactive PDF forms that users can fill out directly in their PDF viewer. This is implemented using the AcroForm standard. + +Currently supported field types: +* **Text Fields**: Single-line, multi-line, and password inputs. +* **Checkboxes**: Toggleable buttons. +* **Radio Buttons**: Groups of mutually exclusive options. +* **Push Buttons**: Clickable buttons (typically used for form submission or actions). +* **Combo Boxes**: Dropdown selection lists. +* **List Boxes**: Scrollable selection lists with optional multi-select. + +## Basic Usage + +To add form fields, use the corresponding methods for each field type. + +```python +from fpdf import FPDF + +pdf = FPDF() +pdf.add_page() +pdf.set_font("Helvetica", size=12) + +# Add a label and a text field +pdf.text(10, 20, "First Name:") +pdf.text_field(name="first_name", x=40, y=15, w=50, h=10, value="John") + +# Add a checkbox +pdf.checkbox(name="subscribe", x=10, y=30, size=5, checked=True) +pdf.text(17, 34, "Subscribe to newsletter") + +# Add radio buttons +pdf.text(10, 50, "Preferred contact:") +pdf.text(30, 50, "Email") +pdf.radio_button(name="contact", x=20, y=46, size=6, selected=True, export_value="email") +pdf.text(60, 50, "Phone") +pdf.radio_button(name="contact", x=50, y=46, size=6, selected=False, export_value="phone") + +# Add a dropdown (combo box) +pdf.text(10, 65, "Country:") +pdf.combo_box(name="country", x=35, y=60, w=60, h=10, + options=["USA", "Canada", "UK", "Other"], value="USA") + +# Add a submit button +pdf.push_button(name="submit", x=40, y=80, w=40, h=15, label="Submit") + +pdf.output("form.pdf") +``` + +## Text Fields + +The `text_field()` method supports several customization options: + +| Parameter | Description | +| --- | --- | +| `name` | Unique identifier for the field. | +| `value` | Initial text content. | +| `multiline` | If `True`, the field allows multiple lines of text. | +| `password` | If `True`, characters are masked (e.g., with bullets). | +| `max_length` | Maximum number of characters allowed. | +| `font_size` | Size of the text in the field. | +| `font_color_gray` | Gray level (0-1) for the text. | +| `background_color` | RGB tuple (0-1) for the field background. | +| `border_color` | RGB tuple (0-1) for the field border. | + +### Example: Multiline Text Area + +```python +pdf.text_field( + name="comments", + x=10, + y=50, + w=100, + h=30, + multiline=True, + value="Enter your comments here..." +) +``` + +## Checkboxes + +The `checkbox()` method creates a toggleable button. + +| Parameter | Description | +| --- | --- | +| `name` | Unique identifier for the checkbox. | +| `checked` | Initial state of the checkbox. | +| `size` | Width and height of the checkbox. | +| `check_color_gray` | Gray level (0-1) for the checkmark. | + +## Radio Buttons + +The `radio_button()` method creates radio buttons. Radio buttons with the same `name` form a group where only one can be selected at a time. + +| Parameter | Description | +| --- | --- | +| `name` | Name for the radio button group. Buttons with the same name are mutually exclusive. | +| `selected` | Initial selected state of this button. | +| `export_value` | Value exported when this button is selected. | +| `size` | Diameter of the radio button. | +| `mark_color_gray` | Gray level (0-1) for the selection mark. | +| `no_toggle_to_off` | If `True`, clicking the selected button doesn't deselect it. | + +### Example: Radio Button Group + +```python +pdf.text(10, 20, "Size:") +pdf.radio_button(name="size", x=35, y=16, size=8, selected=True, export_value="Small") +pdf.text(47, 20, "Small") + +pdf.radio_button(name="size", x=70, y=16, size=8, selected=False, export_value="Medium") +pdf.text(82, 20, "Medium") + +pdf.radio_button(name="size", x=110, y=16, size=8, selected=False, export_value="Large") +pdf.text(122, 20, "Large") +``` + +## Push Buttons + +The `push_button()` method creates a clickable button. Push buttons are typically used with JavaScript actions for form submission or other interactions. + +| Parameter | Description | +| --- | --- | +| `name` | Unique identifier for the button. | +| `label` | Text displayed on the button. | +| `w` | Width of the button. | +| `h` | Height of the button. | +| `font_size` | Size of the label text. | +| `font_color_gray` | Gray level (0-1) for the label text. | +| `background_color` | RGB tuple (0-1) for the button background. | +| `border_color` | RGB tuple (0-1) for the button border. | + +### Example: Styled Button + +```python +pdf.push_button( + name="submit", + x=50, y=100, + w=60, h=20, + label="Submit Form", + font_size=14, + background_color=(0.2, 0.4, 0.8), + border_color=(0, 0, 0.5) +) +``` + +## Combo Boxes (Dropdowns) + +The `combo_box()` method creates a dropdown selection list. + +| Parameter | Description | +| --- | --- | +| `name` | Unique identifier for the field. | +| `options` | List of option strings. | +| `value` | Initially selected value. | +| `editable` | If `True`, the user can type a custom value. | +| `w` | Width of the combo box. | +| `h` | Height of the combo box. | + +### Example: Editable Combo Box + +```python +pdf.combo_box( + name="color", + x=10, y=30, + w=80, h=10, + options=["Red", "Green", "Blue", "Custom"], + value="", + editable=True # User can type a custom color +) +``` + +## List Boxes + +The `list_box()` method creates a scrollable list where users can select one or more options. + +| Parameter | Description | +| --- | --- | +| `name` | Unique identifier for the field. | +| `options` | List of option strings. | +| `value` | Initially selected value. | +| `multi_select` | If `True`, multiple options can be selected. | +| `w` | Width of the list box. | +| `h` | Height of the list box. | + +### Example: Multi-Select List Box + +```python +pdf.list_box( + name="interests", + x=10, y=50, + w=80, h=50, + options=["Sports", "Music", "Travel", "Technology", "Art", "Food"], + value="", + multi_select=True +) +``` + +## Field Properties + +All field types support common properties: + +* `read_only`: If `True`, the user cannot modify the field value. +* `required`: If `True`, the field must be filled before the form can be submitted. + +## Compatibility + +`fpdf2` generates **Appearance Streams** for all form fields. This ensures that the fields are visible and rendered correctly across almost all PDF readers, including: +* Adobe Acrobat Reader +* Chrome / Firefox / Edge built-in viewers +* Sumatra PDF +* Mobile PDF viewers + +Note: Form fields require PDF version 1.4 or higher. `fpdf2` will automatically set the document version to 1.4 if any form fields are added. diff --git a/docs/index.md b/docs/index.md index e1ddb92d1..82f3fb8fd 100644 --- a/docs/index.md +++ b/docs/index.md @@ -36,6 +36,7 @@ Go try it **now** online in a Jupyter notebook: [![Open In Colab](https://colab. * Table of contents & [document outline](DocumentOutlineAndTableOfContents.md) * [Document encryption](Encryption.md) & [document signing](Signing.md) * [Annotations](Annotations.md), including text highlights, and [file attachments](FileAttachments.md) +* Interactive [Forms](Forms.md) (AcroForms), including text fields and checkboxes * [Presentation mode](Presentations.md) with control over page display duration & transitions * Optional basic Markdown-like styling: `**bold**, __italics__` * It has very few dependencies: [Pillow](https://pillow.readthedocs.io/en/stable/), [defusedxml](https://pypi.org/project/defusedxml/), & [fonttools](https://pypi.org/project/fonttools/) diff --git a/fpdf/enums.py b/fpdf/enums.py index 7cdae88ef..a6a4ae4aa 100644 --- a/fpdf/enums.py +++ b/fpdf/enums.py @@ -20,6 +20,63 @@ class SignatureFlag(IntEnum): """ +class FieldFlag(IntFlag): + """ + Flags for form field properties (/Ff entry in field dictionary). + These can be combined with bitwise OR (|) operator. + cf. PDF spec section 12.7.3.1 "Field flags common to all field types" + and sections 12.7.4.* for type-specific flags. + """ + + # Common to all field types + READ_ONLY = 1 + "The user may not change the value of the field." + REQUIRED = 2 + "The field shall have a value before the form can be submitted." + NO_EXPORT = 4 + "The field shall not be exported by a submit-form action." + + # Text field specific flags (12.7.4.3) + MULTILINE = 4096 + "The field may contain multiple lines of text." + PASSWORD = 8192 + "The field is intended for entering a secure password." + FILE_SELECT = 1 << 20 + "The field shall allow the user to select a file." + DO_NOT_SPELL_CHECK = 1 << 22 + "Text entered shall not be spell-checked." + DO_NOT_SCROLL = 1 << 23 + "The field shall not scroll to accommodate more text." + COMB = 1 << 24 + "The field shall be divided into equally spaced positions (for character entry)." + RICH_TEXT = 1 << 25 + "The value of this field shall be a rich text string." + + # Button field specific flags (12.7.4.2) + NO_TOGGLE_TO_OFF = 1 << 14 + "For radio buttons: exactly one button shall be selected at all times." + RADIO = 1 << 15 + "The field is a set of radio buttons (vs checkboxes)." + PUSH_BUTTON = 1 << 16 + "The field is a push button that does not retain a permanent value." + # Note: RADIOS_IN_UNISON intentionally shares bit 25 with RICH_TEXT per PDF spec. + # These flags apply to different field types (buttons vs text) so no conflict occurs. + RADIOS_IN_UNISON = 1 << 25 + "Radio buttons with the same value are selected/deselected in unison." + + # Choice field specific flags (12.7.4.4) + COMBO = 1 << 17 + "The field is a combo box (vs list box)." + EDIT = 1 << 18 + "The combo box includes an editable text box." + SORT = 1 << 19 + "The field's option items shall be sorted alphabetically." + MULTI_SELECT = 1 << 21 + "More than one of the field's option items may be selected." + COMMIT_ON_SEL_CHANGE = 1 << 26 + "Value shall be committed as soon as a selection is made." + + class CoerciveEnum(Enum): "An enumeration that provides a helper to coerce strings into enumeration members." diff --git a/fpdf/forms.py b/fpdf/forms.py new file mode 100644 index 000000000..9473eb935 --- /dev/null +++ b/fpdf/forms.py @@ -0,0 +1,818 @@ +""" +Interactive PDF form fields (AcroForms). + +The contents of this module are internal to fpdf2, and not part of the public API. +They may change at any time without prior warning or any deprecation period, +in non-backward-compatible ways. +""" + +from .annotations import PDFAnnotation +from .enums import FieldFlag +from .syntax import Name, PDFArray, PDFContentStream, PDFString + + +# Standard font resource dictionaries for appearance streams. +# These MUST be included in each appearance XObject's /Resources for Adobe Acrobat compatibility. +# Browser PDF viewers are more lenient and may use AcroForm's /DR, but Acrobat requires local resources. +# /ProcSet is required by Adobe Acrobat to properly interpret the content stream operators. +HELV_FONT_RESOURCE = "<>>>>>" +ZADB_FONT_RESOURCE = "<>>>>>" +HELV_ZADB_FONT_RESOURCE = "<> /ZaDb <>>>>>" +# Graphics-only resources dictionary - for appearance streams that use only path operators (no text) +# /ProcSet [/PDF] tells the viewer this stream uses PDF graphics operators +GRAPHICS_ONLY_RESOURCES = "<>" + + +class PDFFormXObject(PDFContentStream): + """A Form XObject used for appearance streams of form fields.""" + + def __init__(self, commands: str, width: float, height: float, resources: str = None): + if isinstance(commands, str): + commands = commands.encode("latin-1") + super().__init__(contents=commands, compress=False) + self.type = Name("XObject") + self.subtype = Name("Form") + self.b_box = PDFArray([0, 0, round(width, 2), round(height, 2)]) + self.form_type = 1 + self._resources_str = resources + + @property + def resources(self): + return self._resources_str + + +class FormField(PDFAnnotation): + """Base class for interactive form fields.""" + + def __init__( + self, + field_type: str, + field_name: str, + x: float, + y: float, + width: float, + height: float, + value=None, + default_value=None, + field_flags: int = 0, + **kwargs, + ): + super().__init__( + subtype="Widget", + x=x, + y=y, + width=width, + height=height, + field_type=field_type, + value=value, + **kwargs, + ) + self.t = PDFString(field_name, encrypt=True) + self.d_v = default_value + self.f_f = field_flags if field_flags else None + self._width = width + self._height = height + self._appearance_normal = None + self._appearance_dict = None + + def _generate_appearance(self, font_name: str = "Helv", font_size: float = 12): + """Generate the appearance stream for this field. Must be overridden by subclasses.""" + raise NotImplementedError("Subclasses must implement _generate_appearance") + + @property + def a_p(self): + """Return the appearance dictionary (/AP) for serialization.""" + if self._appearance_dict: + return self._appearance_dict + if self._appearance_normal: + return f"<>" + return None + + +class TextField(FormField): + """An interactive text input field.""" + + def __init__( + self, + field_name: str, + x: float, + y: float, + width: float, + height: float, + value: str = "", + font_size: float = 12, + font_color_gray: float = 0, + background_color: tuple = None, + border_color: tuple = None, + border_width: float = 1, + max_length: int = None, + multiline: bool = False, + password: bool = False, + read_only: bool = False, + required: bool = False, + **kwargs, + ): + field_flags = 0 + if multiline: + field_flags |= FieldFlag.MULTILINE + if password: + field_flags |= FieldFlag.PASSWORD + if read_only: + field_flags |= FieldFlag.READ_ONLY + if required: + field_flags |= FieldFlag.REQUIRED + + super().__init__( + field_type="Tx", + field_name=field_name, + x=x, + y=y, + width=width, + height=height, + value=PDFString(value, encrypt=True) if value else None, + default_value=PDFString(value, encrypt=True) if value else None, + field_flags=field_flags, + border_width=border_width, + **kwargs, + ) + + self._font_size = font_size + self._font_color_gray = font_color_gray + self._background_color = background_color + self._border_color = border_color + self._multiline = multiline + self._value_str = value or "" + self.max_len = max_length + # Default Appearance (/DA): PDF content stream fragment specifying font and color. + # Format: "/FontName FontSize Tf GrayLevel g" (e.g., "/Helv 12 Tf 0 g" = Helvetica 12pt black) + # Must be a PDFString so it serializes with parentheses as required by PDF spec. + self.d_a = PDFString(f"/Helv {font_size:.2f} Tf {font_color_gray:.2f} g") + + def _generate_appearance(self, font_name: str = "Helv", font_size: float = None): + """Generate the appearance stream XObject for this text field.""" + if font_size is None: + font_size = self._font_size + + width = self._width + height = self._height + value = self._value_str + + commands = [] + commands.append("/Tx BMC") + commands.append("q") + + if self._background_color: + r, g, b = self._background_color + commands.append(f"{r:.3f} {g:.3f} {b:.3f} rg") + commands.append(f"0 0 {width:.2f} {height:.2f} re") + commands.append("f") + + if self._border_color: + r, g, b = self._border_color + commands.append(f"{r:.3f} {g:.3f} {b:.3f} RG") + commands.append("1 w") + commands.append(f"0.5 0.5 {width - 1:.2f} {height - 1:.2f} re") + commands.append("S") + + if value: + commands.append(f"2 2 {width - 4:.2f} {height - 4:.2f} re W n") + commands.append("BT") + commands.append(f"/{font_name} {font_size:.2f} Tf") + commands.append(f"{self._font_color_gray:.2f} g") + + text_y = (height - font_size) / 2 + 2 + if self._multiline: + text_y = height - font_size - 2 + commands.append(f"2 {text_y:.2f} Td") + + escaped_value = value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + commands.append(f"({escaped_value}) Tj") + commands.append("ET") + + commands.append("Q") + commands.append("EMC") + + content = "\n".join(commands) + + # Include font resources in the appearance XObject for Adobe Acrobat compatibility + self._appearance_normal = PDFFormXObject(content, width, height, resources=HELV_FONT_RESOURCE) + return self._appearance_normal + + +class Checkbox(FormField): + """An interactive checkbox field.""" + + CHECK_CHAR = "4" + + def __init__( + self, + field_name: str, + x: float, + y: float, + size: float = 12, + checked: bool = False, + background_color: tuple = (1, 1, 1), + border_color: tuple = (0, 0, 0), + check_color_gray: float = 0, + border_width: float = 1, + read_only: bool = False, + required: bool = False, + **kwargs, + ): + field_flags = 0 + if read_only: + field_flags |= FieldFlag.READ_ONLY + if required: + field_flags |= FieldFlag.REQUIRED + + value = Name("Yes") if checked else Name("Off") + + super().__init__( + field_type="Btn", + field_name=field_name, + x=x, + y=y, + width=size, + height=size, + value=value, + default_value=value, + field_flags=field_flags, + border_width=border_width, + **kwargs, + ) + + self._size = size + self._checked = checked + self._background_color = background_color + self._border_color = border_color + self._check_color_gray = check_color_gray + # Default Appearance (/DA): PDF content stream fragment for the checkmark. + # Uses ZapfDingbats font (/ZaDb) which contains the checkmark character. + # Must be a PDFString so it serializes with parentheses as required by PDF spec. + self.d_a = PDFString(f"/ZaDb {size * 0.8:.2f} Tf {check_color_gray:.2f} g") + self.a_s = Name("Yes") if checked else Name("Off") + + def _generate_appearance(self, font_name: str = "ZaDb", font_size: float = None): + """Generate appearance streams for checked and unchecked states.""" + size = self._size + if font_size is None: + font_size = size * 0.8 + + off_commands = self._generate_box_appearance(size, show_check=False) + # Use graphics-only resources since checkmark is drawn with path operators (no font) + off_xobj = PDFFormXObject(off_commands, size, size, resources=GRAPHICS_ONLY_RESOURCES) + + yes_commands = self._generate_box_appearance(size, show_check=True, font_size=font_size) + yes_xobj = PDFFormXObject(yes_commands, size, size, resources=GRAPHICS_ONLY_RESOURCES) + + self._appearance_off = off_xobj + self._appearance_yes = yes_xobj + + return off_xobj, yes_xobj + + def _generate_box_appearance(self, size: float, show_check: bool, font_size: float = None) -> str: + """Generate the appearance commands for a checkbox box.""" + commands = [] + commands.append("q") + + if self._background_color: + r, g, b = self._background_color + commands.append(f"{r:.3f} {g:.3f} {b:.3f} rg") + commands.append(f"0 0 {size:.2f} {size:.2f} re") + commands.append("f") + + if self._border_color: + r, g, b = self._border_color + commands.append(f"{r:.3f} {g:.3f} {b:.3f} RG") + commands.append("1 w") + commands.append(f"0.5 0.5 {size - 1:.2f} {size - 1:.2f} re") + commands.append("S") + + if show_check: + # Draw graphical checkmark using path operators (no font dependency) + # This ensures compatibility with Adobe Acrobat without font resolution issues + commands.append(f"{self._check_color_gray:.2f} G") # Stroke color (gray) + line_width = max(1.5, size * 0.12) # Scale line width with checkbox size + commands.append(f"{line_width:.2f} w") + commands.append("1 J") # Round line caps + commands.append("1 j") # Round line joins + # Checkmark path: starts from left, goes down to bottom-center, then up to top-right + x1 = size * 0.20 # Start point (left side) + y1 = size * 0.55 + x2 = size * 0.40 # Bottom point (center-left) + y2 = size * 0.25 + x3 = size * 0.80 # End point (top-right) + y3 = size * 0.80 + commands.append(f"{x1:.2f} {y1:.2f} m") # Move to start + commands.append(f"{x2:.2f} {y2:.2f} l") # Line to bottom + commands.append(f"{x3:.2f} {y3:.2f} l") # Line to top-right + commands.append("S") # Stroke the path + + commands.append("Q") + return "\n".join(commands) + + @property + def a_p(self): + """Return the appearance dictionary for checkbox.""" + if self._appearance_off and self._appearance_yes: + return f"<>>>" + return None + + +class RadioButton(FormField): + """ + An interactive radio button field. + + Radio buttons work in groups - buttons with the same name are part of a group, + and selecting one deselects the others. + """ + + # ZapfDingbats bullet character for radio button + BULLET_CHAR = "l" # Filled circle in ZapfDingbats + + def __init__( + self, + field_name: str, + x: float, + y: float, + size: float = 12, + selected: bool = False, + export_value: str = "Choice1", + background_color: tuple = (1, 1, 1), + border_color: tuple = (0, 0, 0), + mark_color_gray: float = 0, + border_width: float = 1, + read_only: bool = False, + required: bool = False, + no_toggle_to_off: bool = True, + **kwargs, + ): + field_flags = FieldFlag.RADIO + if read_only: + field_flags |= FieldFlag.READ_ONLY + if required: + field_flags |= FieldFlag.REQUIRED + if no_toggle_to_off: + field_flags |= FieldFlag.NO_TOGGLE_TO_OFF + + value = Name(export_value) if selected else Name("Off") + + super().__init__( + field_type="Btn", + field_name=field_name, + x=x, + y=y, + width=size, + height=size, + value=value, + default_value=value, + field_flags=field_flags, + border_width=border_width, + **kwargs, + ) + + self._size = size + self._selected = selected + self._export_value = export_value + self._background_color = background_color + self._border_color = border_color + self._mark_color_gray = mark_color_gray + self.d_a = PDFString(f"/ZaDb {size * 0.6:.2f} Tf {mark_color_gray:.2f} g") + self.a_s = Name(export_value) if selected else Name("Off") + + def _generate_appearance(self, font_name: str = "ZaDb", font_size: float = None): + """Generate appearance streams for selected and unselected states.""" + size = self._size + + off_commands = self._generate_circle_appearance(size, show_mark=False) + # Use graphics-only resources since circles are drawn with path operators (no font) + off_xobj = PDFFormXObject(off_commands, size, size, resources=GRAPHICS_ONLY_RESOURCES) + + on_commands = self._generate_circle_appearance(size, show_mark=True) + # Use graphics-only resources since circles are drawn with path operators (no font) + on_xobj = PDFFormXObject(on_commands, size, size, resources=GRAPHICS_ONLY_RESOURCES) + + self._appearance_off = off_xobj + self._appearance_on = on_xobj + + return off_xobj, on_xobj + + def _generate_circle_appearance(self, size: float, show_mark: bool, font_size: float = None) -> str: + """Generate the appearance commands for a radio button circle.""" + commands = [] + commands.append("q") + + # Draw circle using Bezier curves (approximation) + cx, cy = size / 2, size / 2 + r = size / 2 - 1 # radius with margin for border + + # Bezier control point offset for circle approximation + k = 0.5523 # (4/3) * (sqrt(2) - 1) + kr = k * r + + if self._background_color: + r_col, g_col, b_col = self._background_color + commands.append(f"{r_col:.3f} {g_col:.3f} {b_col:.3f} rg") + # Draw filled circle + commands.append(f"{cx + r:.2f} {cy:.2f} m") + commands.append(f"{cx + r:.2f} {cy + kr:.2f} {cx + kr:.2f} {cy + r:.2f} {cx:.2f} {cy + r:.2f} c") + commands.append(f"{cx - kr:.2f} {cy + r:.2f} {cx - r:.2f} {cy + kr:.2f} {cx - r:.2f} {cy:.2f} c") + commands.append(f"{cx - r:.2f} {cy - kr:.2f} {cx - kr:.2f} {cy - r:.2f} {cx:.2f} {cy - r:.2f} c") + commands.append(f"{cx + kr:.2f} {cy - r:.2f} {cx + r:.2f} {cy - kr:.2f} {cx + r:.2f} {cy:.2f} c") + commands.append("f") + + if self._border_color: + r_col, g_col, b_col = self._border_color + commands.append(f"{r_col:.3f} {g_col:.3f} {b_col:.3f} RG") + commands.append("1 w") + # Draw circle outline + commands.append(f"{cx + r:.2f} {cy:.2f} m") + commands.append(f"{cx + r:.2f} {cy + kr:.2f} {cx + kr:.2f} {cy + r:.2f} {cx:.2f} {cy + r:.2f} c") + commands.append(f"{cx - kr:.2f} {cy + r:.2f} {cx - r:.2f} {cy + kr:.2f} {cx - r:.2f} {cy:.2f} c") + commands.append(f"{cx - r:.2f} {cy - kr:.2f} {cx - kr:.2f} {cy - r:.2f} {cx:.2f} {cy - r:.2f} c") + commands.append(f"{cx + kr:.2f} {cy - r:.2f} {cx + r:.2f} {cy - kr:.2f} {cx + r:.2f} {cy:.2f} c") + commands.append("s") + + if show_mark: + # Draw a smaller filled circle as the selection mark (graphical, no font needed) + mark_r = r * 0.5 # Inner mark is 50% of the outer radius + mark_kr = k * mark_r + commands.append(f"{self._mark_color_gray:.3f} g") + commands.append(f"{cx + mark_r:.2f} {cy:.2f} m") + commands.append(f"{cx + mark_r:.2f} {cy + mark_kr:.2f} {cx + mark_kr:.2f} {cy + mark_r:.2f} {cx:.2f} {cy + mark_r:.2f} c") + commands.append(f"{cx - mark_kr:.2f} {cy + mark_r:.2f} {cx - mark_r:.2f} {cy + mark_kr:.2f} {cx - mark_r:.2f} {cy:.2f} c") + commands.append(f"{cx - mark_r:.2f} {cy - mark_kr:.2f} {cx - mark_kr:.2f} {cy - mark_r:.2f} {cx:.2f} {cy - mark_r:.2f} c") + commands.append(f"{cx + mark_kr:.2f} {cy - mark_r:.2f} {cx + mark_r:.2f} {cy - mark_kr:.2f} {cx + mark_r:.2f} {cy:.2f} c") + commands.append("f") + + commands.append("Q") + return "\n".join(commands) + + @property + def a_p(self): + """Return the appearance dictionary for radio button.""" + if self._appearance_off and self._appearance_on: + # Use the export value directly (it's already a string) + return f"<>>>" + return None + + +class PushButton(FormField): + """ + An interactive push button field. + + Push buttons do not retain a permanent value and are typically used + to trigger actions like form submission or reset. + """ + + def __init__( + self, + field_name: str, + x: float, + y: float, + width: float, + height: float, + label: str = "", + font_size: float = 12, + font_color_gray: float = 0, + background_color: tuple = (0.9, 0.9, 0.9), + border_color: tuple = (0, 0, 0), + border_width: float = 1, + read_only: bool = False, + **kwargs, + ): + field_flags = FieldFlag.PUSH_BUTTON + if read_only: + field_flags |= FieldFlag.READ_ONLY + + super().__init__( + field_type="Btn", + field_name=field_name, + x=x, + y=y, + width=width, + height=height, + value=None, + default_value=None, + field_flags=field_flags, + border_width=border_width, + **kwargs, + ) + + self._width = width + self._height = height + self._label = label + self._font_size = font_size + self._font_color_gray = font_color_gray + self._background_color = background_color + self._border_color = border_color + self.d_a = PDFString(f"/Helv {font_size:.2f} Tf {font_color_gray:.2f} g") + + def _generate_appearance(self, font_name: str = "Helv", font_size: float = None): + """Generate the appearance stream XObject for this push button.""" + if font_size is None: + font_size = self._font_size + + width = self._width + height = self._height + label = self._label + + commands = [] + commands.append("q") + + # Background + if self._background_color: + r, g, b = self._background_color + commands.append(f"{r:.3f} {g:.3f} {b:.3f} rg") + commands.append(f"0 0 {width:.2f} {height:.2f} re") + commands.append("f") + + # Border with 3D effect + if self._border_color: + # Light edge (top and left) + commands.append("1 1 1 RG") + commands.append("1 w") + commands.append(f"0 0 m {width:.2f} 0 l S") + commands.append(f"0 0 m 0 {height:.2f} l S") + # Dark edge (bottom and right) + commands.append("0.5 0.5 0.5 RG") + commands.append(f"{width:.2f} 0 m {width:.2f} {height:.2f} l S") + commands.append(f"0 {height:.2f} m {width:.2f} {height:.2f} l S") + + # Label text centered + if label: + commands.append("BT") + commands.append(f"/{font_name} {font_size:.2f} Tf") + commands.append(f"{self._font_color_gray:.2f} g") + # Approximate centering + text_width = len(label) * font_size * 0.5 + x_pos = (width - text_width) / 2 + y_pos = (height - font_size) / 2 + 2 + commands.append(f"{x_pos:.2f} {y_pos:.2f} Td") + escaped_label = label.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + commands.append(f"({escaped_label}) Tj") + commands.append("ET") + + commands.append("Q") + + content = "\n".join(commands) + # Include Helvetica font resource for the button label + self._appearance_normal = PDFFormXObject(content, width, height, resources=HELV_FONT_RESOURCE) + return self._appearance_normal + + +class ChoiceField(FormField): + """ + Base class for choice fields (list boxes and combo boxes). + """ + + def __init__( + self, + field_name: str, + x: float, + y: float, + width: float, + height: float, + options: list, + value: str = None, + font_size: float = 12, + font_color_gray: float = 0, + background_color: tuple = (1, 1, 1), + border_color: tuple = (0, 0, 0), + border_width: float = 1, + is_combo: bool = False, + editable: bool = False, + multi_select: bool = False, + read_only: bool = False, + required: bool = False, + sort: bool = False, + **kwargs, + ): + field_flags = 0 + if is_combo: + field_flags |= FieldFlag.COMBO + if editable: + field_flags |= FieldFlag.EDIT + if multi_select: + field_flags |= FieldFlag.MULTI_SELECT + if read_only: + field_flags |= FieldFlag.READ_ONLY + if required: + field_flags |= FieldFlag.REQUIRED + if sort: + field_flags |= FieldFlag.SORT + + super().__init__( + field_type="Ch", + field_name=field_name, + x=x, + y=y, + width=width, + height=height, + value=PDFString(value, encrypt=True) if value else None, + default_value=PDFString(value, encrypt=True) if value else None, + field_flags=field_flags, + border_width=border_width, + **kwargs, + ) + + self._width = width + self._height = height + self._options = options + self._value_str = value or "" + self._font_size = font_size + self._font_color_gray = font_color_gray + self._background_color = background_color + self._border_color = border_color + self._is_combo = is_combo + self.d_a = PDFString(f"/Helv {font_size:.2f} Tf {font_color_gray:.2f} g") + # Options array - can be simple strings or [export_value, display_value] pairs + self.opt = PDFArray([PDFString(opt, encrypt=True) for opt in options]) + + def _generate_appearance(self, font_name: str = "Helv", font_size: float = None): + """Generate the appearance stream XObject for this choice field.""" + if font_size is None: + font_size = self._font_size + + width = self._width + height = self._height + value = self._value_str + + commands = [] + commands.append("q") + + # Background + if self._background_color: + r, g, b = self._background_color + commands.append(f"{r:.3f} {g:.3f} {b:.3f} rg") + commands.append(f"0 0 {width:.2f} {height:.2f} re") + commands.append("f") + + # Border + if self._border_color: + r, g, b = self._border_color + commands.append(f"{r:.3f} {g:.3f} {b:.3f} RG") + commands.append("1 w") + commands.append(f"0.5 0.5 {width - 1:.2f} {height - 1:.2f} re") + commands.append("S") + + # For combo boxes, draw dropdown arrow + if self._is_combo: + arrow_size = min(height - 4, 10) + arrow_x = width - arrow_size - 2 + arrow_y = (height - arrow_size) / 2 + commands.append("0.5 0.5 0.5 rg") + # Simple triangle + commands.append(f"{arrow_x:.2f} {arrow_y + arrow_size:.2f} m") + commands.append(f"{arrow_x + arrow_size:.2f} {arrow_y + arrow_size:.2f} l") + commands.append(f"{arrow_x + arrow_size / 2:.2f} {arrow_y:.2f} l") + commands.append("f") + + # Display current value + if value: + commands.append("BT") + commands.append(f"/{font_name} {font_size:.2f} Tf") + commands.append(f"{self._font_color_gray:.2f} g") + y_pos = (height - font_size) / 2 + 2 + commands.append(f"2 {y_pos:.2f} Td") + escaped_value = value.replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + commands.append(f"({escaped_value}) Tj") + commands.append("ET") + + commands.append("Q") + + content = "\n".join(commands) + # Include Helvetica font resource for Adobe Acrobat compatibility + self._appearance_normal = PDFFormXObject(content, width, height, resources=HELV_FONT_RESOURCE) + return self._appearance_normal + + +class ComboBox(ChoiceField): + """An interactive combo box (dropdown list) field.""" + + def __init__( + self, + field_name: str, + x: float, + y: float, + width: float, + height: float, + options: list, + value: str = None, + editable: bool = False, + **kwargs, + ): + super().__init__( + field_name=field_name, + x=x, + y=y, + width=width, + height=height, + options=options, + value=value, + is_combo=True, + editable=editable, + **kwargs, + ) + + +class ListBox(ChoiceField): + """An interactive list box field.""" + + def __init__( + self, + field_name: str, + x: float, + y: float, + width: float, + height: float, + options: list, + value: str = None, + multi_select: bool = False, + **kwargs, + ): + super().__init__( + field_name=field_name, + x=x, + y=y, + width=width, + height=height, + options=options, + value=value, + is_combo=False, + multi_select=multi_select, + **kwargs, + ) + + def _generate_appearance(self, font_name: str = "Helv", font_size: float = None): + """Generate the appearance stream for list box showing options.""" + if font_size is None: + font_size = self._font_size + + width = self._width + height = self._height + + commands = [] + commands.append("q") + + # Background + if self._background_color: + r, g, b = self._background_color + commands.append(f"{r:.3f} {g:.3f} {b:.3f} rg") + commands.append(f"0 0 {width:.2f} {height:.2f} re") + commands.append("f") + + # Border + if self._border_color: + r, g, b = self._border_color + commands.append(f"{r:.3f} {g:.3f} {b:.3f} RG") + commands.append("1 w") + commands.append(f"0.5 0.5 {width - 1:.2f} {height - 1:.2f} re") + commands.append("S") + + # Establish clipping path for content area (prevents text overflow) + commands.append(f"2 2 {width - 4:.2f} {height - 4:.2f} re W n") + + # Draw options + line_height = font_size + 2 + max_lines = int((height - 4) / line_height) + y_pos = height - font_size - 2 + + # First, draw highlight rectangles for selected items (outside of BT/ET) + for i, option in enumerate(self._options[:max_lines]): + option_y = y_pos - i * line_height + if option_y < 2: + break + if option == self._value_str: + commands.append("0.8 0.8 1 rg") + commands.append(f"2 {option_y - 2:.2f} {width - 4:.2f} {line_height:.2f} re f") + + # Now draw all option texts + commands.append("BT") + commands.append(f"/{font_name} {font_size:.2f} Tf") + commands.append(f"{self._font_color_gray:.2f} g") + + first_line = True + for i, option in enumerate(self._options[:max_lines]): + option_y = y_pos - i * line_height + if option_y < 2: + break + + if first_line: + # First Td uses absolute position from origin + commands.append(f"2 {option_y:.2f} Td") + first_line = False + else: + # Subsequent Td moves relative to previous position (just move down by line_height) + commands.append(f"0 {-line_height:.2f} Td") + + escaped_option = str(option).replace("\\", "\\\\").replace("(", "\\(").replace(")", "\\)") + commands.append(f"({escaped_option}) Tj") + + commands.append("ET") + commands.append("Q") + + content = "\n".join(commands) + # Include Helvetica font resource for Adobe Acrobat compatibility + self._appearance_normal = PDFFormXObject(content, width, height, resources=HELV_FONT_RESOURCE) + return self._appearance_normal diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index bd0fbca8e..9d0cdefc5 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -88,6 +88,7 @@ class Image: Corner, DocumentCompliance, EncryptionMethod, + FieldFlag, FileAttachmentAnnotationName, MethodReturnValue, OutputIntentSubType, @@ -113,6 +114,7 @@ class Image: PDFAComplianceError, ) from .fonts import CORE_FONTS, CoreFont, FontFace, TextStyle, TitleStyle, TTFFont +from .forms import Checkbox, ComboBox, ListBox, PushButton, RadioButton, TextField from .graphics_state import GraphicsStateMixin from .html import HTML2FPDF from .image_datastructures import ( @@ -3130,6 +3132,372 @@ def ink_annotation( self.pages[self.page].annots.append(annotation) return annotation + # ---- Interactive Form Fields ---- + + @check_page + def text_field( + self, + name: str, + x: float, + y: float, + w: float, + h: float, + value: str = "", + font_size: float = 12, + font_color_gray: float = 0, + background_color: tuple = None, + border_color: tuple = (0, 0, 0), + border_width: float = 1, + max_length: int = None, + multiline: bool = False, + password: bool = False, + read_only: bool = False, + required: bool = False, + ): + """ + Adds an interactive text input field to the page. + + Args: + name (str): unique name for this field + x (float): horizontal position (from the left) of the field + y (float): vertical position (from the top) of the field + w (float): width of the field + h (float): height of the field + value (str): initial text value + font_size (float): font size in points + font_color_gray (float): gray value 0.0-1.0 for text color (0=black, 1=white) + background_color (tuple): optional RGB tuple (0-1 range) for background + border_color (tuple): RGB tuple (0-1 range) for border + border_width (float): border width + max_length (int): maximum number of characters allowed + multiline (bool): if True, allow multiple lines of text + password (bool): if True, mask entered characters + read_only (bool): if True, field cannot be edited + required (bool): if True, field must be filled before form submission + """ + self._set_min_pdf_version("1.4") + + field = TextField( + field_name=name, + x=x * self.k, + y=self.h_pt - y * self.k, + width=w * self.k, + height=h * self.k, + value=value, + font_size=font_size, + font_color_gray=font_color_gray, + background_color=background_color, + border_color=border_color, + border_width=border_width, + max_length=max_length, + multiline=multiline, + password=password, + read_only=read_only, + required=required, + ) + + field._generate_appearance() + self.pages[self.page].annots.append(field) + + return field + + @check_page + def checkbox( + self, + name: str, + x: float, + y: float, + size: float = 12, + checked: bool = False, + background_color: tuple = (1, 1, 1), + border_color: tuple = (0, 0, 0), + check_color_gray: float = 0, + border_width: float = 1, + read_only: bool = False, + required: bool = False, + ): + """ + Adds an interactive checkbox to the page. + + Args: + name (str): unique name for this checkbox + x (float): horizontal position (from the left) of the checkbox + y (float): vertical position (from the top) of the checkbox + size (float): size of the checkbox + checked (bool): initial checked state + background_color (tuple): RGB tuple (0-1 range) for background + border_color (tuple): RGB tuple (0-1 range) for border + check_color_gray (float): gray value 0.0-1.0 for checkmark (0=black) + border_width (float): border width + read_only (bool): if True, checkbox cannot be toggled + required (bool): if True, checkbox must be checked before form submission + """ + self._set_min_pdf_version("1.4") + + field = Checkbox( + field_name=name, + x=x * self.k, + y=self.h_pt - y * self.k, + size=size * self.k, + checked=checked, + background_color=background_color, + border_color=border_color, + check_color_gray=check_color_gray, + border_width=border_width, + read_only=read_only, + required=required, + ) + + field._generate_appearance() + self.pages[self.page].annots.append(field) + + return field + + @check_page + def radio_button( + self, + name: str, + x: float, + y: float, + size: float = 12, + selected: bool = False, + export_value: str = "Choice1", + background_color: tuple = (1, 1, 1), + border_color: tuple = (0, 0, 0), + mark_color_gray: float = 0, + border_width: float = 1, + read_only: bool = False, + required: bool = False, + no_toggle_to_off: bool = True, + ): + """ + Adds an interactive radio button to the page. + + Radio buttons with the same name form a group where only one can be selected. + + Args: + name (str): name for this radio button group + x (float): horizontal position (from the left) of the radio button + y (float): vertical position (from the top) of the radio button + size (float): size of the radio button + selected (bool): initial selected state + export_value (str): value exported when this button is selected + background_color (tuple): RGB tuple (0-1 range) for background + border_color (tuple): RGB tuple (0-1 range) for border + mark_color_gray (float): gray value 0.0-1.0 for selection mark (0=black) + border_width (float): border width + read_only (bool): if True, radio button cannot be toggled + required (bool): if True, one option must be selected before form submission + no_toggle_to_off (bool): if True, clicking selected button doesn't deselect it + """ + self._set_min_pdf_version("1.4") + + field = RadioButton( + field_name=name, + x=x * self.k, + y=self.h_pt - y * self.k, + size=size * self.k, + selected=selected, + export_value=export_value, + background_color=background_color, + border_color=border_color, + mark_color_gray=mark_color_gray, + border_width=border_width, + read_only=read_only, + required=required, + no_toggle_to_off=no_toggle_to_off, + ) + + field._generate_appearance() + self.pages[self.page].annots.append(field) + + return field + + @check_page + def push_button( + self, + name: str, + x: float, + y: float, + w: float, + h: float, + label: str = "", + font_size: float = 12, + font_color_gray: float = 0, + background_color: tuple = (0.9, 0.9, 0.9), + border_color: tuple = (0, 0, 0), + border_width: float = 1, + read_only: bool = False, + ): + """ + Adds an interactive push button to the page. + + Push buttons are typically used to trigger actions like form submission or reset. + + Args: + name (str): unique name for this button + x (float): horizontal position (from the left) of the button + y (float): vertical position (from the top) of the button + w (float): width of the button + h (float): height of the button + label (str): text label displayed on the button + font_size (float): font size for the label in points + font_color_gray (float): gray value 0.0-1.0 for text color (0=black) + background_color (tuple): RGB tuple (0-1 range) for background + border_color (tuple): RGB tuple (0-1 range) for border + border_width (float): border width + read_only (bool): if True, button cannot be clicked + """ + self._set_min_pdf_version("1.4") + + field = PushButton( + field_name=name, + x=x * self.k, + y=self.h_pt - y * self.k, + width=w * self.k, + height=h * self.k, + label=label, + font_size=font_size, + font_color_gray=font_color_gray, + background_color=background_color, + border_color=border_color, + border_width=border_width, + read_only=read_only, + ) + + field._generate_appearance() + self.pages[self.page].annots.append(field) + + return field + + @check_page + def combo_box( + self, + name: str, + x: float, + y: float, + w: float, + h: float, + options: list, + value: str = None, + font_size: float = 12, + font_color_gray: float = 0, + background_color: tuple = (1, 1, 1), + border_color: tuple = (0, 0, 0), + border_width: float = 1, + editable: bool = False, + read_only: bool = False, + required: bool = False, + ): + """ + Adds an interactive combo box (dropdown list) to the page. + + Args: + name (str): unique name for this field + x (float): horizontal position (from the left) of the field + y (float): vertical position (from the top) of the field + w (float): width of the field + h (float): height of the field + options (list): list of option strings + value (str): initially selected value + font_size (float): font size in points + font_color_gray (float): gray value 0.0-1.0 for text color (0=black) + background_color (tuple): RGB tuple (0-1 range) for background + border_color (tuple): RGB tuple (0-1 range) for border + border_width (float): border width + editable (bool): if True, user can type a custom value + read_only (bool): if True, field cannot be edited + required (bool): if True, field must have a selection before form submission + """ + self._set_min_pdf_version("1.4") + + field = ComboBox( + field_name=name, + x=x * self.k, + y=self.h_pt - y * self.k, + width=w * self.k, + height=h * self.k, + options=options, + value=value, + font_size=font_size, + font_color_gray=font_color_gray, + background_color=background_color, + border_color=border_color, + border_width=border_width, + editable=editable, + read_only=read_only, + required=required, + ) + + field._generate_appearance() + self.pages[self.page].annots.append(field) + + return field + + @check_page + def list_box( + self, + name: str, + x: float, + y: float, + w: float, + h: float, + options: list, + value: str = None, + font_size: float = 12, + font_color_gray: float = 0, + background_color: tuple = (1, 1, 1), + border_color: tuple = (0, 0, 0), + border_width: float = 1, + multi_select: bool = False, + read_only: bool = False, + required: bool = False, + ): + """ + Adds an interactive list box to the page. + + Args: + name (str): unique name for this field + x (float): horizontal position (from the left) of the field + y (float): vertical position (from the top) of the field + w (float): width of the field + h (float): height of the field + options (list): list of option strings + value (str): initially selected value + font_size (float): font size in points + font_color_gray (float): gray value 0.0-1.0 for text color (0=black) + background_color (tuple): RGB tuple (0-1 range) for background + border_color (tuple): RGB tuple (0-1 range) for border + border_width (float): border width + multi_select (bool): if True, multiple options can be selected + read_only (bool): if True, field cannot be edited + required (bool): if True, field must have a selection before form submission + """ + self._set_min_pdf_version("1.4") + + field = ListBox( + field_name=name, + x=x * self.k, + y=self.h_pt - y * self.k, + width=w * self.k, + height=h * self.k, + options=options, + value=value, + font_size=font_size, + font_color_gray=font_color_gray, + background_color=background_color, + border_color=border_color, + border_width=border_width, + multi_select=multi_select, + read_only=read_only, + required=required, + ) + + field._generate_appearance() + self.pages[self.page].annots.append(field) + + return field + @check_page @support_deprecated_txt_arg def text(self, x, y, text=""): diff --git a/fpdf/output.py b/fpdf/output.py index 0f26ffbaa..a8097c407 100644 --- a/fpdf/output.py +++ b/fpdf/output.py @@ -21,6 +21,7 @@ from .annotations import PDFAnnotation from .drawing import PaintSoftMask, Transform from .enums import OutputIntentSubType, PageLabelStyle, PDFResourceType, SignatureFlag +from .forms import FormField from .errors import FPDFException from .fonts import CORE_FONTS, CoreFont, TTFFont from .font_type_3 import Type3Font @@ -223,10 +224,65 @@ def __init__( self.creation_date = creation_date +# Standard fonts available in the AcroForm /DR dictionary for form field appearance streams. +# PDF spec recommends using short names like "Helv" for form fields. +# These are embedded inline in the /DR dictionary as Type1 fonts. +ACROFORM_STANDARD_FONTS = { + "Helv": {"Subtype": "Type1", "BaseFont": "Helvetica", "Encoding": "WinAnsiEncoding"}, + "ZaDb": {"Subtype": "Type1", "BaseFont": "ZapfDingbats"}, +} + + +def _build_acroform_default_resources(font_names=None): + """ + Build the /DR (Default Resources) dictionary for AcroForm. + + Args: + font_names: List of font short names to include (e.g., ["Helv", "ZaDb"]). + If None, includes all standard fonts. + + Returns: + A string containing the /DR dictionary, or None if no fonts. + """ + if font_names is None: + font_names = list(ACROFORM_STANDARD_FONTS.keys()) + + font_dicts = [] + for name in font_names: + if name in ACROFORM_STANDARD_FONTS: + font_info = ACROFORM_STANDARD_FONTS[name] + parts = ["/Type /Font"] + for key, value in font_info.items(): + parts.append(f"/{key} /{value}") + font_dicts.append(f"/{name} <<{' '.join(parts)}>>") + + if not font_dicts: + return None + + # Structure: <> /ZaDb <<...>>>>>> + # - Outer << >> is the /DR dictionary (2 brackets) + # - Inner << >> is the /Font subdictionary (2 brackets) + # - Each font entry /{name} <<...>> has its own dict (2 brackets each) + # After joining font entries (which end with >>), we add 4 closing >> (2 for Font, 2 for DR) + return f"<>>>" + + class AcroForm: - def __init__(self, fields, sig_flags): + """Represents the AcroForm dictionary in the document catalog.""" + + def __init__( + self, + fields, + sig_flags=None, + need_appearances: bool = True, + default_appearance: str = None, + default_resources: str = None, + ): self.fields = fields self.sig_flags = sig_flags + self.need_appearances = need_appearances + self.d_a = default_appearance + self.d_r = default_resources def serialize(self, _security_handler=None, _obj_id=None): obj_dict = build_obj_dict( @@ -1047,6 +1103,22 @@ def _add_annotations_as_objects(self): for page_obj in self.fpdf.pages.values(): for annot_obj in page_obj.annots: if isinstance(annot_obj, PDFAnnotation): # distinct from AnnotationDict + # For form fields, add their appearance XObjects first + if isinstance(annot_obj, FormField): + # Add appearance stream XObjects before the annotation that references them. + # These attributes are set by _generate_appearance() which is called + # during field creation. TextField uses _appearance_normal; Checkbox + # uses _appearance_off and _appearance_yes for its toggle states; + # RadioButton uses _appearance_off and _appearance_on. + if hasattr(annot_obj, '_appearance_normal') and annot_obj._appearance_normal: + self._add_pdf_obj(annot_obj._appearance_normal) + if hasattr(annot_obj, '_appearance_off') and annot_obj._appearance_off: + self._add_pdf_obj(annot_obj._appearance_off) + if hasattr(annot_obj, '_appearance_yes') and annot_obj._appearance_yes: + self._add_pdf_obj(annot_obj._appearance_yes) + if hasattr(annot_obj, '_appearance_on') and annot_obj._appearance_on: + self._add_pdf_obj(annot_obj._appearance_on) + self._add_pdf_obj(annot_obj) if isinstance(annot_obj.v, Signature): assert ( @@ -1785,10 +1857,49 @@ def _finalize_catalog( catalog_obj.struct_tree_root = struct_tree_root_obj catalog_obj.outlines = outline_dict_obj catalog_obj.metadata = xmp_metadata_obj - if sig_annotation_obj: - flags = SignatureFlag.SIGNATURES_EXIST + SignatureFlag.APPEND_ONLY + + # Collect all form fields from all pages + all_form_fields = [] + for page_obj in fpdf.pages.values(): + for annot_obj in page_obj.annots: + if isinstance(annot_obj, FormField): + all_form_fields.append(annot_obj) + + # Build AcroForm if there are form fields or a signature + if all_form_fields or sig_annotation_obj: + # Combine signature and form fields + acro_fields = [] + sig_flags = None + + if sig_annotation_obj: + acro_fields.append(sig_annotation_obj) + sig_flags = SignatureFlag.SIGNATURES_EXIST + SignatureFlag.APPEND_ONLY + + acro_fields.extend(all_form_fields) + + # Build default resources with standard fonts for form fields + default_resources = None + default_appearance = None + if all_form_fields: + # Build /DR dictionary with standard fonts used by form fields. + # Currently uses Helvetica (Helv) for text and ZapfDingbats (ZaDb) for checkmarks. + # Future enhancement: could integrate with FPDF font system for TrueType support. + default_resources = _build_acroform_default_resources(["Helv", "ZaDb"]) + # Default Appearance string (/DA) per PDF spec 12.7.3.3. + # The parentheses are required - this is a PDF literal string value. + # Format: "(content_stream_fragment)" e.g., "(/Helv 0 Tf 0 g)" + default_appearance = "(/Helv 0 Tf 0 g)" + catalog_obj.acro_form = AcroForm( - fields=PDFArray([sig_annotation_obj]), sig_flags=flags + fields=PDFArray(acro_fields), + sig_flags=sig_flags, + # NeedAppearances should be False when we provide custom appearance streams. + # Setting it to True tells the viewer to regenerate appearances, which would + # override our custom /AP entries. We generate appearances for all form fields, + # so we don't need the viewer to regenerate them. + need_appearances=None, # Omit from output (equivalent to false) + default_appearance=default_appearance, + default_resources=default_resources, ) if fpdf.zoom_mode in ZOOM_CONFIGS: zoom_config = [ diff --git a/test/forms/checkbox_checked.pdf b/test/forms/checkbox_checked.pdf new file mode 100644 index 000000000..7cd3b8d55 Binary files /dev/null and b/test/forms/checkbox_checked.pdf differ diff --git a/test/forms/checkbox_readonly.pdf b/test/forms/checkbox_readonly.pdf new file mode 100644 index 000000000..29a042c55 Binary files /dev/null and b/test/forms/checkbox_readonly.pdf differ diff --git a/test/forms/checkbox_unchecked.pdf b/test/forms/checkbox_unchecked.pdf new file mode 100644 index 000000000..46e351286 Binary files /dev/null and b/test/forms/checkbox_unchecked.pdf differ diff --git a/test/forms/combo_box_basic.pdf b/test/forms/combo_box_basic.pdf new file mode 100644 index 000000000..78f51241d Binary files /dev/null and b/test/forms/combo_box_basic.pdf differ diff --git a/test/forms/combo_box_editable.pdf b/test/forms/combo_box_editable.pdf new file mode 100644 index 000000000..f049ddb1b Binary files /dev/null and b/test/forms/combo_box_editable.pdf differ diff --git a/test/forms/complete_form.pdf b/test/forms/complete_form.pdf new file mode 100644 index 000000000..2797bd1a6 Binary files /dev/null and b/test/forms/complete_form.pdf differ diff --git a/test/forms/form_multiple_fields.pdf b/test/forms/form_multiple_fields.pdf new file mode 100644 index 000000000..5d61ac8c1 Binary files /dev/null and b/test/forms/form_multiple_fields.pdf differ diff --git a/test/forms/list_box_basic.pdf b/test/forms/list_box_basic.pdf new file mode 100644 index 000000000..91295288c Binary files /dev/null and b/test/forms/list_box_basic.pdf differ diff --git a/test/forms/list_box_multi_select.pdf b/test/forms/list_box_multi_select.pdf new file mode 100644 index 000000000..a23a409f7 Binary files /dev/null and b/test/forms/list_box_multi_select.pdf differ diff --git a/test/forms/multiple_fields.pdf b/test/forms/multiple_fields.pdf new file mode 100644 index 000000000..ae2c99214 Binary files /dev/null and b/test/forms/multiple_fields.pdf differ diff --git a/test/forms/push_button_basic.pdf b/test/forms/push_button_basic.pdf new file mode 100644 index 000000000..aa78468da Binary files /dev/null and b/test/forms/push_button_basic.pdf differ diff --git a/test/forms/push_button_styled.pdf b/test/forms/push_button_styled.pdf new file mode 100644 index 000000000..35dbbdd4d Binary files /dev/null and b/test/forms/push_button_styled.pdf differ diff --git a/test/forms/radio_button_group.pdf b/test/forms/radio_button_group.pdf new file mode 100644 index 000000000..afed66e34 Binary files /dev/null and b/test/forms/radio_button_group.pdf differ diff --git a/test/forms/radio_button_selected.pdf b/test/forms/radio_button_selected.pdf new file mode 100644 index 000000000..0108f50d0 Binary files /dev/null and b/test/forms/radio_button_selected.pdf differ diff --git a/test/forms/radio_button_unselected.pdf b/test/forms/radio_button_unselected.pdf new file mode 100644 index 000000000..0820e02b3 Binary files /dev/null and b/test/forms/radio_button_unselected.pdf differ diff --git a/test/forms/test_forms.py b/test/forms/test_forms.py new file mode 100644 index 000000000..e05623089 --- /dev/null +++ b/test/forms/test_forms.py @@ -0,0 +1,371 @@ +""" +Tests for interactive PDF form fields (AcroForms). +""" + +from pathlib import Path + +from fpdf import FPDF + +from test.conftest import assert_pdf_equal + + +HERE = Path(__file__).resolve().parent + + +def test_text_field_basic(tmp_path): + """Test basic text field creation.""" + pdf = FPDF() + pdf.add_page() + pdf.text_field( + name="test_field", + x=10, y=10, + w=60, h=8, + value="initial", + border_color=(0, 0, 0), + background_color=(1, 1, 1), + ) + assert_pdf_equal(pdf, HERE / "text_field_basic.pdf", tmp_path) + + +def test_text_field_multiline(tmp_path): + """Test multiline text field creation.""" + pdf = FPDF() + pdf.add_page() + pdf.text_field( + name="multiline_field", + x=10, y=10, + w=100, h=30, + value="line1", + multiline=True, + border_color=(0, 0, 0), + background_color=(1, 1, 1), + ) + assert_pdf_equal(pdf, HERE / "text_field_multiline.pdf", tmp_path) + + +def test_text_field_readonly(tmp_path): + """Test read-only text field.""" + pdf = FPDF() + pdf.add_page() + pdf.text_field( + name="readonly_field", + x=10, y=10, + w=60, h=8, + value="Cannot edit", + read_only=True, + ) + assert_pdf_equal(pdf, HERE / "text_field_readonly.pdf", tmp_path) + + +def test_checkbox_unchecked(tmp_path): + """Test unchecked checkbox creation.""" + pdf = FPDF() + pdf.add_page() + pdf.checkbox( + name="unchecked_box", + x=10, y=10, + size=10, + checked=False, + ) + assert_pdf_equal(pdf, HERE / "checkbox_unchecked.pdf", tmp_path) + + +def test_checkbox_checked(tmp_path): + """Test pre-checked checkbox creation.""" + pdf = FPDF() + pdf.add_page() + pdf.checkbox( + name="checked_box", + x=10, y=10, + size=10, + checked=True, + ) + assert_pdf_equal(pdf, HERE / "checkbox_checked.pdf", tmp_path) + + +def test_checkbox_readonly(tmp_path): + """Test read-only checkbox.""" + pdf = FPDF() + pdf.add_page() + pdf.checkbox( + name="readonly_box", + x=10, y=10, + size=10, + checked=True, + read_only=True, + ) + assert_pdf_equal(pdf, HERE / "checkbox_readonly.pdf", tmp_path) + + +def test_form_with_multiple_fields(tmp_path): + """Test form with multiple fields of different types.""" + pdf = FPDF() + pdf.add_page() + pdf.set_font("Helvetica", "", 12) + + # Add text fields + pdf.text(10, 20, "First Name:") + pdf.text_field( + name="first_name", + x=50, y=15, + w=60, h=8, + value="", + border_color=(0, 0, 0), + background_color=(1, 1, 1), + ) + + pdf.text(10, 35, "Last Name:") + pdf.text_field( + name="last_name", + x=50, y=30, + w=60, h=8, + value="", + border_color=(0, 0, 0), + background_color=(1, 1, 1), + ) + + # Add checkboxes + pdf.checkbox( + name="subscribe", + x=10, y=50, + size=5, + checked=False, + ) + pdf.text(18, 53, "Subscribe to newsletter") + + pdf.checkbox( + name="agree_terms", + x=10, y=62, + size=5, + checked=True, + ) + pdf.text(18, 65, "I agree to the terms") + + assert_pdf_equal(pdf, HERE / "form_multiple_fields.pdf", tmp_path) + + +def test_radio_button_unselected(tmp_path): + """Test unselected radio button creation.""" + pdf = FPDF() + pdf.add_page() + pdf.radio_button( + name="choice_group", + x=10, y=10, + size=10, + selected=False, + export_value="Option1", + ) + assert_pdf_equal(pdf, HERE / "radio_button_unselected.pdf", tmp_path) + + +def test_radio_button_selected(tmp_path): + """Test pre-selected radio button creation.""" + pdf = FPDF() + pdf.add_page() + pdf.radio_button( + name="choice_group", + x=10, y=10, + size=10, + selected=True, + export_value="Option1", + ) + assert_pdf_equal(pdf, HERE / "radio_button_selected.pdf", tmp_path) + + +def test_radio_button_group(tmp_path): + """Test radio button group (multiple options with same name).""" + pdf = FPDF() + pdf.add_page() + pdf.set_font("Helvetica", "", 10) + + pdf.text(25, 15, "Option A") + pdf.radio_button( + name="radio_group", + x=10, y=10, + size=8, + selected=True, + export_value="OptionA", + ) + + pdf.text(25, 30, "Option B") + pdf.radio_button( + name="radio_group", + x=10, y=25, + size=8, + selected=False, + export_value="OptionB", + ) + + pdf.text(25, 45, "Option C") + pdf.radio_button( + name="radio_group", + x=10, y=40, + size=8, + selected=False, + export_value="OptionC", + ) + + assert_pdf_equal(pdf, HERE / "radio_button_group.pdf", tmp_path) + + +def test_push_button_basic(tmp_path): + """Test basic push button creation.""" + pdf = FPDF() + pdf.add_page() + pdf.push_button( + name="submit_btn", + x=10, y=10, + w=60, h=20, + label="Submit", + ) + assert_pdf_equal(pdf, HERE / "push_button_basic.pdf", tmp_path) + + +def test_push_button_styled(tmp_path): + """Test styled push button creation.""" + pdf = FPDF() + pdf.add_page() + pdf.push_button( + name="styled_btn", + x=10, y=10, + w=80, h=25, + label="Click Me", + font_size=14, + background_color=(0.2, 0.4, 0.8), + border_color=(0, 0, 0.5), + ) + assert_pdf_equal(pdf, HERE / "push_button_styled.pdf", tmp_path) + + +def test_combo_box_basic(tmp_path): + """Test basic combo box (dropdown) creation.""" + pdf = FPDF() + pdf.add_page() + pdf.combo_box( + name="country", + x=10, y=10, + w=80, h=10, + options=["United States", "Canada", "Mexico", "Other"], + value="United States", + ) + assert_pdf_equal(pdf, HERE / "combo_box_basic.pdf", tmp_path) + + +def test_combo_box_editable(tmp_path): + """Test editable combo box creation.""" + pdf = FPDF() + pdf.add_page() + pdf.combo_box( + name="custom_option", + x=10, y=10, + w=80, h=10, + options=["Red", "Green", "Blue"], + value="", + editable=True, + ) + assert_pdf_equal(pdf, HERE / "combo_box_editable.pdf", tmp_path) + + +def test_list_box_basic(tmp_path): + """Test basic list box creation.""" + pdf = FPDF() + pdf.add_page() + pdf.list_box( + name="fruits", + x=10, y=10, + w=80, h=50, + options=["Apple", "Banana", "Cherry", "Date", "Elderberry"], + value="Apple", + ) + assert_pdf_equal(pdf, HERE / "list_box_basic.pdf", tmp_path) + + +def test_list_box_multi_select(tmp_path): + """Test multi-select list box creation.""" + pdf = FPDF() + pdf.add_page() + pdf.list_box( + name="colors", + x=10, y=10, + w=80, h=60, + options=["Red", "Orange", "Yellow", "Green", "Blue", "Purple"], + value="Green", + multi_select=True, + ) + assert_pdf_equal(pdf, HERE / "list_box_multi_select.pdf", tmp_path) + + +def test_complete_form_with_all_field_types(tmp_path): + """Test form with all field types together.""" + pdf = FPDF() + pdf.add_page() + pdf.set_font("Helvetica", "", 10) + + # Text field + pdf.text(10, 20, "Name:") + pdf.text_field( + name="name", + x=40, y=15, + w=80, h=8, + value="", + border_color=(0, 0, 0), + ) + + # Checkbox + pdf.checkbox( + name="newsletter", + x=10, y=35, + size=5, + checked=False, + ) + pdf.text(18, 38, "Subscribe to newsletter") + + # Radio buttons + pdf.text(10, 55, "Preferred Contact:") + pdf.text(35, 55, "Email") + pdf.radio_button( + name="contact_method", + x=25, y=50, + size=6, + selected=True, + export_value="email", + ) + pdf.text(70, 55, "Phone") + pdf.radio_button( + name="contact_method", + x=60, y=50, + size=6, + selected=False, + export_value="phone", + ) + + # Combo box + pdf.text(10, 75, "Country:") + pdf.combo_box( + name="country", + x=40, y=70, + w=60, h=8, + options=["USA", "Canada", "UK", "Other"], + value="USA", + ) + + # List box + pdf.text(10, 95, "Interests:") + pdf.list_box( + name="interests", + x=40, y=90, + w=60, h=30, + options=["Sports", "Music", "Travel", "Technology", "Art"], + value="", + multi_select=True, + ) + + # Push button + pdf.push_button( + name="submit", + x=60, y=130, + w=50, h=15, + label="Submit", + ) + + assert_pdf_equal(pdf, HERE / "complete_form.pdf", tmp_path) diff --git a/test/forms/text_field_basic.pdf b/test/forms/text_field_basic.pdf new file mode 100644 index 000000000..e3e9bfe4f Binary files /dev/null and b/test/forms/text_field_basic.pdf differ diff --git a/test/forms/text_field_multiline.pdf b/test/forms/text_field_multiline.pdf new file mode 100644 index 000000000..8c6c171a2 Binary files /dev/null and b/test/forms/text_field_multiline.pdf differ diff --git a/test/forms/text_field_readonly.pdf b/test/forms/text_field_readonly.pdf new file mode 100644 index 000000000..879443038 Binary files /dev/null and b/test/forms/text_field_readonly.pdf differ diff --git a/test_acrobat_forms.pdf b/test_acrobat_forms.pdf new file mode 100644 index 000000000..e782791d0 Binary files /dev/null and b/test_acrobat_forms.pdf differ diff --git a/test_forms_debug.pdf b/test_forms_debug.pdf new file mode 100644 index 000000000..80981211f Binary files /dev/null and b/test_forms_debug.pdf differ diff --git a/test_forms_fixed.pdf b/test_forms_fixed.pdf new file mode 100644 index 000000000..91041a971 Binary files /dev/null and b/test_forms_fixed.pdf differ