From 2d4b88091bbc6818683e33e57ce72c361c57c0c0 Mon Sep 17 00:00:00 2001 From: Harsh Date: Sat, 27 Dec 2025 19:32:03 +0530 Subject: [PATCH 1/6] implement interactive PDF form fields (AcroForms) - Added support for TextField and Checkbox with appearance stream generation. - Introduced FieldFlag IntFlag enum for form field properties. - Added text_field() and checkbox() methods to FPDF class. - Implemented automatic AcroForm dictionary generation in PDF catalog. - Ensured cross-reader compatibility by providing pre-rendered appearance XObjects. --- fpdf/enums.py | 55 ++++++++ fpdf/forms.py | 292 +++++++++++++++++++++++++++++++++++++++ fpdf/fpdf.py | 123 +++++++++++++++++ fpdf/output.py | 67 ++++++++- test/forms/test_forms.py | 131 ++++++++++++++++++ 5 files changed, 664 insertions(+), 4 deletions(-) create mode 100644 fpdf/forms.py create mode 100644 test/forms/test_forms.py diff --git a/fpdf/enums.py b/fpdf/enums.py index 7cdae88ef..6bd7952ac 100644 --- a/fpdf/enums.py +++ b/fpdf/enums.py @@ -20,6 +20,61 @@ 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." + 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..ee2453841 --- /dev/null +++ b/fpdf/forms.py @@ -0,0 +1,292 @@ +""" +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, DEFAULT_ANNOT_FLAGS +from .enums import AnnotationFlag, FieldFlag +from .syntax import Name, PDFArray, PDFContentStream, PDFObject, PDFString + + +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 + self.d_a = 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) + resources = "<>>>" + + self._appearance_normal = PDFFormXObject(content, width, height, resources) + 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 + self.d_a = 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) + off_xobj = PDFFormXObject(off_commands, size, size) + + yes_commands = self._generate_box_appearance(size, show_check=True, font_size=font_size) + yes_xobj = PDFFormXObject(yes_commands, size, size) + + 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: + if font_size is None: + font_size = size * 0.8 + commands.append("BT") + commands.append(f"/ZaDb {font_size:.2f} Tf") + commands.append(f"{self._check_color_gray:.2f} g") + x_offset = (size - font_size) / 2 + y_offset = (size - font_size) / 2 + 1 + commands.append(f"{x_offset:.2f} {y_offset:.2f} Td") + commands.append(f"({self.CHECK_CHAR}) Tj") + commands.append("ET") + + 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 diff --git a/fpdf/fpdf.py b/fpdf/fpdf.py index bd0fbca8e..f2e6248d2 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, TextField from .graphics_state import GraphicsStateMixin from .html import HTML2FPDF from .image_datastructures import ( @@ -3130,6 +3132,127 @@ 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 @support_deprecated_txt_arg def text(self, x, y, text=""): diff --git a/fpdf/output.py b/fpdf/output.py index 0f26ffbaa..18b0b7e33 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 @@ -224,9 +225,21 @@ def __init__( 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 +1060,16 @@ 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 + 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) + self._add_pdf_obj(annot_obj) if isinstance(annot_obj.v, Signature): assert ( @@ -1785,10 +1808,46 @@ 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: + # /DR dictionary with Helvetica and ZapfDingbats fonts + # These are standard PDF fonts that don't require embedding + default_resources = ( + "<> " + "/ZaDb <>" + ">>>>" + ) + 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, + need_appearances=True, + default_appearance=default_appearance, + default_resources=default_resources, ) if fpdf.zoom_mode in ZOOM_CONFIGS: zoom_config = [ diff --git a/test/forms/test_forms.py b/test/forms/test_forms.py new file mode 100644 index 000000000..53bf83788 --- /dev/null +++ b/test/forms/test_forms.py @@ -0,0 +1,131 @@ +""" +Test script for interactive PDF form fields. +Creates a sample PDF with text fields and checkboxes to verify cross-reader compatibility. +""" + +import sys +sys.path.insert(0, r'g:\Stuff\Study\Open Source\fpdf2') + +from fpdf import FPDF + + +def create_test_form(): + """Create a test PDF form with various form fields.""" + pdf = FPDF() + pdf.add_page() + + # Title + pdf.set_font("Helvetica", "B", 16) + pdf.cell(0, 10, "Interactive PDF Form Test", ln=True, align="C") + pdf.ln(10) + + # Instructions + pdf.set_font("Helvetica", "", 10) + pdf.multi_cell(0, 5, + "This form demonstrates fpdf2's interactive form field support. " + "You should be able to fill in the text fields and check the checkboxes " + "in any PDF reader that supports AcroForms (Adobe Acrobat, Sumatra, browsers, etc.)." + ) + pdf.ln(10) + + # Form fields + pdf.set_font("Helvetica", "", 12) + + # Text field - First Name + pdf.text(10, 60, "First Name:") + pdf.text_field( + name="first_name", + x=50, y=55, + w=60, h=8, + value="", + border_color=(0, 0, 0), + background_color=(1, 1, 1), + ) + + # Text field - Last Name + pdf.text(10, 75, "Last Name:") + pdf.text_field( + name="last_name", + x=50, y=70, + w=60, h=8, + value="", + border_color=(0, 0, 0), + background_color=(1, 1, 1), + ) + + # Text field - Email + pdf.text(10, 90, "Email:") + pdf.text_field( + name="email", + x=50, y=85, + w=100, h=8, + value="", + border_color=(0, 0, 0), + background_color=(0.95, 0.95, 1), # Light blue background + ) + + # Text field - Comments (multiline) + pdf.text(10, 110, "Comments:") + pdf.text_field( + name="comments", + x=50, y=105, + w=140, h=30, + value="", + multiline=True, + border_color=(0, 0, 0), + background_color=(1, 1, 1), + ) + + # Checkbox - Subscribe + pdf.checkbox( + name="subscribe", + x=10, y=150, + size=5, + checked=False, + ) + pdf.text(18, 153, "Subscribe to newsletter") + + # Checkbox - Terms (pre-checked) + pdf.checkbox( + name="agree_terms", + x=10, y=162, + size=5, + checked=True, + ) + pdf.text(18, 165, "I agree to the terms and conditions") + + # Checkbox - Read-only (to test that flag) + pdf.checkbox( + name="readonly_check", + x=10, y=174, + size=5, + checked=True, + read_only=True, + ) + pdf.text(18, 177, "This checkbox is read-only (cannot be changed)") + + # Read-only text field + pdf.text(10, 195, "Read-only:") + pdf.text_field( + name="readonly_field", + x=50, y=190, + w=60, h=8, + value="Cannot edit", + read_only=True, + border_color=(0.5, 0.5, 0.5), + background_color=(0.9, 0.9, 0.9), + ) + + # Save the PDF + output_path = r"g:\Stuff\Study\Open Source\fpdf2\test_form_output.pdf" + pdf.output(output_path) + print(f"PDF form created: {output_path}") + print("\nPlease test this PDF in:") + print(" - Adobe Acrobat Reader") + print(" - Sumatra PDF") + print(" - Chrome/Firefox PDF viewer") + print(" - Mobile PDF viewers") + + +if __name__ == "__main__": + create_test_form() From 1fa4963b1bba5bd4e5d90d822c24b573950f2e4f Mon Sep 17 00:00:00 2001 From: Harsh Date: Sat, 27 Dec 2025 19:40:26 +0530 Subject: [PATCH 2/6] docs: add documentation and changelog for interactive forms - Created docs/Forms.md with comprehensive usage examples and API details. - Updated docs/indexdocs: add documentation and changelog for interactive forms - Created docs/Forms.md with comprehensive usage examples and API details. - Updated docs/index --- CHANGELOG.md | 1 + docs/Forms.md | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++ docs/index.md | 1 + 3 files changed, 89 insertions(+) create mode 100644 docs/Forms.md 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/docs/Forms.md b/docs/Forms.md new file mode 100644 index 000000000..542a7b319 --- /dev/null +++ b/docs/Forms.md @@ -0,0 +1,87 @@ +# 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. + +## Basic Usage + +To add form fields, use the `text_field()` and `checkbox()` methods. + +```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") + +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. | + +## Field Properties + +Both 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/) From 3bdb7d6ff6b43c60f210e46bbb52d1e2054b1438 Mon Sep 17 00:00:00 2001 From: Harsh Date: Sun, 28 Dec 2025 18:36:43 +0530 Subject: [PATCH 3/6] cleanup AcroForms implementation and improve test suite - Remove unused imports (PDFObject, AnnotationFlag, DEFAULT_ANNOT_FLAGS) in fpdf/forms.py. - Remove hardcoded font object reference "2 0 R" in TextField appearance streams to rely on global AcroForm resources. - Document bit-flag sharing between RICH_TEXT and RADIOS_IN_UNISON in FieldFlag per PDF specification. - Completely refactor test/forms/test_forms.py to follow pytest conventions: - Use tmprefactor: cleanup AcroForms implementation and improve test suite --- fpdf/enums.py | 2 + fpdf/forms.py | 9 +- test/forms/test_forms.py | 213 ++++++++++++++++++++++----------------- 3 files changed, 126 insertions(+), 98 deletions(-) diff --git a/fpdf/enums.py b/fpdf/enums.py index 6bd7952ac..a6a4ae4aa 100644 --- a/fpdf/enums.py +++ b/fpdf/enums.py @@ -59,6 +59,8 @@ class FieldFlag(IntFlag): "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." diff --git a/fpdf/forms.py b/fpdf/forms.py index ee2453841..e00e5048e 100644 --- a/fpdf/forms.py +++ b/fpdf/forms.py @@ -6,9 +6,9 @@ in non-backward-compatible ways. """ -from .annotations import PDFAnnotation, DEFAULT_ANNOT_FLAGS -from .enums import AnnotationFlag, FieldFlag -from .syntax import Name, PDFArray, PDFContentStream, PDFObject, PDFString +from .annotations import PDFAnnotation +from .enums import FieldFlag +from .syntax import Name, PDFArray, PDFContentStream, PDFString class PDFFormXObject(PDFContentStream): @@ -178,9 +178,8 @@ def _generate_appearance(self, font_name: str = "Helv", font_size: float = None) commands.append("EMC") content = "\n".join(commands) - resources = "<>>>" - self._appearance_normal = PDFFormXObject(content, width, height, resources) + self._appearance_normal = PDFFormXObject(content, width, height) return self._appearance_normal diff --git a/test/forms/test_forms.py b/test/forms/test_forms.py index 53bf83788..344302ff5 100644 --- a/test/forms/test_forms.py +++ b/test/forms/test_forms.py @@ -1,131 +1,158 @@ """ -Test script for interactive PDF form fields. -Creates a sample PDF with text fields and checkboxes to verify cross-reader compatibility. +Tests for interactive PDF form fields (AcroForms). """ -import sys -sys.path.insert(0, r'g:\Stuff\Study\Open Source\fpdf2') +from pathlib import Path from fpdf import FPDF -def create_test_form(): - """Create a test PDF form with various form fields.""" +HERE = Path(__file__).resolve().parent + + +def test_text_field_basic(tmp_path): + """Test basic text field creation.""" pdf = FPDF() pdf.add_page() - - # Title - pdf.set_font("Helvetica", "B", 16) - pdf.cell(0, 10, "Interactive PDF Form Test", ln=True, align="C") - pdf.ln(10) - - # Instructions - pdf.set_font("Helvetica", "", 10) - pdf.multi_cell(0, 5, - "This form demonstrates fpdf2's interactive form field support. " - "You should be able to fill in the text fields and check the checkboxes " - "in any PDF reader that supports AcroForms (Adobe Acrobat, Sumatra, browsers, etc.)." - ) - pdf.ln(10) - - # Form fields - pdf.set_font("Helvetica", "", 12) - - # Text field - First Name - pdf.text(10, 60, "First Name:") pdf.text_field( - name="first_name", - x=50, y=55, + name="test_field", + x=10, y=10, w=60, h=8, - value="", + value="initial", border_color=(0, 0, 0), background_color=(1, 1, 1), ) - - # Text field - Last Name - pdf.text(10, 75, "Last Name:") + output_path = tmp_path / "text_field_basic.pdf" + pdf.output(output_path) + assert output_path.exists() + assert output_path.stat().st_size > 0 + + +def test_text_field_multiline(tmp_path): + """Test multiline text field creation.""" + pdf = FPDF() + pdf.add_page() pdf.text_field( - name="last_name", - x=50, y=70, - w=60, h=8, - value="", + 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), ) - - # Text field - Email - pdf.text(10, 90, "Email:") + output_path = tmp_path / "text_field_multiline.pdf" + pdf.output(output_path) + assert output_path.exists() + + +def test_text_field_readonly(tmp_path): + """Test read-only text field.""" + pdf = FPDF() + pdf.add_page() pdf.text_field( - name="email", - x=50, y=85, - w=100, h=8, + name="readonly_field", + x=10, y=10, + w=60, h=8, + value="Cannot edit", + read_only=True, + ) + output_path = tmp_path / "text_field_readonly.pdf" + pdf.output(output_path) + assert output_path.exists() + + +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, + ) + output_path = tmp_path / "checkbox_unchecked.pdf" + pdf.output(output_path) + assert output_path.exists() + + +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, + ) + output_path = tmp_path / "checkbox_checked.pdf" + pdf.output(output_path) + assert output_path.exists() + + +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, + ) + output_path = tmp_path / "checkbox_readonly.pdf" + pdf.output(output_path) + assert output_path.exists() + + +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=(0.95, 0.95, 1), # Light blue background + background_color=(1, 1, 1), ) - - # Text field - Comments (multiline) - pdf.text(10, 110, "Comments:") + + pdf.text(10, 35, "Last Name:") pdf.text_field( - name="comments", - x=50, y=105, - w=140, h=30, + name="last_name", + x=50, y=30, + w=60, h=8, value="", - multiline=True, border_color=(0, 0, 0), background_color=(1, 1, 1), ) - - # Checkbox - Subscribe + + # Add checkboxes pdf.checkbox( name="subscribe", - x=10, y=150, + x=10, y=50, size=5, checked=False, ) - pdf.text(18, 153, "Subscribe to newsletter") - - # Checkbox - Terms (pre-checked) + pdf.text(18, 53, "Subscribe to newsletter") + pdf.checkbox( name="agree_terms", - x=10, y=162, - size=5, - checked=True, - ) - pdf.text(18, 165, "I agree to the terms and conditions") - - # Checkbox - Read-only (to test that flag) - pdf.checkbox( - name="readonly_check", - x=10, y=174, + x=10, y=62, size=5, checked=True, - read_only=True, - ) - pdf.text(18, 177, "This checkbox is read-only (cannot be changed)") - - # Read-only text field - pdf.text(10, 195, "Read-only:") - pdf.text_field( - name="readonly_field", - x=50, y=190, - w=60, h=8, - value="Cannot edit", - read_only=True, - border_color=(0.5, 0.5, 0.5), - background_color=(0.9, 0.9, 0.9), ) - - # Save the PDF - output_path = r"g:\Stuff\Study\Open Source\fpdf2\test_form_output.pdf" - pdf.output(output_path) - print(f"PDF form created: {output_path}") - print("\nPlease test this PDF in:") - print(" - Adobe Acrobat Reader") - print(" - Sumatra PDF") - print(" - Chrome/Firefox PDF viewer") - print(" - Mobile PDF viewers") + pdf.text(18, 65, "I agree to the terms") - -if __name__ == "__main__": - create_test_form() + output_path = tmp_path / "form_multiple_fields.pdf" + pdf.output(output_path) + assert output_path.exists() + assert output_path.stat().st_size > 0 From 7c704ff41c5af0995e60c0211cd7b1792110c781 Mon Sep 17 00:00:00 2001 From: Harsh Date: Sun, 28 Dec 2025 18:52:50 +0530 Subject: [PATCH 4/6] address review comments on AcroForms implementation - Add documentation for d_a (default appearance) property in TextField and Checkbox classes explaining the PDF content stream fragment format. - Add clarifying comments for default_appearance in output.py explaining that parentheses are required per PDF spec 12.7.3.3. - Document why hasattr checks are used for appearance XObjects in _add_annotations_as_objects (different field types have different attrs). - Update test_forms.py to use assert_pdf_equal for proper regression testing instead of simple file existence checks. - Add 7 reference PDFs for form field tests: - text_field_basic.pdf - text_field_multiline.pdf - text_field_readonly.pdf - checkbox_unchecked.pdf - checkbox_checked.pdf - checkbox_readonly.pdf - form_multiple_fields.pdf --- fpdf/forms.py | 4 ++++ fpdf/output.py | 8 ++++++- test/forms/checkbox_checked.pdf | Bin 0 -> 1866 bytes test/forms/checkbox_readonly.pdf | Bin 0 -> 1873 bytes test/forms/checkbox_unchecked.pdf | Bin 0 -> 1868 bytes test/forms/form_multiple_fields.pdf | Bin 0 -> 3827 bytes test/forms/test_forms.py | 32 ++++++++-------------------- test/forms/text_field_basic.pdf | Bin 0 -> 1642 bytes test/forms/text_field_multiline.pdf | Bin 0 -> 1651 bytes test/forms/text_field_readonly.pdf | Bin 0 -> 1621 bytes 10 files changed, 20 insertions(+), 24 deletions(-) create mode 100644 test/forms/checkbox_checked.pdf create mode 100644 test/forms/checkbox_readonly.pdf create mode 100644 test/forms/checkbox_unchecked.pdf create mode 100644 test/forms/form_multiple_fields.pdf create mode 100644 test/forms/text_field_basic.pdf create mode 100644 test/forms/text_field_multiline.pdf create mode 100644 test/forms/text_field_readonly.pdf diff --git a/fpdf/forms.py b/fpdf/forms.py index e00e5048e..8e910df21 100644 --- a/fpdf/forms.py +++ b/fpdf/forms.py @@ -131,6 +131,8 @@ def __init__( 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) self.d_a = f"/Helv {font_size:.2f} Tf {font_color_gray:.2f} g" def _generate_appearance(self, font_name: str = "Helv", font_size: float = None): @@ -230,6 +232,8 @@ def __init__( 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. self.d_a = f"/ZaDb {size * 0.8:.2f} Tf {check_color_gray:.2f} g" self.a_s = Name("Yes") if checked else Name("Off") diff --git a/fpdf/output.py b/fpdf/output.py index 18b0b7e33..a59cebb43 100644 --- a/fpdf/output.py +++ b/fpdf/output.py @@ -1062,7 +1062,10 @@ def _add_annotations_as_objects(self): 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 + # 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. 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: @@ -1840,6 +1843,9 @@ def _finalize_catalog( "/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( diff --git a/test/forms/checkbox_checked.pdf b/test/forms/checkbox_checked.pdf new file mode 100644 index 0000000000000000000000000000000000000000..f27800e099296304d41da8435c2936a67aa03417 GIT binary patch literal 1866 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 wXBX{)Qz}_y-6| 0 + assert_pdf_equal(pdf, HERE / "text_field_basic.pdf", tmp_path) def test_text_field_multiline(tmp_path): @@ -41,9 +40,7 @@ def test_text_field_multiline(tmp_path): border_color=(0, 0, 0), background_color=(1, 1, 1), ) - output_path = tmp_path / "text_field_multiline.pdf" - pdf.output(output_path) - assert output_path.exists() + assert_pdf_equal(pdf, HERE / "text_field_multiline.pdf", tmp_path) def test_text_field_readonly(tmp_path): @@ -57,9 +54,7 @@ def test_text_field_readonly(tmp_path): value="Cannot edit", read_only=True, ) - output_path = tmp_path / "text_field_readonly.pdf" - pdf.output(output_path) - assert output_path.exists() + assert_pdf_equal(pdf, HERE / "text_field_readonly.pdf", tmp_path) def test_checkbox_unchecked(tmp_path): @@ -72,9 +67,7 @@ def test_checkbox_unchecked(tmp_path): size=10, checked=False, ) - output_path = tmp_path / "checkbox_unchecked.pdf" - pdf.output(output_path) - assert output_path.exists() + assert_pdf_equal(pdf, HERE / "checkbox_unchecked.pdf", tmp_path) def test_checkbox_checked(tmp_path): @@ -87,9 +80,7 @@ def test_checkbox_checked(tmp_path): size=10, checked=True, ) - output_path = tmp_path / "checkbox_checked.pdf" - pdf.output(output_path) - assert output_path.exists() + assert_pdf_equal(pdf, HERE / "checkbox_checked.pdf", tmp_path) def test_checkbox_readonly(tmp_path): @@ -103,9 +94,7 @@ def test_checkbox_readonly(tmp_path): checked=True, read_only=True, ) - output_path = tmp_path / "checkbox_readonly.pdf" - pdf.output(output_path) - assert output_path.exists() + assert_pdf_equal(pdf, HERE / "checkbox_readonly.pdf", tmp_path) def test_form_with_multiple_fields(tmp_path): @@ -152,7 +141,4 @@ def test_form_with_multiple_fields(tmp_path): ) pdf.text(18, 65, "I agree to the terms") - output_path = tmp_path / "form_multiple_fields.pdf" - pdf.output(output_path) - assert output_path.exists() - assert output_path.stat().st_size > 0 + assert_pdf_equal(pdf, HERE / "form_multiple_fields.pdf", tmp_path) diff --git a/test/forms/text_field_basic.pdf b/test/forms/text_field_basic.pdf new file mode 100644 index 0000000000000000000000000000000000000000..64711d65b5ccf956694f361648f521d29ba91e5d GIT binary patch literal 1642 zcma)6%Wm676fLrx?)n9HV?bV%!{JM0Aqe0@a*QUiN)dHo!zd_fq|%^g6mpcx{g(bq zf1%&dO;L1{?6b%nQdAgw(GDOEbneW3oO|w^Et2`S%y7urKmUICgD@~buI|ZXLTD`a zLIET6`*PjDt&OJij?mY9UFMNIz^#dh8@Pt$gYPiI573%D?l{mzzU2*>P6-!l?amsw z4U1aNW&Hq?2~9#6(U-h>hxTlPpY1u&B-N(5#NfEMR@^(sElQXBm1^T?kO7VIhIbZy znya$NfzCuB*QMA3y)H#4nz9F`Q=m6_veMrFpVymww@J{|Dp$?4T|nn0uQV+lW}V&v zeZ%=W-0gT?=c3>ZsCv(l0D86KA}mxX#gRgNRlNkUuPWqAGxaKelzRpAO7K`#`v=j% z4d`y9J*RQ5@=9(8tm=;R%?gE(>XeRdo={vM;&eVPYPsKiuDJV4mq0Vjx7p=27sVsc z7?Fg(D+``pL^}P2wx9|+FDY;2zSiZ9hpFiz26^d_eO~jjQoP2Rt6cGfV@o)puejK% zUw{SrQln~~Kaj)kcKZG=>rdOg9{v5|tFJ%4_(rSeXg%u49e9pT8f0VBFnzEr!}F0+ zEv~k&DBV7i?~u=`(Fb}~f4W-T^Fj?0q1geV*D-lZm|>bG48Qf3XweS?Le0sBU;=n_ z_)RZxn0`eA=8Qk)1#ONDbJSOsI1?74o<^D9p>Pd?L>cKs&n$F`W6?T?EnXNKwgcIk zj7m|eGOx}dyC<`Z{6J=y@H4iwH2Xe;3*G!TpczbcVvI|J%JVW6e2u+bXxV3!4c3~WthCjxp`i5hJ&uqhW(C=whugmq8E94zUiZ|-# zjUJ%$z7MT_2_1&QERSv1V(SZ8)UsG|j7st4!Yud!C+G436&M}&__$x*4K(QwT*N3i zJ&*}X5=O~)Ebszm*^C9)MDyl+aP3fYILG2F%zG>Cz#{l zU_8Ox0Gsp#^Ze&n;9~(_o@2}po?(_dxU1^Cta#l%=F9Roj%CnPN`=B}-<3d+X+i-e-=ey;znNLvBZgOD@L(9UdSTT3#R{lqUF3%d97ON7_v&v&SqD0@(F=W BxQGA% literal 0 HcmV?d00001 diff --git a/test/forms/text_field_multiline.pdf b/test/forms/text_field_multiline.pdf new file mode 100644 index 0000000000000000000000000000000000000000..7200ae34c2bf67e1a88db3927bce0e9fa8936b29 GIT binary patch literal 1651 zcma)6&2Hm15H9w}-ueV{3E&)Riu$po!Z2W2Q8wuA#+8MpNDvgVOsfiH(o0cWr_a)N z=_~XZdMS!tcJF)Gp=>$Y?4eZjw%S)CF{GnI*)*PvCykpOzV<6@TUQi>ym=Bj=PqNyt6%P{pSdz6g^dM)@|R?Su% z%r%&9Bps*oOlOrWdaRm`bj_LxA@vE4X`awrpv67JxKy&)Jym@8mo9;(h_~Klii`Xa z=o~E(e^=%_xeN{bh4G*Y4p9X~Oib|!Lm+&QBXqSzDTc>=e%!C_d*bwa zA?Bz&Gno-;69&SUE3>;jevj{&*6iS=V8SgIN2D|Yd#Ps8?*53 zduDT8oRDWWFTi#^vyJ>OZGn#*e0~n$Vo}d*_Na$NtE{ZJYG3tb`5VVFXdHk3awZg)lHdt{%x`Lg-BH zg#t$Cx8=HldmByZ1EH_^y38YafO``WH*gKh2j5|aAD}gR-f^Ide9Id$of0nA+MP9U z8y2-pWc>t_35`P-(U-h>hxTlPuk9t!IMt>^VsKnrE3Td67NyJmO0{t`$bd$9!#j)K z%~e_CK6>r!li-j*U1P1ys}DbTw-UTN?D&+A>j+r;QW$r6QbA?j(A`5g+kAV`#vPRYzdcc}H&Ic)L3*svYQ)?_rxbq@i@OIj@4Qx~p$MQ3!&+B;X^UDGOODYgUQ(D1Kj4sDKcU#7<2pO8*AD~v`2!a-%tOy!jOv3? z{0$4dfLS(U0k+n>yBr)l)Ev740nwK30nc@@>n=zRu2K6@y$ Date: Fri, 23 Jan 2026 18:57:30 +0530 Subject: [PATCH 5/6] fix: improve AcroForm rendering compatibility in Adobe Acrobat - Added `/ProcSet [/PDF /Text]` to form field resource dictionaries. Strict viewers like Adobe Acrobat require these to properly interpret content stream operators within Form XObjects. - Replaced font-based checkmarks (ZapfDingbats) with vector graphics in Checkboxes and Radio Buttons. This ensures symbols render correctly without relying on the viewer's font resolution logic. - Implemented clipping paths for ListBox fields to prevent text overflow and ensure visual consistency. - Standardized appearance stream Resource generation to ensure local resources are present even when /NeedAppearances is not used. - Performed minor refactoring to comply with pylint/black/isort guidelines. Fixes rendering issues where form fields appeared empty or displayed incorrect 'X' marks in Adobe Acrobat. --- debug_forms_output.pdf | Bin 0 -> 8216 bytes docs/Forms.md | 131 +++++- fpdf/forms.py | 553 ++++++++++++++++++++++++- fpdf/fpdf.py | 247 ++++++++++- fpdf/output.py | 66 ++- test/forms/checkbox_checked.pdf | Bin 1866 -> 1924 bytes test/forms/checkbox_readonly.pdf | Bin 1873 -> 1931 bytes test/forms/checkbox_unchecked.pdf | Bin 1868 -> 1926 bytes test/forms/combo_box_basic.pdf | Bin 0 -> 1848 bytes test/forms/combo_box_editable.pdf | Bin 0 -> 1738 bytes test/forms/complete_form.pdf | Bin 0 -> 8034 bytes test/forms/form_multiple_fields.pdf | Bin 3827 -> 4231 bytes test/forms/list_box_basic.pdf | Bin 0 -> 1920 bytes test/forms/list_box_multi_select.pdf | Bin 0 -> 1949 bytes test/forms/multiple_fields.pdf | Bin 0 -> 3207 bytes test/forms/push_button_basic.pdf | Bin 0 -> 1776 bytes test/forms/push_button_styled.pdf | Bin 0 -> 1778 bytes test/forms/radio_button_group.pdf | Bin 0 -> 5848 bytes test/forms/radio_button_selected.pdf | Bin 0 -> 2599 bytes test/forms/radio_button_unselected.pdf | Bin 0 -> 2587 bytes test/forms/test_forms.py | 227 ++++++++++ test/forms/text_field_basic.pdf | Bin 1642 -> 1755 bytes test/forms/text_field_multiline.pdf | Bin 1651 -> 1764 bytes test/forms/text_field_readonly.pdf | Bin 1621 -> 1734 bytes test_acrobat_forms.pdf | Bin 0 -> 10282 bytes test_forms_debug.pdf | Bin 0 -> 5352 bytes test_forms_fixed.pdf | Bin 0 -> 8409 bytes 27 files changed, 1196 insertions(+), 28 deletions(-) create mode 100644 debug_forms_output.pdf create mode 100644 test/forms/combo_box_basic.pdf create mode 100644 test/forms/combo_box_editable.pdf create mode 100644 test/forms/complete_form.pdf create mode 100644 test/forms/list_box_basic.pdf create mode 100644 test/forms/list_box_multi_select.pdf create mode 100644 test/forms/multiple_fields.pdf create mode 100644 test/forms/push_button_basic.pdf create mode 100644 test/forms/push_button_styled.pdf create mode 100644 test/forms/radio_button_group.pdf create mode 100644 test/forms/radio_button_selected.pdf create mode 100644 test/forms/radio_button_unselected.pdf create mode 100644 test_acrobat_forms.pdf create mode 100644 test_forms_debug.pdf create mode 100644 test_forms_fixed.pdf diff --git a/debug_forms_output.pdf b/debug_forms_output.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0f8d30cef83bdaaf41ec9c46dd3c17e049028a4a GIT binary patch literal 8216 zcmeHMUyLM088-nEYyv@pPefBd4(}4^?yCMzHXG*8&fUek+r8O|i?=yUZ%^&q96jB0 zJw3384@T4wo{TZ^!9R)7Akjo0BqSIV2pO%MFC& zj@)dvdZzx@SHJJq_5JGDR=e{s)otzATW|mKcN!%oN%t;mYipX(N~cLqsAha&*q@MH z3yQiIHRCMr55s19Kz2>YaRNtYKJjg;`vH{pu8uh|w!#6QXzS}5Px{y$Q@U+LS=vdn z5m{R^+6{8tIK|^9p}e;b$>0Pr+FdN_q%b%2=ong{QCc`pgeVrH=!uB5aer>&82*>+v=xQ&_C+p%vjLwk9XciY{ z-PHn-Qe0TX3CXi52TK{7W1cjke3&MMt#OR} z6iKFW42DIMo(Zp})0`Na32&wGbd<>K2@!pl#&3mr7^efp{J3@Ht~Qb+%~zi{!Xe3d z0!?kPEaPOBPRErEi@@b7E5I?!6?BGi&NHx19Ok^uVVAjPoZ-nJzeK1VV5>=<@o=Oa{9yX0-`@3% zdCzTs`{4^O|3u$;`mLA#@~H>De#QK$LAuTVzWwNPYp*``;Qi-5AN)JJ>$Uq%fBQ4P z`OaT||MR=a?Qh(3=FYFY`-@Z2>p!v|xN~&d9npV2@y!>%b@`QBC*S|oeFyzlUVr?Z z=imA2pD%p!&HI1+?(^B_{-xjg-uJ%t%=xWvyztA>{~r17r=R=7&U+6%``WK}f5!gk zp8xE{dmsGAEzdsm=-<&Niuo1Zfp94o*PMA4^DW(W3G;Q!f&E995oiU6EEnrrno$|> zBX%0Zy;iv%&Zpe0$2Rva^C(v=Z}bjG^K47IqEX#6O`@jRKttwG*GDq0?Q5o}s;1qO zz(`L)0hkE>na_ktJTM71*#=^PKQ9ouw#+3vB+;5ZO)>{%&?#_>=$s6o0X22%kzQXr z4rCFhCrIzIw$anhYa1}%+TNghmwVILLb#tJ!oATL%{1!+5(N=b*rSTh%3)#JF#O;I zlzovD1HxVs8BHo$A_!7Jja5uV=bsF#zfvhDSH#fC8kCTmN?$*rV zjXCdcrmwp$p`N3A4)JWJyZ#b?i*VM5IGh-x@-~XxR<3jfceC6De_MfVmpF=qJoR*w zl948V0kQZ%q#{+z<@ZSQksONxq%K(#eGiO;9YuoCiKr(MrA&N{G^LH~N$s@g9qZ4t4oY@ zoWm-``MhFZ%si5QP2I*f!#w@UR+4`q4XDH9!Kco|u0!>}E1B5!9X(ja#97!Mre7Xl z{82yui0Lbl166~c6`b=XACuzreSJIUVSKDciz{sTO+LOcw*1ECeU@~creP? zvJmT|&NWQL2npizroHmkd+k#R`9=tO1e22;O*N@g|AMwh%@}kS$Cha|-PP z^$%=U)}A#~sus--DO#$BxNv5-@l3;95yQ3OVe|w!%Z~`msmb>g^P)N)c1&Nsw*Yb4VToR-0<;h}%rhxD8@SZVO_aNp$J!A>^D4bfZPo3>3 zeN9={HEJXb&X#D3N+cairN{3^yCGRN^`?9cp^T*|c>D#dy%<%6g>u!pbie;ABol zaKI37{Iy(Pci>LMK@MF4(I_45rHdd%-L4$%gu<7g0amV7@nr#7sTnT8tnc5rm@UN6 zYZQegXsKoKc77v}q5 zrTP9WSZXcLH*0gYEjTJJw6z0p?1emsS-_t2V>m7#burI(Yi(VtJ}zuUZCuZ+kLy{q zHfPVqL(}|RJjaAHQ<;}%;V|ZzxOm?lKFtvaMmJ4!xZuJ&W#e?RpAsgb0Qk`+yK7F{ xX05i>c7l%UdZurBZMW0*y}-5`%?5MU-@k^y?IaJgT%gZ$T&5j6wz1jK{ttWE^yL5m literal 0 HcmV?d00001 diff --git a/docs/Forms.md b/docs/Forms.md index 542a7b319..12acc8088 100644 --- a/docs/Forms.md +++ b/docs/Forms.md @@ -5,10 +5,14 @@ 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 `text_field()` and `checkbox()` methods. +To add form fields, use the corresponding methods for each field type. ```python from fpdf import FPDF @@ -25,6 +29,21 @@ pdf.text_field(name="first_name", x=40, y=15, w=50, h=10, value="John") 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") ``` @@ -69,9 +88,117 @@ The `checkbox()` method creates a toggleable button. | `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 -Both field types support common 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. diff --git a/fpdf/forms.py b/fpdf/forms.py index 8e910df21..9473eb935 100644 --- a/fpdf/forms.py +++ b/fpdf/forms.py @@ -11,6 +11,18 @@ 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.""" @@ -133,7 +145,8 @@ def __init__( 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) - self.d_a = f"/Helv {font_size:.2f} Tf {font_color_gray:.2f} g" + # 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.""" @@ -181,7 +194,8 @@ def _generate_appearance(self, font_name: str = "Helv", font_size: float = None) content = "\n".join(commands) - self._appearance_normal = PDFFormXObject(content, width, height) + # 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 @@ -234,7 +248,8 @@ def __init__( 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. - self.d_a = f"/ZaDb {size * 0.8:.2f} Tf {check_color_gray:.2f} g" + # 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): @@ -244,10 +259,11 @@ def _generate_appearance(self, font_name: str = "ZaDb", font_size: float = None) font_size = size * 0.8 off_commands = self._generate_box_appearance(size, show_check=False) - off_xobj = PDFFormXObject(off_commands, size, size) + # 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) + yes_xobj = PDFFormXObject(yes_commands, size, size, resources=GRAPHICS_ONLY_RESOURCES) self._appearance_off = off_xobj self._appearance_yes = yes_xobj @@ -273,16 +289,24 @@ def _generate_box_appearance(self, size: float, show_check: bool, font_size: flo commands.append("S") if show_check: - if font_size is None: - font_size = size * 0.8 - commands.append("BT") - commands.append(f"/ZaDb {font_size:.2f} Tf") - commands.append(f"{self._check_color_gray:.2f} g") - x_offset = (size - font_size) / 2 - y_offset = (size - font_size) / 2 + 1 - commands.append(f"{x_offset:.2f} {y_offset:.2f} Td") - commands.append(f"({self.CHECK_CHAR}) Tj") - commands.append("ET") + # 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) @@ -293,3 +317,502 @@ def a_p(self): 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 f2e6248d2..9d0cdefc5 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -114,7 +114,7 @@ class Image: PDFAComplianceError, ) from .fonts import CORE_FONTS, CoreFont, FontFace, TextStyle, TitleStyle, TTFFont -from .forms import Checkbox, TextField +from .forms import Checkbox, ComboBox, ListBox, PushButton, RadioButton, TextField from .graphics_state import GraphicsStateMixin from .html import HTML2FPDF from .image_datastructures import ( @@ -3253,6 +3253,251 @@ def checkbox( 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 a59cebb43..a8097c407 100644 --- a/fpdf/output.py +++ b/fpdf/output.py @@ -224,6 +224,49 @@ 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: """Represents the AcroForm dictionary in the document catalog.""" @@ -1065,13 +1108,16 @@ def _add_annotations_as_objects(self): # 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. + # 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): @@ -1835,14 +1881,10 @@ def _finalize_catalog( default_resources = None default_appearance = None if all_form_fields: - # /DR dictionary with Helvetica and ZapfDingbats fonts - # These are standard PDF fonts that don't require embedding - default_resources = ( - "<> " - "/ZaDb <>" - ">>>>" - ) + # 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)" @@ -1851,7 +1893,11 @@ def _finalize_catalog( catalog_obj.acro_form = AcroForm( fields=PDFArray(acro_fields), sig_flags=sig_flags, - need_appearances=True, + # 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, ) diff --git a/test/forms/checkbox_checked.pdf b/test/forms/checkbox_checked.pdf index f27800e099296304d41da8435c2936a67aa03417..7cd3b8d55f4d88d8bbb0964c174aaff06beebfa5 100644 GIT binary patch delta 395 zcmX@b*TTQSopEz4V-KTjP-=00X;E@&v4V|_en3%va&T&iLbQH>i(9On-Q?ZO?UVDE zy%kL@x%3IDv^UT*Fi>#kGS)LOP$=gzRPX}gEG|<$Gjjz)Q$15lg+E zONAURBO^UC3k3+7!xcQakR^{%WAc3#QAW+pUs#+O>y3;Q3_w63Pk{@}FfcGRH^C4y zGs6_KG%!O~XJ}+-j3H)XVu>MUY6#Igc^jK~tgE@Dg`>HNi?f@vrJJ#vqlKxNiHVb& rxuKJ@nWM9zg`EvS6|r1)c3j0JiA5z9MX70AhNk9*T&k+B{%%|VIcr}( delta 351 zcmZqSKgGAfol#6d-!C;a#j&6uHL)l$FFCbXp`@rZb+bET598!dOc$6;j3<9!YS(uP z;nI&vbV*V$GSV}%PzXumGSD+HP)O%8(z7sDFa{AJDO?&RnhGIVT&^LLSF+@ZqH0ip zXxOaB>daVgVxnLG0t$HwTwsQQfvKe#hM1WJrkI7LIl4MSLsLvKV@pE}bta}3V6n*` z*vw-M3{9O}Oq>iI%`MGcfR1oAb~JS~F)(*=G;%XBHg&VJA*dpj%g&CgxFoTtq@pM_ Rjmyx~(3neA)z#mP3jl;)Q!fAj diff --git a/test/forms/checkbox_readonly.pdf b/test/forms/checkbox_readonly.pdf index 83ed980ee1cb15fb39ac419e18cdd9625319c19f..29a042c55d3f69ac1113df51ee8984f252546033 100644 GIT binary patch delta 395 zcmcb}*Ui7dopEz4V-KTjP-=00X;E@&v4V|_en3%va&T&iLbQH>i(9On-Q?ZO?UVDE zy%kL@x%3IDv^UT*Fi>#kGS)LOP$=gzRPX}gEG|<$Gjjz)Q$15lg+E zONAURBO^UC3k3+7!xcQakR^{%WAc3#QAW+pUs${t>y3;Q3_w63Pk{@}FfcGRH^C4y zGs6_KG%!O~XJ}+-j3H)XimA@j2%>KCUN-YsH$!I^Q&%TLLnliMM-vxUBUf`rGc!|H qOIKqfQ&UqnI~#&3V!7<>xQa^>i%KerQq#B$P0cO1R8?L5-M9csl3yqQ delta 351 zcmeC?zsR@2ol#6d-!C;a#j&6uHL)l$FFCbXp`@rZb+bET598!dOc$6;j3<9!YS(uP z;nI&vbV*V$GSV}%PzXumGSD+HP)O%8(z7sDFa{AJDO?&RnhGIVT&^LLSF+@ZqH0ip zXxOaB>cv=ZVxnLG0t$HwTwsQQfvKe#hM1WJrkI7LIl4MSLsLvKV@nGRbtYz}V6n;H z+00`tT%BA^%uS4qEe#9}+{|2zObkqo4Gf%(O$-f8+>9OVYzV4|<+8KmDlSPZDyb++ SP2(~&H8S8*Rdw}u;{pJRcv8{; diff --git a/test/forms/checkbox_unchecked.pdf b/test/forms/checkbox_unchecked.pdf index 933a586438dc900c23d2a90d1e29fba752be9ee7..46e35128621230e8f803be2f7d9eddc2777d910b 100644 GIT binary patch delta 395 zcmX@Z*T%oWopEz4V-KTjP-=00X;E@&v4V|_en3%va&T&iLbQH>i(9On-Q?ZO?UVDE zy%kL@x%3IDv^UT*Fi>#kGS)LOP$=gzRPX}gEG|<$Gjjz)Q$15lg+E zONAURBO^UC3k3+7!xcQakR^{%WAc3#QAW+pUszlj>y3;Q3_w63Pk{@}FfcGRH^C4y zGs6_KG%!O~XJ}+-j3H)XYKS3bYG?r#o4kX~Jl4&{$-vCS$=K1-(#YJz$i>pd5adIJeVxnLG0t$HwTwsQQfvKe#hM1WJrkI7LIl4MSLsLvKV@qQUbtYy8V6n-c z*vw;H&CH#QU5uTa9SzM*&0S0kU5$;+EiFyWoZVb44V^6PYzV4|<+8KmDlSPZDyb++ SP2(~&H8kZ?Rdw}u;{pJn_)~}g diff --git a/test/forms/combo_box_basic.pdf b/test/forms/combo_box_basic.pdf new file mode 100644 index 0000000000000000000000000000000000000000..78f51241d03c20a4e86f066e978499bc66f73569 GIT binary patch literal 1848 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 VxQ!DpN(B0B-BG2brM2~%^k1vu*(m@3 literal 0 HcmV?d00001 diff --git a/test/forms/form_multiple_fields.pdf b/test/forms/form_multiple_fields.pdf index f9544a550cb6205e4d1467309ee4acdeb1fc7f6e..5d61ac8c14ed0447229b02c66f65757a94566b81 100644 GIT binary patch delta 884 zcmchTO-lk%6oyf(>}IWmHc7{Ykel&-%*>q$6^zkBf+X9NOkgqwVX32i>?B$Q?c#3y z0WDev(k|$4^gmj)2pYA8c`03W;C}FO&v~EszDI7OPbFWk>09-OcAZxJ+^sk*NG8Q~ zDD1c4zH0~4UJ00a6nX$JuO+se+654*U<`Xz!2R<$E*57aq8qrIFyrly{)fn@f1=>l zD@CUbr9{EX*#m;OXrM#!F&`HBIU}lZl$fd`HDKICMBx}N5rMq7P7qZL23#>PoFYxp zIWR?2p@s-imUq_{Y`vqr! z1{aKVqDh)T&2fzoL)MtCOYb<9H4_hgJ0r%FsOoq#RW&SWx*-jurVPj^lY%gn|L|Dp zXJ#6ge!&Yu#QsvsW>zMHb2{NVHHe{E7T(BdCTAJbB!p%&>Ey&0HcF>at6g^6SFTe< Nj1!JxvFuI`eF1t})Tsaf delta 627 zcmbu4ze>YU6vjy_)!tg1s*A;|P_zim{gd3>fEHSlj&=zSmI~1(f!7`ZgJ-y$^W!_;ch7M1t$5$kRPV~Bm^kd&S#gtaK zfxQrs3L*F$(Tc$^OCGO-c8oIiE|BZNXt@B8ZGo0AWRdP1VZ9lKfb&JzMWb27-M{QF z6YI%Z{y`fE1Cyk17o)F+5Wy{lNt4bqN-U``W^#oYk|<1=v)sXtU@C<j)6 zGSgy;PIBcloLIkh*OQw$70M-i>Ig3@`)!*t`@pU{t}7d+bQ!HVb>c7~99Kvx?CRd1 ZiZ-fNQ9t&h_&N$g#5v`tRC1dQ^a*x-mNNhV diff --git a/test/forms/list_box_basic.pdf b/test/forms/list_box_basic.pdf new file mode 100644 index 0000000000000000000000000000000000000000..91295288c0b50c5812a128626325db28eff8c3cb GIT binary patch literal 1920 zcmcIl&5q+l5Jr1!ZahIyE?QX}8jiN|SoXn0`nBZ6pnrIG=)5&<`=??AAGQ+d* zF1!NIzy%>LaOXg^ogeQ62`<=*t$fv0UG>#h?TcAFeapH%a`D^mfBr-mIG~n~WH2Cf ztaj1>BlL@6Rl&kVQ*uw}mts|9BlQdm2N5syx;%iO$GSm?*7Rj7fX=dwsK{_ggk0Hh zycIUewVEpZ1cL#MBk0g~qWpmNbd8VACD1srrm4cs@m*W--2|>tI^Qi#y^jSM&{0;2 z#$s0sQ{)-YiOkiikQ<=4g^XlX9Dv~v=v@{sZS?;Z^)B14V+^&-Of{@8pwmK>mKGng zPVTW(db1TW%1xoCbM3wHha+yo$ zDSPbkOsjk@3@j*W9cd8H<{s1hAN6G=LQX2RSsr@(?alI0;{DY5MsDUBhTQ;VT?PG`@dD&zvI+gzgU z8`zhF8iO7_Qk<9vJL9OMx=?89f~E|xS;HwwjGtheVZnp0>w(|LlvL6vUn1^crLE}KLpKw}7`}cVNzP@kG zFSh>}VvNIS53jnCPW(9w{g82&u@Kei++DWZp0z*;KuD~mxxn{4EOZS=j3M*(0(r!k zgCBxdSdX7$ULOyZSL=NL3}c*!uh+4jkGy>B%RIN`YjjqWLf47V2Q!=_&SEgAey+&DDYV1bB|5K$aDPIaoi}3$CJQe{o%i_s5!4prcF&9^Ma6E JTug4J!4?QZ|XMp2~1p3II|NN^$sCYpoebg~|Kx`W->o#9z{ z7hZv9;DQhrxO1S|juSIk2`)H_oqW|@UG>#h<%?N7dCe@BT>SR?pFa@>HmKDD84L)G z)Lv>}gnp5)D_A;cO702$QmpfIte#+LBjSag#RCXjW(6Ty7tcEZbe3*KMTSEns`9r#OP|3>S|bDKqt8<3@tuJ zo!n!pbh;CAl<8c_16gK~egkq}6v&L>;Y9SGE8z0xD+V4)eHGkCoA77G zg7t2^B%)ILG7}Z_yP=sRI&qmJ+qfoqp+$)~7pWGpK=K8lZ-w0IcfdlksnVrLAIZ~? zPV&bu{Ab6z?fv@dlTZJ8^_kJh!Fo`P*YP|)s*Q8s>i2>5ZOaajQ=_B0gs9TPQI2C1 zdWs%vJS$Z;7aEonwT?81Cw-4${*U@H5+Ns*nkd`aemh+~h)j3r_?|G!wr%L%%Pld@ z^^bI+6l6m%aa`NIC)e1uAJBl5;g@r-rpOPGBC}_X-G?8}8`y7PxCM!O0^Ac|&GA4E zjB#UZm%4ZqI)DusFG$meW`XI{It-}yc(udDn)DK}zJ$dCM*f=Bdkr{6(QU;!{+%ca z_2C@9E`^Zi@VMB2#K~-5?urJ#T9B{LRb-m8)#qqtn&K-k%1TUq#;RPSVCyl)P)w&` z)TUYUj57&s-$1Vk4Mhho-X_59u+Kb>zZcj}@5}%|ks%AvBn?fDQ11hi0W<;QSqW+Y zmOQW=4}90LTpzgSS&T!#9LsZY$#KZt=IgD{C?M!%s!(Nx@icR*9tDJ+V#Bl!{bRbe z&HN(`0QBY&TX}qVMu+FkeW$rm8%GxrcCMLhG4?j};?G&=hm1Rng(ye+?y|G(sT8OW z2#K*Y7x<6Ghd=JIpQ>*LgUvCj9;FvgM77wecCo@1Vm)3x=d z%QP=US-=10`40lqpovl%JE$JeH*%xE5QD?|CWhr8a2?xY5#vGNv#=jWjy(yS$O-H* c3%$r4{`-oW^Gc_suBl@_3&_RA)pSDs22f}UKmY&$ literal 0 HcmV?d00001 diff --git a/test/forms/multiple_fields.pdf b/test/forms/multiple_fields.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ae2c99214d65c0459fe1ff6664d8ec42d69fc0ca GIT binary patch literal 3207 zcmeHJ&2HO95H51u-ueV{F<>9c%&8DN3~LC`o}H3RHrY&3!T6WiXbzVSs^TlgU#U@+M8y&=ZIpPSXUP_{Tw%3B)DG|O6!5cTqUNmK zW-1?%IoUAVNz0;NlruOy5;^XF>%7WKn1E|UY`y_i(}0uRTh(c-f*Y@~d!xYp(z z5(?l^GFO#`-c~wG6W~XAT8y%M0{lG7JNZ1T1?@KQi==l=)<4YZMRGUpp{eUc&)en$ zyq~ElrNu|DW7aaOp!CI87&kw?Q+X%VS&^?YM?3U$$QRQoril{tGPy4n8u)3hy2W%c z%Pa95jFmc--9#tTV$w(^6{4ysT&o<_uJMi^m&M|46Ls~M4}cHLB%j|U zrOMNL;9ZpT)Y~jo@o_}4oRJ2RP+5u9yjYZ}LO-djaqTFD)bNtdti9}KQ>{wOz%z|X?A7m4f!V;Hl%9z`!04e=Y=A9?Unz~$t)y@c zIg5s@>M0@79Cn>C4inrUY)b?%9I^d=R?hVS3~$-dkiBL{=)|Kk5b)~)cg_e_UjdaS zrU;{=9AU)`MF=d%rqSzTO*Gi=sQVgL2V&X4#bt=KSW>N0tc|HC1TEwl8>Hu12)mYW zEYC%|YbH3)MiZqmm#{yk&J<|4YHrgajBgG>|5iu}8XR_`1j4fAE-c9>uvDM20aMrrq5&r2qg|{Ndb^-x?vgdg zchkiihJj#9H`j8)=$5W}s9KR7s-E9XD27SohEHBUW&lB^>$-hJ$ifT%8!;2%!ZC9! z*G8@t@B)9g%(MIeglBmn%$N|C<3eEBAxs(4E#b>Ma`s@tx>4zO-J7H&)~h)b ziS%?l27*%z{9C$bDbLsoM64aWoKf};hBaeNbv}(# zEkA?+k>c6!Xo-}Zeu7@j37d8>!$A#>CoWpxCo`NFqSdpzdY)W0W(d(7b-Fkq&^*<{ z83OiuUx?5Tg>-}n5%l)OL1W*tR0wepE+bX-0pIhm6UVTY3B0Z71NmAeY#eE}WG?R7 zmiac8aqB(wW?Lrg)+2jD;*_$rSNNFpZCT658}{KD5)D3*xmdRC_k8z}JsIHLzJ5=a zNj6obnezwP4~lZdr1QnDO1S)jdChBaf7!y!L@q|c_wU&5ifX7)S z8x2`Yldm%17iy)~x!MB1$<;_zc`FzWf!}7ylDz*nueaHLlc1|*W~yQR0H5TtAXoy7 zI=#bG`So6^(aPjn9l;_a^9IzRC=eOKH9js&eb_(c*8Jo%;PWz5)jlhwT0H_Eqa=~<@|8?4V+wyt z8mxEYC6$#vlq*?5zZ)7Q(TR($Y~z~bg^?xZTx3Qj62X^@zm;lhegF~Cp~{pp+p&kA z-1LuMonKu4ruXsHXRrTy^*Qn6s68^q?|6<+Y!iLkvP19#Dq_m?GG;U-ntlUR>y#jib-6|g z)q_P~eV@iddoSif_nu?z!Nb=C0^fH7+|6+e80@DM;e~}0Qbc`BF7%Ooq2;*{3fJ;I zbbA^fH~D%i4b}#FRUP>EOQXyVuqp$+{Lqym1m!|sTW>-e}{-E~}q9T#J4 z1{xL#whQ!-j&*D zAYf>pz+w>laWaXkHFU!Be0FwraXn#w E18f1^4*&oF literal 0 HcmV?d00001 diff --git a/test/forms/push_button_styled.pdf b/test/forms/push_button_styled.pdf new file mode 100644 index 0000000000000000000000000000000000000000..35dbbdd4d660441404248aa5906fde07b4132b26 GIT binary patch literal 1778 zcmcIl&2Hm15H9w}-ueV{3E&)76!mARFbvq1l?1!ncx3@u#0Uymrd_2n>6Peh(r4+r z^cDIHy%fb>+ItTj>c>$x3$&NgFiFo0=jWRrpDn`ZB{Ljy_WQ>_edU&Be1(TvO@txK_0tO=Li)X~i3k zmgXwYQlN8@$xSYHKyPv}5mnw1#$%wjX}H$f|C`p^^so(4)jCzxxL!b`oEI7v5511> zFjRVd;9`=gT#6PfEu=0%9E$>x(U`nWALUU2y%u~Xi{oB2f$(k0NOOht7_OD?iUpfjX|{6n7c_+qNvUuX{I zyU`N!N*>FMS1|0mri1ADrA>BzMR}oki7^+c;vq-yIiat)*s1S;1^Q8?N}le?!%uen z$FJ5ewtF-9?Zp>g{`KN3T_-KKtr)kbd3vfg%kqrj5Qe7V4-rybMs)_UNLx#mWfS`3 zJf?V2%52FMtSFW`qQD>29eVjcmY1#&@>HbS%PzI=uGjZGQ#~}^6VtFvY$d~~cOUn1 zM|5xXqlJ=_Ew-or#20w^p%&S9LM9ZjY)NJVP(v{g$@YuyT9qc89@bd4kDL2vSkr_w zjmR7W{Y$p!G@d)+yM`Trjn3PUY{+0%dX+U(AF&b_89J3D&ZV`B{YkPQ0a1bc$s2Bl;mE{|@3q!{;JRdCA zHJAlHvklilwWk4clW%rhVRm3psmFqEzFjMUx|_LBw_2~cmcC)#sU-9?RoFnL&*_y2#Dr1tB%`+Eu3f!Vblx6 ziN;JEy`Rt=>p9IGVk>_l=XuX)jM*5#KjoNhJ+sT~pl3ssX}zKXMF%#!@QK ze+xMEuEbV?21egQSdAw5K8Re;G$TKpv2gCQa5{G*7c(73kvsl(i5l}trKPH&V~#T< KXJ_;4i2M!1Xx*a# literal 0 HcmV?d00001 diff --git a/test/forms/radio_button_group.pdf b/test/forms/radio_button_group.pdf new file mode 100644 index 0000000000000000000000000000000000000000..afed66e34875caeca308016c99bf0c858ee3c7e3 GIT binary patch literal 5848 zcmeHL&2QsG6o(s(v}ZtE=2EF;g~wxi92-@UlDO$s6q-$|E!(IDdGF1eH@`RYmRdpc zRc<(B>E~a6|B7&G()8entgaH)NXJP|IbpBI-4X5Bz-sRk_J-`nVLd&e9TODG&`oNt1PI|Wp+yr?g4+N2Tgy4vY?Hu zW(vyTt+?8E$4)&IL9EFsvB~jXqlRjm;aZ*c0@q|#VQnnWc z2Ppk=Quo4PF91;oVLqy=hq7iY2k4dxSluQZeF3k+NicD?HIzv$%HuRCU57*RTQnID z2H+?3&}Mj=j&sVk64^)xl73hU>$#2X;f-gX|M2CX?moErptJYOOCQ}l`*iv6j~71Z{`hdkJ9_1;`^gtv zeEX%jcISt)&%ZtozWMIOyYJhZH=cQ~LO;V;ER~c|iN;S1tWP7_vJBUws)cpJNL4`s z`=GSfb_HP)=R6xi;H(2scdAq&5R z#lzjW+m|^YB3;hHZk)c|$CLz$icGB18DW}Iu)c(APj|xtuAe@*_OBKI3%JH3#?eCv4K>sanPl3+EH*_;B=*eaD6qL289ep)x-t_sJsI3Lu_Z{Ceordk1rfWH(8C3tehl-0)9%i{(U06#f KWG}64HOZgZ=3TV_ literal 0 HcmV?d00001 diff --git a/test/forms/radio_button_selected.pdf b/test/forms/radio_button_selected.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0108f50d0abea07cff69dea23fec406d90ce6d06 GIT binary patch literal 2599 zcmeHJ&2Hm15H9w}-ueV{36LBriuzY!7_clm8+5mJm7pnN1cfZqjux`$mFR7<&(e44 zEA$z9DT-d&dk-CwvZHJYwCJe?sslyN3};5e;rHoeo=!hymPby0{^hst2?HC%@(mdb z2px-kErAjGdAVv~;i4+LCG<64m3boWVPT`hkA2IDAo7?M#i+e{*m0oqe8XEZ91>oy zlsl*Ac2qQCDw-_}1~eVPDSgSScc{Nwu9mrMhx!3JEqSHN z5@OcbEq*GU?RY&ZWGU*dSjtGggnD09SQ%B5SNVh3OQ5rwk43fL)<)cds(W^*-Z+yOS4u77>{(~FNj{{6)#s!uv?*D-$2b8_r9C$d~0^oSKzU8D~Xd$bGW z20o#Wx#J1X8&OpmULD)= zJus`#^5f3bOuL(57TVC5_q2M7Zgc>>=%V!en)|M9*q^kjqJRSmCJ6Rij_>jYDndtd z1;k&dVYXg_0<{d$f2T--fokzg(FqzN8h2ts6Va>?Nj1MR#5v89nz1lPn`x$0*s(c7 z-B-PhQ}7TKag3So;Oq*iQ6k4=h-|Ph;2B0p*vQ*~6~quR7i)(hj+l5|t~OjED#B@T zCy*V!*(g52NQNpk`w-fTis^Tlt(f9ahbOR$+{5pY_Aa(aA4&Hfckhea9-+iN7h_}# zRj3sC1Wwb>SRBO6aT$vdmhGFf-nRROBlZEaiBg#d0w2Ku)^L8k@XgOJ(>;Rl)mJfcc{Nw;jlgfn&nzGkr*7`MvHIj*huN+exXbpEo4B4 zMZ>K|TXR*F1<M-#kDk72oKx z#F%w{i(94B9T$UARZ_IY(njhfi2Y`RmC-eMRXoVO0y-6ZBscr5u;K=E-Sb2BMujRi za^0z)uKnRy20}=646KhyC@xSESRd!L-0vRuW|#C5=v7^aW>?f)ln+2hD9QM{s^t0k zP^UlB8a!|7CFhOY*ClTt>87SP(aB4PJmfX0Hj3A{=S`t_#bOsmZ1!E8?1zyJLQi zm-^d(b=nR2@_7S9|EvK*|NjO+T<)~Nf1&|w!g#w}CypL(mtDn9!ho5vtG9E!I<6B2 zU{|pdrmd-+wl~8rjG;B}Xw4Mem;h$cM(Oyq_g&wxKWSA>0S6QuBG_{^x+`kf5IUG? zApSxR)71(zsO5S9r-{O zc+X{FjP&+_T@*c&F&1M3JhfvfR1}+QSQ5L3NI0@aT|JN4gBpOxJ$`sV| MeEb(Y8BZtV52+w`CIA2c literal 0 HcmV?d00001 diff --git a/test/forms/test_forms.py b/test/forms/test_forms.py index 58fa89e4a..e05623089 100644 --- a/test/forms/test_forms.py +++ b/test/forms/test_forms.py @@ -142,3 +142,230 @@ def test_form_with_multiple_fields(tmp_path): 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 index 64711d65b5ccf956694f361648f521d29ba91e5d..e3e9bfe4f4c4f57a58762e8d4f102433d9391926 100644 GIT binary patch delta 300 zcmaFGbDMXAJLBe9#y&=#pw#00(xT+lVg(zU$s1Tim|_(s&WL5P1A@&jnUopDHS|4F zbIKG9jr0r*6hhJz3_x_c=4M3}Z^n8fBLxExP{>o@0y7K@OwCO&#LUbv#0(8AEz#8( z8XI7kXJ~AOq^_hWF*7H%h|9)?OFuZXYVvDVrC1|NS0^(gQzs`&3kz2lMSlMwKE};a%!-Vn`W~q{WeSEy zdIkmxA!!N*AUb{XBo=SRdJ_``0}xQiQ{VzK3=B*y%`n8wEHK3^EiBO085&wxVu%@; zBIzwDO3chjE#k7V;nEMztePCdrW9-FVr=2+SX3(X6Rz#Y;5FgXl`I)=4j|- lU}kA&Lr_I5mz^C~aYn7XZ`3Ml1jT diff --git a/test/forms/text_field_multiline.pdf b/test/forms/text_field_multiline.pdf index 7200ae34c2bf67e1a88db3927bce0e9fa8936b29..8c6c171a239ea30e08ca19d95f1e73c5cf7042c3 100644 GIT binary patch delta 290 zcmey&^MrSUJLBe9#y&=+pw#00(xT+lVg(x;{eYtU#G0n(G!2kpl@)Wqh3 qx){5e7`vF;*$`9_%VlTBRa}x-R8motn#N^lVrSlMwKE};a%nFR6`W~q{WeSEy zdIkmxA!!N*AUb{XM3zv-G!qjA0}xQiQ{VzK3=B*y%`n8wEHK3^EzHr?85&wzV2Bx+ znNId$vyL?~HnlW1wsbQ$cX6?>Ffuc6b8-Tb=9Z4eCdNjFZgw^VRm5`H*>M$@Bo>ua U6s4wd85$cIaH*=g`nz!f0QtZ_B>(^b diff --git a/test/forms/text_field_readonly.pdf b/test/forms/text_field_readonly.pdf index b6ef97a401b8a8361f404e71dabc2d1743318d1c..879443038678ca114ad5f489422360ffd077819e 100644 GIT binary patch delta 289 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 WFRjRFMqKORg9r;vOw26KqW=PcxhOmU literal 0 HcmV?d00001 diff --git a/test_forms_debug.pdf b/test_forms_debug.pdf new file mode 100644 index 0000000000000000000000000000000000000000..80981211f9abc5dfb2216c275c9bb7388d573f30 GIT binary patch literal 5352 zcmeHL&x;&I6ebrB4LKPP1_MvX!Y*oeSN|Sa7H6h+lGP-;&W^5T4M}fL&1|nd-D6Kr z+zp5zV(^fdOK$lC%teSG1R)6Vpa(%P9>jwv2u3A=pch5NSJgc;H8ZOYYsRDk(~n8);8J{lm)LzJJQUIM58)WrwM9{eiB#Wqy^K{id%$9 zILh71)TKuu>V_fuka_Qzw-I+!Q060A zj>B#%%ETSWhR6*sd#M-3O~KP_m3j+UjG{Q*)sRO(H>8{Dfp>nmv2^dk$B+GX>-WPK_Z@N< z4%={m96I>;)VV*_UU)x!V`Kf1vzOu1>q|3dKDvDAt+$`Ke&*rt=3f5lp>Y4zrPj4? zK6~Z+TmB9A_TQJT?I-Wuc7A-~uZ3rS`RRkk@oz8OO(Xx$o70z{{9IeRbM(!N=a=8T zdj86zcMg8oSo`|R>3w%ET6G5R+^8JrrZA&Yf0JmMCEGR-+$6S+3(Vlpk%yZrpQ285 zX@G0iptqM^nqOI?ekwTNHHpZos)AT1O^I#Cp^+e=QbSUCTd}Mj!F~QZR-m=`)ig7+ zPDSfjpGB!uld_FRH0+IAlWT#dG+V=82zLAuz%ex-thbIzv`h(WpHOIQt%LQkS)W>6 zn#Ao9(kZAhjvz$uiA!FDpIlxj^u*;=nj{;!*x>^+B`!iH7uw9lT=Q&Lr^!mcJzDwX(31^c7)D|W@04T9>A`Y0AY%vnw1fQC=6MU+20sSJDIX=aK zGI$aM5`i8h(YhAojZ_F>k)yhWVs2X|$#KiD%lc)u3mt`~dAGjKLDiNJx6suFXa-va z2Ci}#_^-n83}Bn1h_U}DikPE!jG_!V6|hWGw=xWsQlx$n874910{X1r$c7H4t;v?U z9WqwBX&Of(*cbq!AlN`8%UUub%oX{Z8-cgug;2+T`jXHm(@m9zq;^u_K52}4g;Hi_Qk}c><~M`co+@)gAUjaW9zQ=#sm7>i>$pl{fQ~j1NzJ2 z?f*E!v4h(XvGj729nhbcc$T)KKh6(*{=*c7-+~DI*J$j5`&g&@;XU2qxj=SkHAsFs z-v#gCfCA?YC8^_AC(_neCj#m6tCwEF!7g9K5yZ5rcac-U=+RHD7LwRsq?qj}c>jc# z3v?atI_6q<@lnd(<@`N&N>nVSFJ-(9U{Q%DQj_k}nyQ&Z(+R=SFi%bi{nitT;U0)1 zVcwiS{E%%z1Nt&%9(y3_%ZQ2rVnk+G2%iy|rK4pdGRGK`Vd9Gcb+{ddJq)TNGRMN3 zlwp~s8e@GmQy*)mtJav!x~`4cqB|^@507E!#+W{gw_|=WtO9O?k-oMgjGHE25YmKS zBrXQ$DD%BijpGzp#H~uoT-1m$)64(3usmHXSOr6OEOXW+s%=(C$t})ewo|g~qBHBd aGymR#W2KXNNy=B;aPZ=2Vq$i_BK-qKha=Pg literal 0 HcmV?d00001 diff --git a/test_forms_fixed.pdf b/test_forms_fixed.pdf new file mode 100644 index 0000000000000000000000000000000000000000..91041a9716d3c9b9a0b32332654f933aa8013109 GIT binary patch literal 8409 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 literal 0 HcmV?d00001 From 6a7cfac294d20660e4ffd18b570a4ae747ee4a49 Mon Sep 17 00:00:00 2001 From: Harsh Date: Fri, 23 Jan 2026 18:58:49 +0530 Subject: [PATCH 6/6] fix: improve AcroForm rendering compatibility in Adobe Acrobat - Added `/ProcSet [/PDF /Text]` to form field resource dictionaries. Strict viewers like Adobe Acrobat require these to properly interpret content stream operators within Form XObjects. - Replaced font-based checkmarks (ZapfDingbats) with vector graphics in Checkboxes and Radio Buttons. This ensures symbols render correctly without relying on the viewer's font resolution logic. - Implemented clipping paths for ListBox fields to prevent text overflow and ensure visual consistency. - Standardized appearance stream Resource generation to ensure local resources are present even when /NeedAppearances is not used. - Performed minor refactoring to comply with pylint/black/isort guidelines. Fixes rendering issues where form fields appeared empty or displayed incorrect 'X' marks in Adobe Acrobat. --- docs/Forms.md | 131 +++++- fpdf/forms.py | 553 ++++++++++++++++++++++++- fpdf/fpdf.py | 247 ++++++++++- fpdf/output.py | 66 ++- test/forms/checkbox_checked.pdf | Bin 1866 -> 1924 bytes test/forms/checkbox_readonly.pdf | Bin 1873 -> 1931 bytes test/forms/checkbox_unchecked.pdf | Bin 1868 -> 1926 bytes test/forms/combo_box_basic.pdf | Bin 0 -> 1848 bytes test/forms/combo_box_editable.pdf | Bin 0 -> 1738 bytes test/forms/complete_form.pdf | Bin 0 -> 8034 bytes test/forms/form_multiple_fields.pdf | Bin 3827 -> 4231 bytes test/forms/list_box_basic.pdf | Bin 0 -> 1920 bytes test/forms/list_box_multi_select.pdf | Bin 0 -> 1949 bytes test/forms/multiple_fields.pdf | Bin 0 -> 3207 bytes test/forms/push_button_basic.pdf | Bin 0 -> 1776 bytes test/forms/push_button_styled.pdf | Bin 0 -> 1778 bytes test/forms/radio_button_group.pdf | Bin 0 -> 5848 bytes test/forms/radio_button_selected.pdf | Bin 0 -> 2599 bytes test/forms/radio_button_unselected.pdf | Bin 0 -> 2587 bytes test/forms/test_forms.py | 227 ++++++++++ test/forms/text_field_basic.pdf | Bin 1642 -> 1755 bytes test/forms/text_field_multiline.pdf | Bin 1651 -> 1764 bytes test/forms/text_field_readonly.pdf | Bin 1621 -> 1734 bytes 23 files changed, 1196 insertions(+), 28 deletions(-) create mode 100644 test/forms/combo_box_basic.pdf create mode 100644 test/forms/combo_box_editable.pdf create mode 100644 test/forms/complete_form.pdf create mode 100644 test/forms/list_box_basic.pdf create mode 100644 test/forms/list_box_multi_select.pdf create mode 100644 test/forms/multiple_fields.pdf create mode 100644 test/forms/push_button_basic.pdf create mode 100644 test/forms/push_button_styled.pdf create mode 100644 test/forms/radio_button_group.pdf create mode 100644 test/forms/radio_button_selected.pdf create mode 100644 test/forms/radio_button_unselected.pdf diff --git a/docs/Forms.md b/docs/Forms.md index 542a7b319..12acc8088 100644 --- a/docs/Forms.md +++ b/docs/Forms.md @@ -5,10 +5,14 @@ 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 `text_field()` and `checkbox()` methods. +To add form fields, use the corresponding methods for each field type. ```python from fpdf import FPDF @@ -25,6 +29,21 @@ pdf.text_field(name="first_name", x=40, y=15, w=50, h=10, value="John") 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") ``` @@ -69,9 +88,117 @@ The `checkbox()` method creates a toggleable button. | `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 -Both field types support common 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. diff --git a/fpdf/forms.py b/fpdf/forms.py index 8e910df21..9473eb935 100644 --- a/fpdf/forms.py +++ b/fpdf/forms.py @@ -11,6 +11,18 @@ 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.""" @@ -133,7 +145,8 @@ def __init__( 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) - self.d_a = f"/Helv {font_size:.2f} Tf {font_color_gray:.2f} g" + # 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.""" @@ -181,7 +194,8 @@ def _generate_appearance(self, font_name: str = "Helv", font_size: float = None) content = "\n".join(commands) - self._appearance_normal = PDFFormXObject(content, width, height) + # 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 @@ -234,7 +248,8 @@ def __init__( 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. - self.d_a = f"/ZaDb {size * 0.8:.2f} Tf {check_color_gray:.2f} g" + # 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): @@ -244,10 +259,11 @@ def _generate_appearance(self, font_name: str = "ZaDb", font_size: float = None) font_size = size * 0.8 off_commands = self._generate_box_appearance(size, show_check=False) - off_xobj = PDFFormXObject(off_commands, size, size) + # 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) + yes_xobj = PDFFormXObject(yes_commands, size, size, resources=GRAPHICS_ONLY_RESOURCES) self._appearance_off = off_xobj self._appearance_yes = yes_xobj @@ -273,16 +289,24 @@ def _generate_box_appearance(self, size: float, show_check: bool, font_size: flo commands.append("S") if show_check: - if font_size is None: - font_size = size * 0.8 - commands.append("BT") - commands.append(f"/ZaDb {font_size:.2f} Tf") - commands.append(f"{self._check_color_gray:.2f} g") - x_offset = (size - font_size) / 2 - y_offset = (size - font_size) / 2 + 1 - commands.append(f"{x_offset:.2f} {y_offset:.2f} Td") - commands.append(f"({self.CHECK_CHAR}) Tj") - commands.append("ET") + # 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) @@ -293,3 +317,502 @@ def a_p(self): 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 f2e6248d2..9d0cdefc5 100644 --- a/fpdf/fpdf.py +++ b/fpdf/fpdf.py @@ -114,7 +114,7 @@ class Image: PDFAComplianceError, ) from .fonts import CORE_FONTS, CoreFont, FontFace, TextStyle, TitleStyle, TTFFont -from .forms import Checkbox, TextField +from .forms import Checkbox, ComboBox, ListBox, PushButton, RadioButton, TextField from .graphics_state import GraphicsStateMixin from .html import HTML2FPDF from .image_datastructures import ( @@ -3253,6 +3253,251 @@ def checkbox( 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 a59cebb43..a8097c407 100644 --- a/fpdf/output.py +++ b/fpdf/output.py @@ -224,6 +224,49 @@ 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: """Represents the AcroForm dictionary in the document catalog.""" @@ -1065,13 +1108,16 @@ def _add_annotations_as_objects(self): # 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. + # 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): @@ -1835,14 +1881,10 @@ def _finalize_catalog( default_resources = None default_appearance = None if all_form_fields: - # /DR dictionary with Helvetica and ZapfDingbats fonts - # These are standard PDF fonts that don't require embedding - default_resources = ( - "<> " - "/ZaDb <>" - ">>>>" - ) + # 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)" @@ -1851,7 +1893,11 @@ def _finalize_catalog( catalog_obj.acro_form = AcroForm( fields=PDFArray(acro_fields), sig_flags=sig_flags, - need_appearances=True, + # 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, ) diff --git a/test/forms/checkbox_checked.pdf b/test/forms/checkbox_checked.pdf index f27800e099296304d41da8435c2936a67aa03417..7cd3b8d55f4d88d8bbb0964c174aaff06beebfa5 100644 GIT binary patch delta 395 zcmX@b*TTQSopEz4V-KTjP-=00X;E@&v4V|_en3%va&T&iLbQH>i(9On-Q?ZO?UVDE zy%kL@x%3IDv^UT*Fi>#kGS)LOP$=gzRPX}gEG|<$Gjjz)Q$15lg+E zONAURBO^UC3k3+7!xcQakR^{%WAc3#QAW+pUs#+O>y3;Q3_w63Pk{@}FfcGRH^C4y zGs6_KG%!O~XJ}+-j3H)XVu>MUY6#Igc^jK~tgE@Dg`>HNi?f@vrJJ#vqlKxNiHVb& rxuKJ@nWM9zg`EvS6|r1)c3j0JiA5z9MX70AhNk9*T&k+B{%%|VIcr}( delta 351 zcmZqSKgGAfol#6d-!C;a#j&6uHL)l$FFCbXp`@rZb+bET598!dOc$6;j3<9!YS(uP z;nI&vbV*V$GSV}%PzXumGSD+HP)O%8(z7sDFa{AJDO?&RnhGIVT&^LLSF+@ZqH0ip zXxOaB>daVgVxnLG0t$HwTwsQQfvKe#hM1WJrkI7LIl4MSLsLvKV@pE}bta}3V6n*` z*vw-M3{9O}Oq>iI%`MGcfR1oAb~JS~F)(*=G;%XBHg&VJA*dpj%g&CgxFoTtq@pM_ Rjmyx~(3neA)z#mP3jl;)Q!fAj diff --git a/test/forms/checkbox_readonly.pdf b/test/forms/checkbox_readonly.pdf index 83ed980ee1cb15fb39ac419e18cdd9625319c19f..29a042c55d3f69ac1113df51ee8984f252546033 100644 GIT binary patch delta 395 zcmcb}*Ui7dopEz4V-KTjP-=00X;E@&v4V|_en3%va&T&iLbQH>i(9On-Q?ZO?UVDE zy%kL@x%3IDv^UT*Fi>#kGS)LOP$=gzRPX}gEG|<$Gjjz)Q$15lg+E zONAURBO^UC3k3+7!xcQakR^{%WAc3#QAW+pUs${t>y3;Q3_w63Pk{@}FfcGRH^C4y zGs6_KG%!O~XJ}+-j3H)XimA@j2%>KCUN-YsH$!I^Q&%TLLnliMM-vxUBUf`rGc!|H qOIKqfQ&UqnI~#&3V!7<>xQa^>i%KerQq#B$P0cO1R8?L5-M9csl3yqQ delta 351 zcmeC?zsR@2ol#6d-!C;a#j&6uHL)l$FFCbXp`@rZb+bET598!dOc$6;j3<9!YS(uP z;nI&vbV*V$GSV}%PzXumGSD+HP)O%8(z7sDFa{AJDO?&RnhGIVT&^LLSF+@ZqH0ip zXxOaB>cv=ZVxnLG0t$HwTwsQQfvKe#hM1WJrkI7LIl4MSLsLvKV@nGRbtYz}V6n;H z+00`tT%BA^%uS4qEe#9}+{|2zObkqo4Gf%(O$-f8+>9OVYzV4|<+8KmDlSPZDyb++ SP2(~&H8S8*Rdw}u;{pJRcv8{; diff --git a/test/forms/checkbox_unchecked.pdf b/test/forms/checkbox_unchecked.pdf index 933a586438dc900c23d2a90d1e29fba752be9ee7..46e35128621230e8f803be2f7d9eddc2777d910b 100644 GIT binary patch delta 395 zcmX@Z*T%oWopEz4V-KTjP-=00X;E@&v4V|_en3%va&T&iLbQH>i(9On-Q?ZO?UVDE zy%kL@x%3IDv^UT*Fi>#kGS)LOP$=gzRPX}gEG|<$Gjjz)Q$15lg+E zONAURBO^UC3k3+7!xcQakR^{%WAc3#QAW+pUszlj>y3;Q3_w63Pk{@}FfcGRH^C4y zGs6_KG%!O~XJ}+-j3H)XYKS3bYG?r#o4kX~Jl4&{$-vCS$=K1-(#YJz$i>pd5adIJeVxnLG0t$HwTwsQQfvKe#hM1WJrkI7LIl4MSLsLvKV@qQUbtYy8V6n-c z*vw;H&CH#QU5uTa9SzM*&0S0kU5$;+EiFyWoZVb44V^6PYzV4|<+8KmDlSPZDyb++ SP2(~&H8kZ?Rdw}u;{pJn_)~}g diff --git a/test/forms/combo_box_basic.pdf b/test/forms/combo_box_basic.pdf new file mode 100644 index 0000000000000000000000000000000000000000..78f51241d03c20a4e86f066e978499bc66f73569 GIT binary patch literal 1848 zcmcIl&5q+l5Jr1!ZahIy;yX`oMQ4}e*ly$ zwPmQ3Hs`?9CS{~TN&DdArqsLbOKpe0dI(9R74{$3VF&a&I=<;thq>yj3k0D8Gk3$+B|~r>7~k)GTpHK4{q}N z&(2S-ch~ym%|{>q`Q{Vi$x(Y`jMwlSoZ03$9jn^~r)RkyLP|8MJBTDbMsh@l@t5SW z#gkHJQ)yt%k?M$o+?xl?@_(ckF@(L4l(Iav_M7qINoJ;j#YkwJ+aY@eRgc31!&_f2H^_3>)_)IDgH=9iJkvjh7hJI zU8e9}?(_dF|Zg%e6GvQ)p$hB+F;a z>aa@c>16yeLoD)0Z5pT(4*x(-;I|u`vfy}z$Mg1~F?YR&ix7v3mQIB5Lo51B_#Iz3 zt`I)b%D%sDytXeTb`X3)R&htxd5JK)EnS;rJm@fJQ~Jh;P-jDr0oAK>wUgkrGFUf6QnN1Hr z!v09hL~CPDg^4C@l}5{8pW~`F?e2MR`&T{(ep~0d+2ys;#S`!ZEvb567Am`nsr?yw zkneq#Of{yh3)Mi-Z;dw5>x)Vr`;R??EwsFLQiSo0LN*2tbC8=Od&fqmDro>9LX@GXC5= zQar0oF;^BA9HovZs7L#NUH*^qB89MLky4jOYQMf&zEg$mq46yfj_bP6ztAur~YVn6{Razp@ICS~unLT^Og&RJo- zuU-;EdOuP=nUT)E1R^V=J;$bAp$58>K_EfT^DQK{-px3zn+qfl26&r-7kN?)@$nW# z`Ig2AxysZEu4<(;#8s;>Q--L)q9Y!H5YHTh$M+o}ArzkD4>06(D%_Q;jj{+ioEL3l z&Gs9!qp`%I`+4t1s3&8R_;FAFAQ#}*Thx7gSd+tg{m@Gh_EID$bE=o(Ai#P0nTUpw zke(0`67Ak!^ges66|xEuBhUVVq3%6Q~-Thh2%kLF#gJ;HApyM7-vunLJK)}#*3X4%7r>U3- r>Bqq|N_}~m1aT0DNfg8~k(bf<-y=HA8=Kd*gH8K7Mci~5J@1MBo54~TUEFIOD045XL?fUp|^WAgKUD~MDjxpJgmfpGc&)-W7R0ub>rIi&)sf2?d0VXLYyR8_` z>sVF4AStJLtLv4+T{y3z#B@zrbHFi}?6_Fl+}q)xY)xu&W^!pd63F!Oloq=})Br zN;C3;c*l!);O~J_K}nTg>iWEXyiDudpc+I_I!c|#;UMyP44Qyaz6e@wVFmQgdDi?vwoLt6$hx?^t{PhcDkd(4YJ9xj)tS zugE{S^5JK$eq;0LyPkRFgZICAXXB;!{{B|nzUj$_Z+m9{?d_*teD4oG(p$Iv`m1le zd*sS3pTG6)$CqFE&HlgLe=PsyJD2|RxyQeC|`cy!6-CFJHU; z*v&6@-Z=8pe|_<9@80u0wSMbcuU-B6YrE#{!3SqTZ~N4XWFFZ9GD9KcyU7JqGX&t11#np{yTA8oG=Xs0{qqG*c{|;o>3UQbRf;tzp+G z-H|2cx+`rEULOQui8QZr7_|^eg0mRvaKt=J!*>hzp=bLj>K6&m)~gAMuUY`4|s2`(F!1Fz3-sx{x}kZCGTWf3&2c{7ikWF2jwKU- z0f8&qDq@$x1Ne{Up6uvg%Z>_t$&@V{blEhZCov}L2Bc+AAbLt+bZX=fsdTvigG$GX z2$(sv5#Q@uTObY@P#!^EB^}pOGb|w&W9=N=i_wu2wmInL zCv6YRqA7?a_oeg)(uHM0R*tIEN?$T$-GZzo={0LeU&S!KFgy^=(ze4YvPHo9^aY}` z7{4%NHpBv(yg_FiBWX`ToE5#z1MIuW7M^zh2OhjZwRjl!Jw4gm1Z&M2nv(dz@OXD1 zD@WC7OJA~NLnr&eZiZ~5&57pXYi0-HyJ#kgQI+_HrZno2j#C(NXiBqyS&Uc8=#}$C zuy1dQeFb&ZLE(L`+e1PhXJ8>+$ib?tjr$(N!CAMIg|T#*sih7{fsbufc5JX5JatWQ z@Qks+R>>`y48PzX{u$h+K(EaS}tvjHp(DSULpU-u$P$* zH9(fesEi!_!cPp=EM4p?RrW9*E;pq#D~Ub@5`8oE%Ak!|PMg{^Thj7FjnYwQZcA&; znT~HU{?Fq%Oel!(ZH~Vn@}T(}$6_||vOH^1!#JF&cIGcsI+Sh1`~}VjDqi5VJb36N ztS22Fp`S38+psiMwq4NBqcAM640_DYiq#$rv5tWXN^@QsXoydY2Qk-PLX`o%+>rz-`q zFi6({&Ed2GXE_ObQ^lN(p2OaBDAPIz_FYViM?6MN&dckeGe0wvfwv?04v{sPgLe&c zxrZr%ONm=oxVfWpm_Qt@HpM$;aqutC>!OPKcqofTD)gWs(vE!;r)ec2DG)UE%UcgS z7Mf<=W>n2(Y7-N4n6b1ewiE_3*z9)~0a)DL{-d4CoU8RE=D31yyu}SvnpN%!E*IJ=2|Y(P(g@4RXumK z&~G>5cqW5pN>r78fYfcm77S%{OGh6FhBBt&lqY0{h0f%J%rfx+pOCo}{Ey3+rcJeD zx{I_o(atiEHzs7RF*QGpVIp~=9UhdZrSZPHZXn-I$Sg`#jLQs0uJMG-Mjv28W~!Le zoRk^vlwM35&1s^YrJ*x1DKqRT9aye8r9YeLQ~I+t+{KCUY+Wm?BZ)8-!y|Ep-RM5b z2?t6&3==#->2_8*5p0D(J;aYHoL^y9MQ3)&c3i7ga@Mq3xoonE>8z=yYn9ct)$6wq VxQ!DpN(B0B-BG2brM2~%^k1vu*(m@3 literal 0 HcmV?d00001 diff --git a/test/forms/form_multiple_fields.pdf b/test/forms/form_multiple_fields.pdf index f9544a550cb6205e4d1467309ee4acdeb1fc7f6e..5d61ac8c14ed0447229b02c66f65757a94566b81 100644 GIT binary patch delta 884 zcmchTO-lk%6oyf(>}IWmHc7{Ykel&-%*>q$6^zkBf+X9NOkgqwVX32i>?B$Q?c#3y z0WDev(k|$4^gmj)2pYA8c`03W;C}FO&v~EszDI7OPbFWk>09-OcAZxJ+^sk*NG8Q~ zDD1c4zH0~4UJ00a6nX$JuO+se+654*U<`Xz!2R<$E*57aq8qrIFyrly{)fn@f1=>l zD@CUbr9{EX*#m;OXrM#!F&`HBIU}lZl$fd`HDKICMBx}N5rMq7P7qZL23#>PoFYxp zIWR?2p@s-imUq_{Y`vqr! z1{aKVqDh)T&2fzoL)MtCOYb<9H4_hgJ0r%FsOoq#RW&SWx*-jurVPj^lY%gn|L|Dp zXJ#6ge!&Yu#QsvsW>zMHb2{NVHHe{E7T(BdCTAJbB!p%&>Ey&0HcF>at6g^6SFTe< Nj1!JxvFuI`eF1t})Tsaf delta 627 zcmbu4ze>YU6vjy_)!tg1s*A;|P_zim{gd3>fEHSlj&=zSmI~1(f!7`ZgJ-y$^W!_;ch7M1t$5$kRPV~Bm^kd&S#gtaK zfxQrs3L*F$(Tc$^OCGO-c8oIiE|BZNXt@B8ZGo0AWRdP1VZ9lKfb&JzMWb27-M{QF z6YI%Z{y`fE1Cyk17o)F+5Wy{lNt4bqN-U``W^#oYk|<1=v)sXtU@C<j)6 zGSgy;PIBcloLIkh*OQw$70M-i>Ig3@`)!*t`@pU{t}7d+bQ!HVb>c7~99Kvx?CRd1 ZiZ-fNQ9t&h_&N$g#5v`tRC1dQ^a*x-mNNhV diff --git a/test/forms/list_box_basic.pdf b/test/forms/list_box_basic.pdf new file mode 100644 index 0000000000000000000000000000000000000000..91295288c0b50c5812a128626325db28eff8c3cb GIT binary patch literal 1920 zcmcIl&5q+l5Jr1!ZahIyE?QX}8jiN|SoXn0`nBZ6pnrIG=)5&<`=??AAGQ+d* zF1!NIzy%>LaOXg^ogeQ62`<=*t$fv0UG>#h?TcAFeapH%a`D^mfBr-mIG~n~WH2Cf ztaj1>BlL@6Rl&kVQ*uw}mts|9BlQdm2N5syx;%iO$GSm?*7Rj7fX=dwsK{_ggk0Hh zycIUewVEpZ1cL#MBk0g~qWpmNbd8VACD1srrm4cs@m*W--2|>tI^Qi#y^jSM&{0;2 z#$s0sQ{)-YiOkiikQ<=4g^XlX9Dv~v=v@{sZS?;Z^)B14V+^&-Of{@8pwmK>mKGng zPVTW(db1TW%1xoCbM3wHha+yo$ zDSPbkOsjk@3@j*W9cd8H<{s1hAN6G=LQX2RSsr@(?alI0;{DY5MsDUBhTQ;VT?PG`@dD&zvI+gzgU z8`zhF8iO7_Qk<9vJL9OMx=?89f~E|xS;HwwjGtheVZnp0>w(|LlvL6vUn1^crLE}KLpKw}7`}cVNzP@kG zFSh>}VvNIS53jnCPW(9w{g82&u@Kei++DWZp0z*;KuD~mxxn{4EOZS=j3M*(0(r!k zgCBxdSdX7$ULOyZSL=NL3}c*!uh+4jkGy>B%RIN`YjjqWLf47V2Q!=_&SEgAey+&DDYV1bB|5K$aDPIaoi}3$CJQe{o%i_s5!4prcF&9^Ma6E JTug4J!4?QZ|XMp2~1p3II|NN^$sCYpoebg~|Kx`W->o#9z{ z7hZv9;DQhrxO1S|juSIk2`)H_oqW|@UG>#h<%?N7dCe@BT>SR?pFa@>HmKDD84L)G z)Lv>}gnp5)D_A;cO702$QmpfIte#+LBjSag#RCXjW(6Ty7tcEZbe3*KMTSEns`9r#OP|3>S|bDKqt8<3@tuJ zo!n!pbh;CAl<8c_16gK~egkq}6v&L>;Y9SGE8z0xD+V4)eHGkCoA77G zg7t2^B%)ILG7}Z_yP=sRI&qmJ+qfoqp+$)~7pWGpK=K8lZ-w0IcfdlksnVrLAIZ~? zPV&bu{Ab6z?fv@dlTZJ8^_kJh!Fo`P*YP|)s*Q8s>i2>5ZOaajQ=_B0gs9TPQI2C1 zdWs%vJS$Z;7aEonwT?81Cw-4${*U@H5+Ns*nkd`aemh+~h)j3r_?|G!wr%L%%Pld@ z^^bI+6l6m%aa`NIC)e1uAJBl5;g@r-rpOPGBC}_X-G?8}8`y7PxCM!O0^Ac|&GA4E zjB#UZm%4ZqI)DusFG$meW`XI{It-}yc(udDn)DK}zJ$dCM*f=Bdkr{6(QU;!{+%ca z_2C@9E`^Zi@VMB2#K~-5?urJ#T9B{LRb-m8)#qqtn&K-k%1TUq#;RPSVCyl)P)w&` z)TUYUj57&s-$1Vk4Mhho-X_59u+Kb>zZcj}@5}%|ks%AvBn?fDQ11hi0W<;QSqW+Y zmOQW=4}90LTpzgSS&T!#9LsZY$#KZt=IgD{C?M!%s!(Nx@icR*9tDJ+V#Bl!{bRbe z&HN(`0QBY&TX}qVMu+FkeW$rm8%GxrcCMLhG4?j};?G&=hm1Rng(ye+?y|G(sT8OW z2#K*Y7x<6Ghd=JIpQ>*LgUvCj9;FvgM77wecCo@1Vm)3x=d z%QP=US-=10`40lqpovl%JE$JeH*%xE5QD?|CWhr8a2?xY5#vGNv#=jWjy(yS$O-H* c3%$r4{`-oW^Gc_suBl@_3&_RA)pSDs22f}UKmY&$ literal 0 HcmV?d00001 diff --git a/test/forms/multiple_fields.pdf b/test/forms/multiple_fields.pdf new file mode 100644 index 0000000000000000000000000000000000000000..ae2c99214d65c0459fe1ff6664d8ec42d69fc0ca GIT binary patch literal 3207 zcmeHJ&2HO95H51u-ueV{F<>9c%&8DN3~LC`o}H3RHrY&3!T6WiXbzVSs^TlgU#U@+M8y&=ZIpPSXUP_{Tw%3B)DG|O6!5cTqUNmK zW-1?%IoUAVNz0;NlruOy5;^XF>%7WKn1E|UY`y_i(}0uRTh(c-f*Y@~d!xYp(z z5(?l^GFO#`-c~wG6W~XAT8y%M0{lG7JNZ1T1?@KQi==l=)<4YZMRGUpp{eUc&)en$ zyq~ElrNu|DW7aaOp!CI87&kw?Q+X%VS&^?YM?3U$$QRQoril{tGPy4n8u)3hy2W%c z%Pa95jFmc--9#tTV$w(^6{4ysT&o<_uJMi^m&M|46Ls~M4}cHLB%j|U zrOMNL;9ZpT)Y~jo@o_}4oRJ2RP+5u9yjYZ}LO-djaqTFD)bNtdti9}KQ>{wOz%z|X?A7m4f!V;Hl%9z`!04e=Y=A9?Unz~$t)y@c zIg5s@>M0@79Cn>C4inrUY)b?%9I^d=R?hVS3~$-dkiBL{=)|Kk5b)~)cg_e_UjdaS zrU;{=9AU)`MF=d%rqSzTO*Gi=sQVgL2V&X4#bt=KSW>N0tc|HC1TEwl8>Hu12)mYW zEYC%|YbH3)MiZqmm#{yk&J<|4YHrgajBgG>|5iu}8XR_`1j4fAE-c9>uvDM20aMrrq5&r2qg|{Ndb^-x?vgdg zchkiihJj#9H`j8)=$5W}s9KR7s-E9XD27SohEHBUW&lB^>$-hJ$ifT%8!;2%!ZC9! z*G8@t@B)9g%(MIeglBmn%$N|C<3eEBAxs(4E#b>Ma`s@tx>4zO-J7H&)~h)b ziS%?l27*%z{9C$bDbLsoM64aWoKf};hBaeNbv}(# zEkA?+k>c6!Xo-}Zeu7@j37d8>!$A#>CoWpxCo`NFqSdpzdY)W0W(d(7b-Fkq&^*<{ z83OiuUx?5Tg>-}n5%l)OL1W*tR0wepE+bX-0pIhm6UVTY3B0Z71NmAeY#eE}WG?R7 zmiac8aqB(wW?Lrg)+2jD;*_$rSNNFpZCT658}{KD5)D3*xmdRC_k8z}JsIHLzJ5=a zNj6obnezwP4~lZdr1QnDO1S)jdChBaf7!y!L@q|c_wU&5ifX7)S z8x2`Yldm%17iy)~x!MB1$<;_zc`FzWf!}7ylDz*nueaHLlc1|*W~yQR0H5TtAXoy7 zI=#bG`So6^(aPjn9l;_a^9IzRC=eOKH9js&eb_(c*8Jo%;PWz5)jlhwT0H_Eqa=~<@|8?4V+wyt z8mxEYC6$#vlq*?5zZ)7Q(TR($Y~z~bg^?xZTx3Qj62X^@zm;lhegF~Cp~{pp+p&kA z-1LuMonKu4ruXsHXRrTy^*Qn6s68^q?|6<+Y!iLkvP19#Dq_m?GG;U-ntlUR>y#jib-6|g z)q_P~eV@iddoSif_nu?z!Nb=C0^fH7+|6+e80@DM;e~}0Qbc`BF7%Ooq2;*{3fJ;I zbbA^fH~D%i4b}#FRUP>EOQXyVuqp$+{Lqym1m!|sTW>-e}{-E~}q9T#J4 z1{xL#whQ!-j&*D zAYf>pz+w>laWaXkHFU!Be0FwraXn#w E18f1^4*&oF literal 0 HcmV?d00001 diff --git a/test/forms/push_button_styled.pdf b/test/forms/push_button_styled.pdf new file mode 100644 index 0000000000000000000000000000000000000000..35dbbdd4d660441404248aa5906fde07b4132b26 GIT binary patch literal 1778 zcmcIl&2Hm15H9w}-ueV{3E&)76!mARFbvq1l?1!ncx3@u#0Uymrd_2n>6Peh(r4+r z^cDIHy%fb>+ItTj>c>$x3$&NgFiFo0=jWRrpDn`ZB{Ljy_WQ>_edU&Be1(TvO@txK_0tO=Li)X~i3k zmgXwYQlN8@$xSYHKyPv}5mnw1#$%wjX}H$f|C`p^^so(4)jCzxxL!b`oEI7v5511> zFjRVd;9`=gT#6PfEu=0%9E$>x(U`nWALUU2y%u~Xi{oB2f$(k0NOOht7_OD?iUpfjX|{6n7c_+qNvUuX{I zyU`N!N*>FMS1|0mri1ADrA>BzMR}oki7^+c;vq-yIiat)*s1S;1^Q8?N}le?!%uen z$FJ5ewtF-9?Zp>g{`KN3T_-KKtr)kbd3vfg%kqrj5Qe7V4-rybMs)_UNLx#mWfS`3 zJf?V2%52FMtSFW`qQD>29eVjcmY1#&@>HbS%PzI=uGjZGQ#~}^6VtFvY$d~~cOUn1 zM|5xXqlJ=_Ew-or#20w^p%&S9LM9ZjY)NJVP(v{g$@YuyT9qc89@bd4kDL2vSkr_w zjmR7W{Y$p!G@d)+yM`Trjn3PUY{+0%dX+U(AF&b_89J3D&ZV`B{YkPQ0a1bc$s2Bl;mE{|@3q!{;JRdCA zHJAlHvklilwWk4clW%rhVRm3psmFqEzFjMUx|_LBw_2~cmcC)#sU-9?RoFnL&*_y2#Dr1tB%`+Eu3f!Vblx6 ziN;JEy`Rt=>p9IGVk>_l=XuX)jM*5#KjoNhJ+sT~pl3ssX}zKXMF%#!@QK ze+xMEuEbV?21egQSdAw5K8Re;G$TKpv2gCQa5{G*7c(73kvsl(i5l}trKPH&V~#T< KXJ_;4i2M!1Xx*a# literal 0 HcmV?d00001 diff --git a/test/forms/radio_button_group.pdf b/test/forms/radio_button_group.pdf new file mode 100644 index 0000000000000000000000000000000000000000..afed66e34875caeca308016c99bf0c858ee3c7e3 GIT binary patch literal 5848 zcmeHL&2QsG6o(s(v}ZtE=2EF;g~wxi92-@UlDO$s6q-$|E!(IDdGF1eH@`RYmRdpc zRc<(B>E~a6|B7&G()8entgaH)NXJP|IbpBI-4X5Bz-sRk_J-`nVLd&e9TODG&`oNt1PI|Wp+yr?g4+N2Tgy4vY?Hu zW(vyTt+?8E$4)&IL9EFsvB~jXqlRjm;aZ*c0@q|#VQnnWc z2Ppk=Quo4PF91;oVLqy=hq7iY2k4dxSluQZeF3k+NicD?HIzv$%HuRCU57*RTQnID z2H+?3&}Mj=j&sVk64^)xl73hU>$#2X;f-gX|M2CX?moErptJYOOCQ}l`*iv6j~71Z{`hdkJ9_1;`^gtv zeEX%jcISt)&%ZtozWMIOyYJhZH=cQ~LO;V;ER~c|iN;S1tWP7_vJBUws)cpJNL4`s z`=GSfb_HP)=R6xi;H(2scdAq&5R z#lzjW+m|^YB3;hHZk)c|$CLz$icGB18DW}Iu)c(APj|xtuAe@*_OBKI3%JH3#?eCv4K>sanPl3+EH*_;B=*eaD6qL289ep)x-t_sJsI3Lu_Z{Ceordk1rfWH(8C3tehl-0)9%i{(U06#f KWG}64HOZgZ=3TV_ literal 0 HcmV?d00001 diff --git a/test/forms/radio_button_selected.pdf b/test/forms/radio_button_selected.pdf new file mode 100644 index 0000000000000000000000000000000000000000..0108f50d0abea07cff69dea23fec406d90ce6d06 GIT binary patch literal 2599 zcmeHJ&2Hm15H9w}-ueV{36LBriuzY!7_clm8+5mJm7pnN1cfZqjux`$mFR7<&(e44 zEA$z9DT-d&dk-CwvZHJYwCJe?sslyN3};5e;rHoeo=!hymPby0{^hst2?HC%@(mdb z2px-kErAjGdAVv~;i4+LCG<64m3boWVPT`hkA2IDAo7?M#i+e{*m0oqe8XEZ91>oy zlsl*Ac2qQCDw-_}1~eVPDSgSScc{Nwu9mrMhx!3JEqSHN z5@OcbEq*GU?RY&ZWGU*dSjtGggnD09SQ%B5SNVh3OQ5rwk43fL)<)cds(W^*-Z+yOS4u77>{(~FNj{{6)#s!uv?*D-$2b8_r9C$d~0^oSKzU8D~Xd$bGW z20o#Wx#J1X8&OpmULD)= zJus`#^5f3bOuL(57TVC5_q2M7Zgc>>=%V!en)|M9*q^kjqJRSmCJ6Rij_>jYDndtd z1;k&dVYXg_0<{d$f2T--fokzg(FqzN8h2ts6Va>?Nj1MR#5v89nz1lPn`x$0*s(c7 z-B-PhQ}7TKag3So;Oq*iQ6k4=h-|Ph;2B0p*vQ*~6~quR7i)(hj+l5|t~OjED#B@T zCy*V!*(g52NQNpk`w-fTis^Tlt(f9ahbOR$+{5pY_Aa(aA4&Hfckhea9-+iN7h_}# zRj3sC1Wwb>SRBO6aT$vdmhGFf-nRROBlZEaiBg#d0w2Ku)^L8k@XgOJ(>;Rl)mJfcc{Nw;jlgfn&nzGkr*7`MvHIj*huN+exXbpEo4B4 zMZ>K|TXR*F1<M-#kDk72oKx z#F%w{i(94B9T$UARZ_IY(njhfi2Y`RmC-eMRXoVO0y-6ZBscr5u;K=E-Sb2BMujRi za^0z)uKnRy20}=646KhyC@xSESRd!L-0vRuW|#C5=v7^aW>?f)ln+2hD9QM{s^t0k zP^UlB8a!|7CFhOY*ClTt>87SP(aB4PJmfX0Hj3A{=S`t_#bOsmZ1!E8?1zyJLQi zm-^d(b=nR2@_7S9|EvK*|NjO+T<)~Nf1&|w!g#w}CypL(mtDn9!ho5vtG9E!I<6B2 zU{|pdrmd-+wl~8rjG;B}Xw4Mem;h$cM(Oyq_g&wxKWSA>0S6QuBG_{^x+`kf5IUG? zApSxR)71(zsO5S9r-{O zc+X{FjP&+_T@*c&F&1M3JhfvfR1}+QSQ5L3NI0@aT|JN4gBpOxJ$`sV| MeEb(Y8BZtV52+w`CIA2c literal 0 HcmV?d00001 diff --git a/test/forms/test_forms.py b/test/forms/test_forms.py index 58fa89e4a..e05623089 100644 --- a/test/forms/test_forms.py +++ b/test/forms/test_forms.py @@ -142,3 +142,230 @@ def test_form_with_multiple_fields(tmp_path): 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 index 64711d65b5ccf956694f361648f521d29ba91e5d..e3e9bfe4f4c4f57a58762e8d4f102433d9391926 100644 GIT binary patch delta 300 zcmaFGbDMXAJLBe9#y&=#pw#00(xT+lVg(zU$s1Tim|_(s&WL5P1A@&jnUopDHS|4F zbIKG9jr0r*6hhJz3_x_c=4M3}Z^n8fBLxExP{>o@0y7K@OwCO&#LUbv#0(8AEz#8( z8XI7kXJ~AOq^_hWF*7H%h|9)?OFuZXYVvDVrC1|NS0^(gQzs`&3kz2lMSlMwKE};a%!-Vn`W~q{WeSEy zdIkmxA!!N*AUb{XBo=SRdJ_``0}xQiQ{VzK3=B*y%`n8wEHK3^EiBO085&wxVu%@; zBIzwDO3chjE#k7V;nEMztePCdrW9-FVr=2+SX3(X6Rz#Y;5FgXl`I)=4j|- lU}kA&Lr_I5mz^C~aYn7XZ`3Ml1jT diff --git a/test/forms/text_field_multiline.pdf b/test/forms/text_field_multiline.pdf index 7200ae34c2bf67e1a88db3927bce0e9fa8936b29..8c6c171a239ea30e08ca19d95f1e73c5cf7042c3 100644 GIT binary patch delta 290 zcmey&^MrSUJLBe9#y&=+pw#00(xT+lVg(x;{eYtU#G0n(G!2kpl@)Wqh3 qx){5e7`vF;*$`9_%VlTBRa}x-R8motn#N^lVrSlMwKE};a%nFR6`W~q{WeSEy zdIkmxA!!N*AUb{XM3zv-G!qjA0}xQiQ{VzK3=B*y%`n8wEHK3^EzHr?85&wzV2Bx+ znNId$vyL?~HnlW1wsbQ$cX6?>Ffuc6b8-Tb=9Z4eCdNjFZgw^VRm5`H*>M$@Bo>ua U6s4wd85$cIaH*=g`nz!f0QtZ_B>(^b diff --git a/test/forms/text_field_readonly.pdf b/test/forms/text_field_readonly.pdf index b6ef97a401b8a8361f404e71dabc2d1743318d1c..879443038678ca114ad5f489422360ffd077819e 100644 GIT binary patch delta 289 zcmcc0bBuR`JLBe9#y&=+pw#00(xT+lVg(x;{eYtUSlMwKE}yW%)dnSJyLVZ6by~@ z3=9-P(i99pbo%COmRQC#6B7jk5Kzcd-~uxY3`{M}FvQF(FvToQEYZ~&8k$*Ph#481 zPS#_yj&*f3F?O^xb~bl%F*7i8v@ozVFf_Mtbu=(=GB