diff --git a/docassemble/AssemblyLine/al_document.py b/docassemble/AssemblyLine/al_document.py index 62cfa68..3521c3a 100644 --- a/docassemble/AssemblyLine/al_document.py +++ b/docassemble/AssemblyLine/al_document.py @@ -1,7 +1,7 @@ import re import os import mimetypes -from typing import Any, Dict, List, Union, Callable, Optional +from typing import Any, Dict, List, Literal, Union, Callable, Optional from docassemble.base.util import ( Address, LatitudeLongitude, @@ -34,6 +34,7 @@ from math import floor import subprocess from collections import ChainMap +import pikepdf __all__ = [ "ALAddendumField", @@ -180,6 +181,50 @@ def table_row(title: str, button_htmls: List[str] = []) -> str: return html +def pdf_page_parity(pdf_path: str) -> Literal["even", "odd"]: + """ + Count the number of pages in the PDF and + return "even" if it is divisible by 2 and "odd" + if it is not divisible by 2. + + Args: + pdf_path (str): Path to the PDF in the filesystem + + Returns: + Literal["even", "odd"]: The parity of the number of pages in the PDF + """ + with pikepdf.open(pdf_path) as pdf: + num_pages = len(pdf.pages) + if num_pages % 2 == 0: + return "even" + return "odd" + + +def add_blank_page(pdf_path: str) -> None: + """ + Add a blank page to the end of a PDF. + + Args: + pdf_path (str): Path to the PDF in the filesystem + """ + # Load the PDF + with pikepdf.open(pdf_path, allow_overwriting_input=True) as pdf: + # Retrieve the last page + last_page = pdf.pages[-1] + + # Extract the size of the last page + media_box = last_page.MediaBox + + # Create a new blank page with the same dimensions as the last page + blank_page = pikepdf.Page(pikepdf.Dictionary(MediaBox=media_box)) + + # Add the blank page to the end of the PDF + pdf.pages.append(blank_page) + + # Overwrite the original PDF with the modified version + pdf.save(pdf_path) + + class ALAddendumField(DAObject): """ Represents a field with attributes determining its display in an addendum, typically for PDF templates. @@ -1441,6 +1486,7 @@ class ALDocumentBundle(DAList): enabled (bool, optional): Determines if the bundle is active. Defaults to True. auto_gather (bool, optional): Automatically gathers attributes. Defaults to False. gathered (bool, optional): Specifies if attributes have been gathered. Defaults to True. + default_parity (Optional[Literal["even", "odd"]]): Default parity to enforce on the PDF. Defaults to None. Examples: Given three documents: `Cover page`, `Main motion form`, and `Notice of Interpreter Request`, @@ -1481,6 +1527,7 @@ def as_pdf( refresh: bool = True, pdfa: bool = False, append_matching_suffix: bool = True, + ensure_parity: Optional[Literal["even", "odd"]] = None, ) -> Optional[DAFile]: """ Returns a consolidated PDF of all enabled documents in the bundle. @@ -1491,6 +1538,8 @@ def as_pdf( pdfa (bool): If True, generates a PDF/A compliant document, defaults to False. append_matching_suffix (bool): Flag to determine if matching suffix should be appended to file name, default is True. Used primarily to enhance automated tests. + ensure_parity (Optional[Literal["even", "odd"]]): Ensures the number of pages in the PDF is even or odd. If omitted, + no parity is enforced. Defaults to None. Returns: Optional[DAFile]: Combined PDF file or None if no documents are enabled. @@ -1533,6 +1582,19 @@ def as_pdf( ) pdf.title = self.title setattr(self.cache, safe_key, pdf) + + if hasattr(self, "default_parity") and not ensure_parity: + ensure_parity = self.default_parity + + if ensure_parity not in [None, "even", "odd"]: + raise ValueError("ensure_parity must be either 'even', 'odd' or None") + + if ensure_parity: # Check for odd/even requirement + if pdf_page_parity(pdf.path()) == ensure_parity: + return pdf + else: + add_blank_page(pdf.path()) + return pdf def __str__(self) -> str: diff --git a/docassemble/AssemblyLine/data/questions/test_bundle_parity.yml b/docassemble/AssemblyLine/data/questions/test_bundle_parity.yml new file mode 100644 index 0000000..bc30ced --- /dev/null +++ b/docassemble/AssemblyLine/data/questions/test_bundle_parity.yml @@ -0,0 +1,79 @@ +--- +include: + - assembly_line.yml +--- +objects: + - the_doc: ALDocument.using( + title="The document", + filename="the_document", + enabled=True, + has_addendum=False + ) +--- +objects: + - even_bundle: ALDocumentBundle.using( + title="Even bundle", + filename="the_bundle", + elements=[ + the_doc + ], + default_parity="even", + enabled=True + ) + - odd_bundle: ALDocumentBundle.using( + title="Even bundle", + filename="the_bundle", + elements=[ + the_doc, + the_doc + ], + enabled=True + ) + - bundle_with_default: ALDocumentBundle.using( + title="Even bundle", + filename="the_bundle", + elements=[ + the_doc + ], + default_parity="even", + enabled=True + ) + +--- +objects: + - bundle_of_bundles: ALDocumentBundle.using( + title="Bundle of bundles", + filename="the_bundle", + elements=[ + even_bundle, + bundle_with_default + ], + enabled=True + ) + +--- +attachment: + variable name: the_doc[i] + content: | + Test content +--- +mandatory: True +question: | + About to make a PDF +continue button field: the_field +--- +mandatory: True +question: | + Download +subquestion: | + # Should be even + ${ even_bundle.as_pdf(ensure_parity="even") } + + # should be odd + ${ odd_bundle.as_pdf(ensure_parity="odd") } + + # should be even + ${ bundle_with_default.as_pdf() } + + # Should be even + ${ bundle_of_bundles.as_pdf() } \ No newline at end of file