diff --git a/docassemble/AssemblyLine/al_document.py b/docassemble/AssemblyLine/al_document.py index ce3da371..39ea497c 100644 --- a/docassemble/AssemblyLine/al_document.py +++ b/docassemble/AssemblyLine/al_document.py @@ -3887,6 +3887,7 @@ def as_pdf( add_cover_page: bool = True, filename: Optional[str] = None, append_matching_suffix: bool = True, + refresh_cover_page: Optional[bool] = None, ) -> Optional[DAFile]: """ Generates a PDF version of the exhibit, with optional features like Bates numbering or a cover page. @@ -3896,7 +3897,7 @@ def as_pdf( Note that these are keyword only parameters, not positional. Args: - refresh (bool): If True, forces the exhibit to refresh before generating the PDF. (unused, provided for signature compatibility). + refresh (bool): If True, rebuilds the cover page and bypasses the cached exhibit PDF. pdfa (bool): If True, the generated PDF will be in PDF/A format. add_page_numbers (bool): If True, apply Bates numbering starting from 'self.start_page'. page_number_prefix (str): If add_page_numbers is True, this gets added to the beginning on the bates number each page (e.g. `EX-`). @@ -3908,6 +3909,8 @@ def as_pdf( add_cover_page (bool): If True, prepend the exhibit with a cover page. filename (Optional[str]): Custom filename for the generated PDF. Default is "exhibits.pdf". append_matching_suffix (bool): If True, appends a suffix to the filename based on certain matching criteria. + refresh_cover_page (Optional[bool]): Whether to rebuild the cover page. Defaults to the value of `refresh`; + pass False when the caller has already refreshed it. Returns: DAFile: PDF representation of the exhibit. @@ -3925,7 +3928,7 @@ def as_pdf( if add_page_numbers: safe_key = safe_key + "_page_nums" - if hasattr(self._cache, safe_key): + if not refresh and hasattr(self._cache, safe_key): self._failed_during_processing = False return getattr(self._cache, safe_key) if not filename: @@ -3936,6 +3939,10 @@ def as_pdf( f"ALExhibit.as_pdf(): no valid pages for exhibit '{self.title}', skipping" ) return None + if refresh_cover_page is None: + refresh_cover_page = refresh + if refresh_cover_page and add_cover_page: + self.getattr_fresh("cover_page") try: if add_cover_page: concatenated_pages = pdf_concatenate( @@ -4184,6 +4191,7 @@ def as_pdf( page_number_offset_vertical: float = 15, toc_pages: int = 0, append_matching_suffix: bool = True, + refresh: bool = False, ) -> Optional[DAFile]: """ Compiles all exhibits in the list into a single PDF. @@ -4202,6 +4210,8 @@ def as_pdf( page_number_offset_vertical (float): The number of pixels that the bates page number is offset from the top / bottom of the page. toc_pages (int): Expected number of pages in the table of contents. append_matching_suffix (bool): If True, appends matching suffix to the filename. + refresh (bool): If True, recalculates auto-generated labels and page offsets, then rebuilds + exhibit cover pages and rendered files. Returns: DAFile: A single PDF containing all exhibits. @@ -4213,17 +4223,31 @@ def as_pdf( ${ exhibit_attachment.exhibits.as_pdf() } ``` """ + if refresh: + # List edits can leave old labels and page offsets defined, and the + # cover pages depend on both. + if self.auto_label: + self._update_labels() + self._update_page_numbers(toc_guess_pages=toc_pages) + elif self.include_table_of_contents and toc_pages != 1: + self._update_page_numbers(toc_guess_pages=toc_pages) if self.include_exhibit_cover_pages: + # Resolve every refreshed attachment before beginning expensive PDF + # rendering: dependency gathering can restart this method. for exhibit in self: - exhibit.cover_page - if self.include_table_of_contents and toc_pages != 1: - self._update_page_numbers(toc_guess_pages=toc_pages) + if refresh: + exhibit.getattr_fresh("cover_page") + else: + exhibit.cover_page if not page_number_prefix and self.bates_prefix: page_number_prefix = self.bates_prefix exhibit_pdfs = [ pdf for pdf in ( exhibit.as_pdf( + refresh=refresh, + # Already refreshed above + refresh_cover_page=False, add_cover_page=self.include_exhibit_cover_pages, add_page_numbers=add_page_numbers, page_number_prefix=page_number_prefix, @@ -4636,7 +4660,15 @@ def as_pdf( if len(self.exhibits): self._set_default_attributes() + # List edits can leave old labels, page offsets and attachment + # objects defined. Refresh their dependencies before rendering. + self.exhibits._update_page_numbers() + if self.exhibits.auto_label: + self.exhibits._update_labels() + if refresh and self.include_table_of_contents: + self.getattr_fresh("table_of_contents") exhibits_pdf = self.exhibits.as_pdf( + refresh=refresh, add_page_numbers=self.add_page_numbers, page_number_prefix=self.page_number_prefix, page_number_digits=self.page_number_digits, diff --git a/docassemble/AssemblyLine/test_al_document.py b/docassemble/AssemblyLine/test_al_document.py index f172c8e0..e422525e 100644 --- a/docassemble/AssemblyLine/test_al_document.py +++ b/docassemble/AssemblyLine/test_al_document.py @@ -20,6 +20,7 @@ ALDocumentBundle, ALExhibit, ALExhibitList, + ALExhibitDocument, _javascript_href, ) from docassemble.base.error import DAError @@ -686,5 +687,118 @@ def test_original_value_exceeds_trigger_with_newlines(self): ) # Original value exceeds the overflow_trigger, but preserve_newlines is True +class TestExhibitReviewRefresh(unittest.TestCase): + def test_changed_order_updates_labels_offsets_and_table_of_contents(self): + document = ALExhibitDocument("appendix", title="Appendix", filename="appendix") + for title in ["Renamed beta", "Gamma"]: + exhibit = document.exhibits.appendObject(title=title) + exhibit.pages = Mock() + exhibit.pages.num_pages.return_value = 1 + exhibit.label = "stale" + exhibit.start_page = 99 + document.exhibits.gathered = True + toc = Mock() + toc.num_pages.return_value = 1 + document.table_of_contents = toc + with ( + patch.object(document, "getattr_fresh", return_value=toc) as refresh, + patch.object( + document.exhibits, "as_pdf", return_value="exhibits" + ) as render, + patch( + __package__ + ".al_document.pdf_concatenate", return_value="combined" + ) as combine, + ): + self.assertEqual(document.as_pdf(), "combined") + self.assertEqual([item.label for item in document.exhibits], ["A", "B"]) + self.assertEqual([item.start_page for item in document.exhibits], [2, 4]) + refresh.assert_called_once_with("table_of_contents") + self.assertTrue(render.call_args.kwargs["refresh"]) + combine.assert_called_once() + + def test_refresh_bypasses_old_exhibit_pdf_and_regenerates_cover(self): + exhibit = ALExhibit("exhibit", title="Renamed exhibit", start_page=2) + exhibit.cover_page = "old cover" + exhibit._cache._file = "old rendered exhibit" + new_pdf = Mock() + with ( + patch.object( + exhibit, + "getattr_fresh", + side_effect=lambda name: setattr(exhibit, name, "new cover"), + ) as cover, + patch.object(exhibit, "ocr_pages", return_value=[Mock(ok=True)]), + patch( + __package__ + ".al_document.pdf_concatenate", return_value=new_pdf + ) as combine, + ): + self.assertEqual( + exhibit.as_pdf(refresh=False, add_page_numbers=False), + "old rendered exhibit", + ) + combine.assert_not_called() + self.assertIs(exhibit.as_pdf(refresh=True, add_page_numbers=False), new_pdf) + cover.assert_called_once_with("cover_page") + combine.assert_called_once() + self.assertEqual(combine.call_args.args[0], "new cover") + + def test_refresh_resolves_all_covers_before_rendering_any_pdf(self): + events = [] + exhibits = [] + for index in range(2): + exhibit = Mock() + exhibit.num_pages.return_value = 2 + exhibit.label = "stale" + exhibit.start_page = 99 + exhibit.getattr_fresh.side_effect = lambda name, i=index: events.append( + ("refresh", i) + ) + exhibit.as_pdf.side_effect = lambda i=index, **kwargs: ( + events.append(("render", i)) or "rendered" + ) + exhibits.append(exhibit) + collection = ALExhibitList( + "exhibits", + elements=exhibits, + gathered=True, + include_table_of_contents=False, + ) + with patch(__package__ + ".al_document.pdf_concatenate"): + collection.as_pdf(refresh=True) + self.assertEqual( + events, + [("refresh", 0), ("refresh", 1), ("render", 0), ("render", 1)], + ) + # A direct list-level refresh recalculates labels and offsets itself + self.assertEqual([item.label for item in exhibits], ["A", "B"]) + self.assertEqual([item.start_page for item in exhibits], [1, 4]) + # Covers refreshed in the first pass are not rebuilt again per exhibit + for exhibit in exhibits: + exhibit.getattr_fresh.assert_called_once_with("cover_page") + self.assertTrue(exhibit.as_pdf.call_args.kwargs["refresh"]) + self.assertIs(exhibit.as_pdf.call_args.kwargs["refresh_cover_page"], False) + + def test_refresh_cover_page_false_keeps_cover_but_bypasses_cache(self): + exhibit = ALExhibit("exhibit", title="Exhibit", start_page=2) + exhibit.cover_page = "refreshed cover" + exhibit._cache._file = "old rendered exhibit" + new_pdf = Mock() + with ( + patch.object(exhibit, "getattr_fresh") as cover, + patch.object(exhibit, "ocr_pages", return_value=[Mock(ok=True)]), + patch( + __package__ + ".al_document.pdf_concatenate", return_value=new_pdf + ) as combine, + ): + self.assertIs( + exhibit.as_pdf( + refresh=True, refresh_cover_page=False, add_page_numbers=False + ), + new_pdf, + ) + cover.assert_not_called() + self.assertEqual(combine.call_args.args[0], "refreshed cover") + + if __name__ == "__main__": unittest.main()