From 0f6809781541cd8371a5e85adaaaea4f4cf0ed3f Mon Sep 17 00:00:00 2001 From: rajeswari1301 Date: Tue, 25 Aug 2026 17:14:18 -0500 Subject: [PATCH 1/6] Skip exhibit pages with no valid files --- docassemble/AssemblyLine/al_document.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/docassemble/AssemblyLine/al_document.py b/docassemble/AssemblyLine/al_document.py index 5e90297b..5dcf6ab6 100644 --- a/docassemble/AssemblyLine/al_document.py +++ b/docassemble/AssemblyLine/al_document.py @@ -2895,7 +2895,7 @@ def as_pdf( add_cover_page: bool = True, filename: Optional[str] = None, append_matching_suffix: bool = True, - ) -> DAFile: + ) -> Optional[DAFile]: """ Generates a PDF version of the exhibit, with optional features like Bates numbering or a cover page. @@ -2928,13 +2928,19 @@ def as_pdf( return getattr(self._cache, safe_key) if not filename: filename = "exhibits.pdf" + valid_pages = [p for p in self.ocr_pages() if p and p.ok] + if not valid_pages: + log( + f"ALExhibit.as_pdf(): no valid pages for exhibit '{self.title}', skipping" + ) + return None if add_cover_page: concatenated_pages = pdf_concatenate( - self.cover_page, self.ocr_pages(), filename=filename, pdfa=pdfa + self.cover_page, valid_pages, filename=filename, pdfa=pdfa ) else: concatenated_pages = pdf_concatenate( - self.ocr_pages(), filename=filename, pdfa=pdfa + valid_pages, filename=filename, pdfa=pdfa ) if add_page_numbers: From 477cd07379a857872f025c5724630e9b9c216c92 Mon Sep 17 00:00:00 2001 From: rajeswari1301 Date: Tue, 25 Aug 2026 17:34:39 -0500 Subject: [PATCH 2/6] skip empty exhibits in ALExhibitList.as_pdf() --- docassemble/AssemblyLine/al_document.py | 16 ++++++++++++---- 1 file changed, 12 insertions(+), 4 deletions(-) diff --git a/docassemble/AssemblyLine/al_document.py b/docassemble/AssemblyLine/al_document.py index 5dcf6ab6..81009b57 100644 --- a/docassemble/AssemblyLine/al_document.py +++ b/docassemble/AssemblyLine/al_document.py @@ -3112,7 +3112,7 @@ def as_pdf( page_number_offset_vertical: float = 15, toc_pages: int = 0, append_matching_suffix: bool = True, - ) -> DAFile: + ) -> Optional[DAFile]: """ Compiles all exhibits in the list into a single PDF. @@ -3139,8 +3139,9 @@ def as_pdf( self._update_page_numbers(toc_guess_pages=toc_pages) if not page_number_prefix and self.bates_prefix: page_number_prefix = self.bates_prefix - return pdf_concatenate( - [ + exhibit_pdfs = [ + pdf + for pdf in ( exhibit.as_pdf( add_cover_page=self.include_exhibit_cover_pages, add_page_numbers=add_page_numbers, @@ -3152,7 +3153,14 @@ def as_pdf( page_number_offset_vertical=page_number_offset_vertical, ) for exhibit in self - ], + ) + if pdf is not None + ] + if not exhibit_pdfs: + log("ALExhibitList.as_pdf(): no valid exhibits to include, skipping") + return None + return pdf_concatenate( + exhibit_pdfs, filename=filename, pdfa=pdfa, ) From 1cf2bdc065c4623d7c6519c261a983a90635a3b0 Mon Sep 17 00:00:00 2001 From: rajeswari1301 Date: Tue, 25 Aug 2026 17:38:57 -0500 Subject: [PATCH 3/6] skip empty exhibits in ALExhibitDocument.as_pdf() --- docassemble/AssemblyLine/al_document.py | 53 +++++++++++++------------ 1 file changed, 27 insertions(+), 26 deletions(-) diff --git a/docassemble/AssemblyLine/al_document.py b/docassemble/AssemblyLine/al_document.py index 81009b57..fed11352 100644 --- a/docassemble/AssemblyLine/al_document.py +++ b/docassemble/AssemblyLine/al_document.py @@ -3409,7 +3409,7 @@ def as_pdf( refresh: bool = True, pdfa: bool = False, append_matching_suffix: bool = True, - ) -> DAFile: + ) -> Optional[DAFile]: """ Render the document as a PDF. @@ -3433,43 +3433,44 @@ def as_pdf( if len(self.exhibits): self._set_default_attributes() + exhibits_pdf = self.exhibits.as_pdf( + add_page_numbers=self.add_page_numbers, + page_number_prefix=self.page_number_prefix, + page_number_digits=self.page_number_digits, + page_number_area=self.page_number_area, + page_number_font_size=self.page_number_font_size, + page_number_offset_horizontal=self.page_number_offset_horizontal, + page_number_offset_vertical=self.page_number_offset_vertical, + toc_pages=( + self.table_of_contents.num_pages() + if self.include_table_of_contents + else 0 + ), + pdfa=pdfa, + ) + if exhibits_pdf is None: + log( + f"ALExhibitDocument.as_pdf(): no valid exhibits for '{self.title}', skipping" + ) + return ( + self.table_of_contents if self.include_table_of_contents else None + ) if self.include_table_of_contents: - toc_pages = self.table_of_contents.num_pages() return pdf_concatenate( self.table_of_contents, - self.exhibits.as_pdf( - add_page_numbers=self.add_page_numbers, - page_number_prefix=self.page_number_prefix, - page_number_digits=self.page_number_digits, - page_number_area=self.page_number_area, - page_number_font_size=self.page_number_font_size, - page_number_offset_horizontal=self.page_number_offset_horizontal, - page_number_offset_vertical=self.page_number_offset_vertical, - toc_pages=toc_pages, - pdfa=pdfa, - ), - filename=filename, - pdfa=pdfa, - ) - else: - return self.exhibits.as_pdf( - add_page_numbers=self.add_page_numbers, - page_number_prefix=self.page_number_prefix, - page_number_digits=self.page_number_digits, - page_number_area=self.page_number_area, - page_number_font_size=self.page_number_font_size, - page_number_offset_horizontal=self.page_number_offset_horizontal, - page_number_offset_vertical=self.page_number_offset_vertical, + exhibits_pdf, filename=filename, pdfa=pdfa, ) + return exhibits_pdf + return None def as_docx( self, key: str = "final", refresh: bool = True, append_matching_suffix: bool = True, - ) -> DAFile: + ) -> Optional[DAFile]: """ Despite the name, renders the document as a PDF. Provided for signature compatibility. From b127c5ce8671baae75b89a29970ee46450cf7720 Mon Sep 17 00:00:00 2001 From: rajeswari1301 Date: Wed, 26 Aug 2026 14:53:51 -0500 Subject: [PATCH 4/6] Skip empty exhibits and documents Extends the same fix to the rest of ALDocumentBundle: as_pdf, as_zip, as_pdf_list, as_docx_list, as_docx, get_cacheable_documents, download_html, send_email, and str, all of which had the same gap as the exhibit classes. Also added a safety check to size_in_bytes, so a single bad page won't break the whole size calculation anymore. --- docassemble/AssemblyLine/al_document.py | 179 ++++++++++++++++++++---- 1 file changed, 148 insertions(+), 31 deletions(-) diff --git a/docassemble/AssemblyLine/al_document.py b/docassemble/AssemblyLine/al_document.py index fed11352..32ad00bd 100644 --- a/docassemble/AssemblyLine/al_document.py +++ b/docassemble/AssemblyLine/al_document.py @@ -1740,13 +1740,17 @@ def as_pdf( # In the case of no enabled files, avoid errors return None elif len(files) == 1: - # This case is simplest--we do not need to process the document at this level pdf = files[0].as_pdf( key=key, refresh=refresh, pdfa=pdfa, append_matching_suffix=append_matching_suffix, ) + if pdf is None: + log( + f"ALDocumentBundle.as_pdf(): '{files[0].title}' in bundle '{self.title}' has no valid PDF, skipping" + ) + return None bundle_filename = f"{base_name(self.filename)}{append_suffix}.pdf" pdf.title = self.title pdf.filename = bundle_filename @@ -1756,11 +1760,35 @@ def as_pdf( except: pass else: - pdf = pdf_concatenate( - [document.as_pdf(key=key, refresh=refresh) for document in files], - filename=f"{base_name(self.filename)}{append_suffix}.pdf", - pdfa=pdfa, - ) + document_pdfs = [ + p + for p in ( + document.as_pdf(key=key, refresh=refresh) for document in files + ) + if p is not None + ] + if len(document_pdfs) < len(files): + log( + f"ALDocumentBundle.as_pdf(): {len(files) - len(document_pdfs)} of {len(files)} documents in bundle '{self.title}' have no valid PDF, skipping them" + ) + if len(document_pdfs) == 0: + return None + elif len(document_pdfs) == 1: + pdf = document_pdfs[0] + bundle_filename = f"{base_name(self.filename)}{append_suffix}.pdf" + pdf.title = self.title + pdf.filename = bundle_filename + try: + pdf.set_attributes(filename=bundle_filename) + pdf.set_mimetype("application/pdf") + except: + pass + else: + pdf = pdf_concatenate( + document_pdfs, + filename=f"{base_name(self.filename)}{append_suffix}.pdf", + pdfa=pdfa, + ) if hasattr(self, "add_page_numbers") and self.add_page_numbers: self._set_default_attributes() pdf.bates_number( @@ -1799,8 +1827,10 @@ def __str__(self) -> str: Returns: str: String representation of the PDF. """ - # Could be triggered in many different places unintentionally: don't refresh - return str(self.as_pdf(refresh=False)) + pdf = self.as_pdf(refresh=False) + if pdf is None: + return "" + return str(pdf) def as_zip( self, @@ -1810,10 +1840,12 @@ def as_zip( title: str = "", format: Optional[str] = "pdf", include_pdf: Optional[bool] = True, - ) -> DAFile: + ) -> Optional[DAFile]: """ Returns a zip file containing all enabled documents in the bundle in the specified format. + Returns None if there are no valid documents to include in the zip. + Args: key (str): Identifier for the document version, default is "final". refresh (bool): Flag to reconsider the 'enabled' attribute, default is True. @@ -1843,21 +1875,34 @@ def as_zip( if format == "docx": docs = [] for doc in self.enabled_documents(refresh=refresh): - docs.append(doc.as_docx(key=key, refresh=refresh)) + docx_doc = doc.as_docx(key=key, refresh=refresh) + if docx_doc is not None: + docs.append(docx_doc) + else: + log(f"'{doc.title}' has no usable DOCX, leaving it out of the zip") if include_pdf and doc._is_docx(): - docs.append(doc.as_pdf(key=key, pdfa=pdfa, refresh=refresh)) + pdf_doc = doc.as_pdf(key=key, pdfa=pdfa, refresh=refresh) + if pdf_doc is not None: + docs.append(pdf_doc) elif format == "original": - # We don't try to convert to PDF if format=="original" (for things like XLSX files) + # We don't try to convert to PDF if format=="original"(for things like XLSX files) docs = [doc[key] for doc in self.enabled_documents(refresh=refresh)] else: docs = [ - doc.as_pdf( - key=key, - refresh=refresh, - pdfa=pdfa, + pdf + for pdf in ( + doc.as_pdf( + key=key, + refresh=refresh, + pdfa=pdfa, + ) + for doc in self.enabled_documents(refresh=refresh) ) - for doc in self.enabled_documents(refresh=refresh) + if pdf is not None ] + if not docs: + log(f"as_zip(): no valid documents to include for bundle '{self.title}'") + return None zip = zip_file(docs, filename=zipname + ".zip") if title == "": zip.title = self.title @@ -1955,6 +2000,8 @@ def as_pdf_list( """ Returns all enabled documents in the bundle as individual PDFs, even from nested bundles. + Documents that have no valid PDF are omitted from the list. + Args: key (str): Identifier for the document version, default is "final". refresh (bool): Flag to reconsider the 'enabled' attribute and regenerate the enabled documents, default is True. @@ -1964,8 +2011,12 @@ def as_pdf_list( List[DAFile]: List of enabled documents as individual PDFs. """ return [ - doc.as_pdf(key=key, refresh=refresh, pdfa=pdfa) - for doc in self.enabled_documents(refresh=refresh) + pdf + for pdf in ( + doc.as_pdf(key=key, refresh=refresh, pdfa=pdfa) + for doc in self.enabled_documents(refresh=refresh) + ) + if pdf is not None ] def as_docx_list(self, key: str = "final", refresh: bool = True) -> List[DAFile]: @@ -1974,6 +2025,8 @@ def as_docx_list(self, key: str = "final", refresh: bool = True) -> List[DAFile] If a particular document can't be represented as a DOCX, its original format or a PDF is returned. + Documents that have no valid DOCX are omitted from the list. + Args: key (str): Identifier for the document version, default is "final". refresh (bool): Flag to reconsider the 'enabled' attribute, default is True. @@ -1982,8 +2035,12 @@ def as_docx_list(self, key: str = "final", refresh: bool = True) -> List[DAFile] List[DAFile]: List of documents represented as DOCX files or in their original format. """ return [ - doc.as_docx(key=key, refresh=refresh) - for doc in self.enabled_documents(refresh=refresh) + docx + for docx in ( + doc.as_docx(key=key, refresh=refresh) + for doc in self.enabled_documents(refresh=refresh) + ) + if docx is not None ] def as_editable_list( @@ -2066,6 +2123,7 @@ def get_cacheable_documents( for doc in enabled_docs: result = {"title": doc.title} filename_root = os.path.splitext(str(doc.filename))[0] + got_any_format = False if pdf: result["pdf"] = doc.as_pdf( key=key, @@ -2073,17 +2131,38 @@ def get_cacheable_documents( pdfa=pdfa, append_matching_suffix=append_matching_suffix, ) - result["download_filename"] = filename_root + ".pdf" + if result["pdf"] is not None: + result["download_filename"] = filename_root + ".pdf" + got_any_format = True + else: + log( + f"get_cacheable_documents(): '{doc.title}' produced no valid PDF" + ) + del result["pdf"] if docx and doc._is_docx(key=key): result["docx"] = doc.as_docx( key=key, refresh=refresh, append_matching_suffix=append_matching_suffix, ) - result["download_filename"] = filename_root + ".docx" + if result["docx"] is not None: + result["download_filename"] = filename_root + ".docx" + got_any_format = True + else: + log( + f"get_cacheable_documents(): '{doc.title}' produced no valid DOCX" + ) + del result["docx"] if original: result["original"] = doc[key] result["download_filename"] = doc.filename + got_any_format = True + + if not got_any_format: + log( + f"get_cacheable_documents(): '{doc.title}' didn't produce a valid file in any requested format, skipping entirely" + ) + continue try: # If it's possible, set the file extension to the actual filetype @@ -2352,6 +2431,10 @@ def download_html( else: the_file = self.as_pdf(key=key, pdfa=pdfa) + if the_file is None: + log(f"bundle '{self.title}' has nothing to download") + return "" + doc_download_button = action_button_html( the_file.url_for(attachment=True), label=download_label, @@ -2673,7 +2756,12 @@ def send_email( if "docx" in allowed: primary = item.as_docx(key=key) - attachments.append(primary) + if primary is not None: + attachments.append(primary) + else: + log( + f"send_email(): '{item.title}' has no valid DOCX, skipping from an email" + ) if "pdf" in allowed: if not ( @@ -2681,7 +2769,13 @@ def send_email( and hasattr(primary, "extension") and primary.extension == "pdf" ): - attachments.append(item.as_pdf(key=key)) + pdf_doc = item.as_pdf(key=key) + if pdf_doc is not None: + attachments.append(pdf_doc) + else: + log( + f"send_email(): '{item.title}' has no valid PDF, skipping from an email" + ) return send_email( to=to, @@ -2744,10 +2838,12 @@ def as_docx( key: str = "final", refresh: bool = True, append_matching_suffix: bool = True, - ) -> DAFile: + ) -> Optional[DAFile]: """ Convert the enabled documents to a single DOCX file or PDF file if conversion fails. + Returns None if none of the enabled documents produced a valid file. + Args: key (str, optional): The key to identify enabled documents. Defaults to "final". refresh (bool, optional): Refresh the enabled documents before conversion. Defaults to True. @@ -2899,6 +2995,8 @@ def as_pdf( """ Generates a PDF version of the exhibit, with optional features like Bates numbering or a cover page. + Returns None if no valid pages were available to include. + Note that these are keyword only parameters, not positional. Args: @@ -3116,6 +3214,8 @@ def as_pdf( """ Compiles all exhibits in the list into a single PDF. + Returns None if none of the exhibits produced valid pages to include. + Args: filename (str): Desired filename for the generated PDF. pdfa (bool): If True, generates the PDF in PDF/A format. @@ -3157,7 +3257,9 @@ def as_pdf( if pdf is not None ] if not exhibit_pdfs: - log("ALExhibitList.as_pdf(): no valid exhibits to include, skipping") + log( + "ALExhibitList.as_pdf(): none of the exhibits have valid pages, nothing to compile" + ) return None return pdf_concatenate( exhibit_pdfs, @@ -3169,12 +3271,21 @@ def size_in_bytes(self) -> int: """ Calculates the total size in bytes of all exhibits in the list. + Pages that fail to load are skipped rather than raising an exception, + so the total may be an undercount if any page couldn't be read. + Returns: int: Total size of all exhibits in bytes. """ full_size = 0 for exhibit in self.complete_elements(): - full_size += sum((a_page.size_in_bytes() for a_page in exhibit.pages)) + for a_page in exhibit.pages: + try: + full_size += a_page.size_in_bytes() + except Exception: + log( + f"ALExhibitList.size_in_bytes(): could not get size for a page in exhibit '{exhibit.title}', skipping it" + ) return full_size def _update_labels(self, auto_labeler: Optional[Callable] = None) -> None: @@ -3413,6 +3524,8 @@ def as_pdf( """ Render the document as a PDF. + Returns None if no valid exhibits were available to include. + Args: key (str): Identifier key for the document. Default is "final". For compatibility with ALDocument. @@ -3452,9 +3565,11 @@ def as_pdf( log( f"ALExhibitDocument.as_pdf(): no valid exhibits for '{self.title}', skipping" ) - return ( - self.table_of_contents if self.include_table_of_contents else None - ) + if self.include_table_of_contents: + return pdf_concatenate( + self.table_of_contents, filename=filename, pdfa=pdfa + ) + return None if self.include_table_of_contents: return pdf_concatenate( self.table_of_contents, @@ -3474,6 +3589,8 @@ def as_docx( """ Despite the name, renders the document as a PDF. Provided for signature compatibility. + Returns None if no valid exhibits were available to include. + Args: key (str, optional): Identifier key for the document. Default is "final". refresh (bool, optional): If True, refreshes the DOCX document. Default is True. From d72474ffe5be6709c934fe0d8936465c7f8983ec Mon Sep 17 00:00:00 2001 From: rajeswari1301 Date: Wed, 26 Aug 2026 15:01:47 -0500 Subject: [PATCH 5/6] forward pdfa to each exhibit when building the exhibit list --- docassemble/AssemblyLine/al_document.py | 1 + 1 file changed, 1 insertion(+) diff --git a/docassemble/AssemblyLine/al_document.py b/docassemble/AssemblyLine/al_document.py index 32ad00bd..959ad3eb 100644 --- a/docassemble/AssemblyLine/al_document.py +++ b/docassemble/AssemblyLine/al_document.py @@ -3251,6 +3251,7 @@ def as_pdf( page_number_font_size=page_number_font_size, page_number_offset_horizontal=page_number_offset_horizontal, page_number_offset_vertical=page_number_offset_vertical, + pdfa=pdfa, ) for exhibit in self ) From d26c2a0e42ece50ac63ec8c41df9cb0d5925bd86 Mon Sep 17 00:00:00 2001 From: rajeswari1301 Date: Thu, 27 Aug 2026 10:14:13 -0500 Subject: [PATCH 6/6] add tests for skipping broken exhibits and bundle docs --- docassemble/AssemblyLine/test_al_document.py | 41 ++++++++++++++++++++ 1 file changed, 41 insertions(+) diff --git a/docassemble/AssemblyLine/test_al_document.py b/docassemble/AssemblyLine/test_al_document.py index 168de8f8..d57a6920 100644 --- a/docassemble/AssemblyLine/test_al_document.py +++ b/docassemble/AssemblyLine/test_al_document.py @@ -181,6 +181,47 @@ def test_adjusts_for_multi_page_table_of_contents(self): self.assertEqual(self.exhibit.toc_page_number(toc_pages=2), 4) +class TestBundleSkipsBrokenDocument(unittest.TestCase): + def test_partial_failure_still_works(self): + good_pdf = FakePdf(filename="good.pdf", title="Good doc") + bundle = ALDocumentBundle( + "bundle", + elements=[FakeSingleDoc(None), FakeSingleDoc(good_pdf)], + title="Bundle title", + filename="bundle-output.pdf", + enabled=True, + ) + + result = bundle.as_pdf() + + self.assertIsNotNone(result) + + def test_all_broken_returns_none(self): + bundle = ALDocumentBundle( + "bundle", + elements=[FakeSingleDoc(None), FakeSingleDoc(None)], + title="Bundle title", + filename="bundle-output.pdf", + enabled=True, + ) + + result = bundle.as_pdf() + + self.assertIsNone(result) + + +class TestExhibitWithNoValidPages(unittest.TestCase): + def test_exhibit_with_no_pages_returns_none(self): + exhibit = ALExhibit("exhibit") + exhibit.title = "Test Exhibit" + exhibit.pages = [] + exhibit.start_page = 1 + + result = exhibit.as_pdf() + + self.assertIsNone(result) + + class test_aladdendum(unittest.TestCase): def test_safe_value(self): text_testcase1 = """Charged by my father with a very delicate mission, I repaired, towards the end of May, 1788, to the château of Ionis, situated a dozen leagues distant, in the lands lying between Angers and Saumur. I was twenty-two, and already practising the profession of lawyer, for which I experienced but slight inclination, although neither the study of business nor of argument had presented serious difficulties to me. Taking my youth into consideration, I was not esteemed without talent, and the standing of my father, a lawyer renowned in the locality, assured me a brilliant patronage in the future, in return for any paltry efforts I might make to be worthy of replacing him. But I would have preferred literature, a more dreamy life, a more independent and more individual use of my faculties, a responsibility less submissive to the passions and interests of others. As my family was well off, and I an only son, greatly spoiled and petted, I might have chosen my own career, but I would have thus afflicted my father, who took pride in his ability to direct me 4in the road which he had cleared in advance, and I loved him too tenderly to permit my instinct to outweigh his wishes.