diff --git a/src/main/java/uk/gov/hmcts/reform/em/stitching/domain/Bundle.java b/src/main/java/uk/gov/hmcts/reform/em/stitching/domain/Bundle.java index 6c9afd367..2b74b6445 100644 --- a/src/main/java/uk/gov/hmcts/reform/em/stitching/domain/Bundle.java +++ b/src/main/java/uk/gov/hmcts/reform/em/stitching/domain/Bundle.java @@ -17,6 +17,7 @@ import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline; import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineNode; import org.hibernate.annotations.Fetch; import org.hibernate.annotations.FetchMode; import org.hibernate.annotations.Type; @@ -29,11 +30,11 @@ import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; -import java.util.function.Function; -import java.util.function.ToIntFunction; +import java.util.Set; import java.util.stream.Stream; @Entity @@ -276,7 +277,9 @@ public Integer getNumberOfSubtitles(SortableBundleItem container, if (container.getSortedDocuments().count() == documentBundledFilesRef.size()) { List docsToClose = new ArrayList<>(); int subtitles = extractDocumentOutlineStream(container, documentBundledFilesRef, docsToClose) - .mapToInt(getItemsFromOutline) + .mapToInt(outline -> Objects.isNull(outline) + ? 0 + : countNestedItems(outline, 0, new HashSet<>())) .sum(); closeDocuments(docsToClose); return subtitles; @@ -290,7 +293,9 @@ public List getSubtitles(SortableBundleItem container, Map docsToClose = new ArrayList<>(); List subtitles = extractDocumentOutlineStream(container, documentBundledFilesRef, docsToClose) - .map(getItemTitlesFromOutline) + .map(outline -> Objects.isNull(outline) + ? Collections.emptyList() + : extractNestedTitles(outline, 0, new HashSet<>())) .flatMap(List::stream) .toList(); closeDocuments(docsToClose); @@ -343,33 +348,43 @@ private PDDocumentOutline extractDocumentOutline( return null; } - @Transient - private final transient ToIntFunction getItemsFromOutline = outline -> { - if (Objects.isNull(outline)) { + private int countNestedItems(PDOutlineNode node, int depth, Set visited) { + if (depth > 10 || Objects.isNull(node)) { return 0; } int count = 0; - PDOutlineItem current = outline.getFirstChild(); + PDOutlineItem current = node.getFirstChild(); while (Objects.nonNull(current)) { - count++; + if (!visited.add(current)) { + break; + } + if (Objects.nonNull(current.getTitle())) { + count++; + } + count += countNestedItems(current, depth + 1, visited); current = current.getNextSibling(); } return count; - }; + } - @Transient - private final transient Function> getItemTitlesFromOutline = outline -> { - if (Objects.isNull(outline)) { - return Collections.emptyList(); - } + private List extractNestedTitles(PDOutlineNode node, int depth, Set visited) { List titles = new ArrayList<>(); - PDOutlineItem current = outline.getFirstChild(); + if (depth > 10 || Objects.isNull(node)) { + return titles; + } + PDOutlineItem current = node.getFirstChild(); while (Objects.nonNull(current)) { - titles.add(current.getTitle()); + if (!visited.add(current)) { + break; + } + if (Objects.nonNull(current.getTitle())) { + titles.add(current.getTitle()); + } + titles.addAll(extractNestedTitles(current, depth + 1, visited)); current = current.getNextSibling(); } return titles; - }; + } @Override @Transient diff --git a/src/main/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFUtility.java b/src/main/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFUtility.java index 8f1ef5831..0e8a004af 100644 --- a/src/main/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFUtility.java +++ b/src/main/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFUtility.java @@ -101,9 +101,9 @@ static float getStringWidth(String string, PDFont font, float fontSize) { } static void addSubtitleLink(PDDocument document, PDPage from, PDPage to, String text, float yyOffset, - PDType1Font pdType1Font) throws IOException { + PDType1Font pdType1Font, int depth) throws IOException { - float xxOffset = 45; + float xxOffset = 45 + (depth * 15f); int noOfLines = splitString(text, TableOfContents.SPACE_PER_SUBTITLE_LINE, pdType1Font, FONT_SIZE_SUBTITLES).length; PDAnnotationLink link = generateLink(to, from, xxOffset, yyOffset, noOfLines); diff --git a/src/main/java/uk/gov/hmcts/reform/em/stitching/pdf/TableOfContents.java b/src/main/java/uk/gov/hmcts/reform/em/stitching/pdf/TableOfContents.java index e1b338c8a..577cce622 100644 --- a/src/main/java/uk/gov/hmcts/reform/em/stitching/pdf/TableOfContents.java +++ b/src/main/java/uk/gov/hmcts/reform/em/stitching/pdf/TableOfContents.java @@ -5,6 +5,8 @@ import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.font.PDType1Font; import org.apache.pdfbox.pdmodel.font.Standard14Fonts; +import org.apache.pdfbox.pdmodel.interactive.action.PDActionGoTo; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDDestination; import org.apache.pdfbox.pdmodel.interactive.documentnavigation.destination.PDPageDestination; import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; import org.slf4j.Logger; @@ -15,13 +17,16 @@ import java.io.File; import java.io.IOException; import java.util.ArrayList; +import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; +import java.util.Set; import static java.lang.Math.max; import static org.springframework.util.ObjectUtils.isEmpty; import static uk.gov.hmcts.reform.em.stitching.pdf.PDFMerger.INDEX_PAGE; +import static uk.gov.hmcts.reform.em.stitching.pdf.PDFUtility.FONT_SIZE_SUBTITLES; import static uk.gov.hmcts.reform.em.stitching.pdf.PDFUtility.LINE_HEIGHT; import static uk.gov.hmcts.reform.em.stitching.pdf.PDFUtility.addCenterText; import static uk.gov.hmcts.reform.em.stitching.pdf.PDFUtility.addLink; @@ -45,6 +50,9 @@ public class TableOfContents { private final Logger logger = LoggerFactory.getLogger(TableOfContents.class); private static final int TITLE_XX_OFFSET = 50; + // Maximum allowed outline nesting depth + private static final int MAX_OUTLINE_DEPTH = 10; + public TableOfContents(PDDocument document, Bundle bundle, Map documents) throws IOException { this.document = document; this.bundle = bundle; @@ -79,18 +87,9 @@ public TableOfContents(PDDocument document, Bundle bundle, Map visited = new HashSet<>(); + processOutlineNode(documentTitle, pageNumber, firstOutlineItem, 0, visited); } - if (Objects.nonNull(sibling)) { - int noOfLines = splitString(sibling.getTitle(), SPACE_PER_SUBTITLE_LINE, - new PDType1Font(Standard14Fonts.FontName.HELVETICA), 12).length; - PDPage destination = new PDPage(); - try { - if (sibling.getDestination() instanceof PDPageDestination pdPageDestination) { - destination = document.getPage(pdPageDestination.retrievePageNumber() + pageNumber); - } - if (documentTitle != null && !documentTitle.equalsIgnoreCase(sibling.getTitle())) { - addSubtitleLink( - document, - getPage(), - destination, - sibling.getTitle(), - yyOffset, - new PDType1Font(Standard14Fonts.FontName.HELVETICA)); - numLinesAdded += noOfLines; + + endOfFolder = false; + } + + private void processOutlineNode(String documentTitle, int basePageNumber, + PDOutlineItem item, int depth, Set visited) { + if (Objects.isNull(item)) { + return; + } + + if (!visited.add(item)) { + logger.warn("Circular reference detected in PDF outline for document: {}", documentTitle); + return; + } + if (depth > MAX_OUTLINE_DEPTH) { + logger.warn("Outline depth limit exceeded for document: {}", documentTitle); + return; + } + + drawOutlineItem(documentTitle, basePageNumber, item, depth); + + if (Objects.nonNull(item.getFirstChild())) { + processOutlineNode(documentTitle, basePageNumber, item.getFirstChild(), depth + 1, visited); + } + + if (depth > 0 && Objects.nonNull(item.getNextSibling())) { + processOutlineNode(documentTitle, basePageNumber, item.getNextSibling(), depth, visited); + } + } + + private void drawOutlineItem(String documentTitle, int basePageNumber, PDOutlineItem item, int depth) { + if (Objects.isNull(item.getTitle())) { + return; + } + + if (Objects.nonNull(documentTitle) && documentTitle.equalsIgnoreCase(item.getTitle())) { + return; + } + + try { + float yyOffset = getVerticalOffset(); + int noOfLines = splitString(item.getTitle(), SPACE_PER_SUBTITLE_LINE, + new PDType1Font(Standard14Fonts.FontName.HELVETICA), FONT_SIZE_SUBTITLES).length; + + PDPage destination = getDestinationPage(item, basePageNumber, documentTitle); + + addSubtitleLink( + document, + getPage(), + destination, + item.getTitle(), + yyOffset, + new PDType1Font(Standard14Fonts.FontName.HELVETICA), + depth + ); + + numLinesAdded += noOfLines; + } catch (Exception e) { + logger.error("Error processing outline item: {}", item.getTitle(), e); + } + } + + private PDPage getDestinationPage(PDOutlineItem item, int basePageNumber, String documentTitle) + throws IOException { + PDDestination pdDestination = item.getDestination(); + + if (Objects.isNull(pdDestination) && item.getAction() instanceof PDActionGoTo actionGoTo) { + pdDestination = actionGoTo.getDestination(); + } + + if (pdDestination instanceof PDPageDestination pdPageDestination) { + int destPageNum = pdPageDestination.retrievePageNumber(); + if (destPageNum >= 0) { + int targetPageIndex = destPageNum + basePageNumber; + if (targetPageIndex < document.getNumberOfPages()) { + return document.getPage(targetPageIndex); + } else { + logger.warn("Calculated page index {} is out of bounds for document {}", + targetPageIndex, documentTitle); } - } catch (Exception e) { - logger.error("Error processing subtitles: {}", documentTitle, e); } + } else if (Objects.nonNull(pdDestination)) { + logger.debug("Unsupported destination type found: {}", pdDestination.getClass().getSimpleName()); } - endOfFolder = false; + + return new PDPage(); } public void addFolder(String title, int pageNumber) throws IOException { @@ -165,6 +225,17 @@ public void addFolder(String title, int pageNumber) throws IOException { endOfFolder = false; } + private void addSpaceAfterFolder() throws IOException { + if (endOfFolder) { + float yyOffset = getVerticalOffset(); + PDFText pdfText = new PDFText(" ", TITLE_XX_OFFSET, yyOffset, + new PDType1Font(Standard14Fonts.FontName.HELVETICA_BOLD), 13); + addText(document, getPage(), pdfText); + numLinesAdded += 1; + endOfFolder = false; + } + } + private float getVerticalOffset() { return TOP_MARGIN_OFFSET + ((numLinesAdded % NUM_LINES_PER_PAGE) * LINE_HEIGHT); } @@ -217,4 +288,4 @@ private int getNumberOfLinesForAllTitles() { public void setEndOfFolder(boolean value) { endOfFolder = value; } -} +} \ No newline at end of file diff --git a/src/test/java/uk/gov/hmcts/reform/em/stitching/domain/BundleTest.java b/src/test/java/uk/gov/hmcts/reform/em/stitching/domain/BundleTest.java index ec3b10634..856aef040 100644 --- a/src/test/java/uk/gov/hmcts/reform/em/stitching/domain/BundleTest.java +++ b/src/test/java/uk/gov/hmcts/reform/em/stitching/domain/BundleTest.java @@ -4,8 +4,14 @@ import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.apache.pdfbox.Loader; +import org.apache.pdfbox.pdmodel.PDDocument; +import org.apache.pdfbox.pdmodel.PDDocumentCatalog; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.mockito.MockedStatic; import uk.gov.hmcts.reform.em.stitching.domain.enumeration.ImageRendering; import uk.gov.hmcts.reform.em.stitching.domain.enumeration.ImageRenderingLocation; @@ -20,20 +26,23 @@ import static org.junit.jupiter.api.Assertions.assertEquals; import static org.junit.jupiter.api.Assertions.assertNotEquals; import static org.junit.jupiter.api.Assertions.assertNull; - +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.mockStatic; +import static org.mockito.Mockito.when; public class BundleTest { private static final String DEFAULT_DOCUMENT_ID = "/AAAAAAAAAA"; private final ObjectMapper mapper = new ObjectMapper(); private static final File FILE_1 = new File( - ClassLoader.getSystemResource("test-files/Potential_Energy_PDF.pdf").getPath() + ClassLoader.getSystemResource("test-files/Potential_Energy_PDF.pdf").getPath() ); private static final File FILE_2 = new File( - ClassLoader.getSystemResource("test-files/TEST_INPUT_FILE.pdf").getPath() + ClassLoader.getSystemResource("test-files/TEST_INPUT_FILE.pdf").getPath() ); private static final File FILE_3 = new File( - ClassLoader.getSystemResource("test-files/bundle.json").getPath() + ClassLoader.getSystemResource("test-files/bundle.json").getPath() ); @BeforeEach @@ -284,9 +293,9 @@ void toStringTest() { Bundle bundle = new Bundle(); String toString = bundle.toString(); assertEquals("Bundle(id=null, bundleTitle=null, " - + "description=null, stitchedDocumentURI=null, stitchStatus=null, " - + "fileName=null, hasTableOfContents=false, " - + "hasCoversheets=false, hasFolderCoversheets=false)", toString); + + "description=null, stitchedDocumentURI=null, stitchStatus=null, " + + "fileName=null, hasTableOfContents=false, " + + "hasCoversheets=false, hasFolderCoversheets=false)", toString); } private static BundleDocument getBundleDocument(int index) { @@ -319,9 +328,138 @@ void testNumberOfSubtitlesInPDF() { assertEquals(8,numberOfSubtitle); } + @Test + void testGetSubtitlesWithCircularReferenceSafelyExits() throws IOException { + Bundle bundle = getTestBundle(); + bundle.getDocuments().clear(); + + BundleDocument bd = new BundleDocument(); + bd.setDocTitle("Mocked Doc"); + bundle.getDocuments().add(bd); + + HashMap docs = new HashMap<>(); + docs.put(bd, FILE_1); + + PDDocument mockDoc = mock(PDDocument.class); + PDDocumentCatalog mockCatalog = mock(PDDocumentCatalog.class); + PDDocumentOutline mockOutline = mock(PDDocumentOutline.class); + PDOutlineItem item1 = mock(PDOutlineItem.class); + PDOutlineItem item2 = mock(PDOutlineItem.class); + + when(mockDoc.getDocumentCatalog()).thenReturn(mockCatalog); + when(mockCatalog.getDocumentOutline()).thenReturn(mockOutline); + when(mockOutline.getFirstChild()).thenReturn(item1); + + when(item1.getTitle()).thenReturn("Item 1"); + when(item1.getNextSibling()).thenReturn(item2); + + when(item2.getTitle()).thenReturn("Item 2"); + when(item2.getNextSibling()).thenReturn(item1); + + try (MockedStatic loader = mockStatic(Loader.class)) { + loader.when(() -> Loader.loadPDF(FILE_1)).thenReturn(mockDoc); + + List subtitles = bundle.getSubtitles(bundle, docs); + assertEquals(2, subtitles.size(), "Should extract exact items before circular termination"); + assertTrue(subtitles.contains("Item 1")); + assertTrue(subtitles.contains("Item 2")); + + int count = bundle.getNumberOfSubtitles(bundle, docs); + assertEquals(2, count); + } + } + + @Test + void testGetSubtitlesWithExtremeDepthLimit() { + Bundle bundle = getTestBundle(); + bundle.getDocuments().clear(); + + BundleDocument bd = new BundleDocument(); + bd.setDocTitle("Mocked Doc"); + bundle.getDocuments().add(bd); + + HashMap docs = new HashMap<>(); + docs.put(bd, FILE_1); + + PDDocument mockDoc = mock(PDDocument.class); + PDDocumentCatalog mockCatalog = mock(PDDocumentCatalog.class); + PDDocumentOutline mockOutline = mock(PDDocumentOutline.class); + + when(mockDoc.getDocumentCatalog()).thenReturn(mockCatalog); + when(mockCatalog.getDocumentOutline()).thenReturn(mockOutline); + + PDOutlineItem firstItem = mock(PDOutlineItem.class); + when(mockOutline.getFirstChild()).thenReturn(firstItem); + when(firstItem.getTitle()).thenReturn("Level 0"); + + PDOutlineItem current = firstItem; + for (int i = 1; i <= 20; i++) { + PDOutlineItem child = mock(PDOutlineItem.class); + when(child.getTitle()).thenReturn("Level " + i); + when(current.getFirstChild()).thenReturn(child); + current = child; + } + + try (MockedStatic loader = mockStatic(Loader.class)) { + loader.when(() -> Loader.loadPDF(FILE_1)).thenReturn(mockDoc); + + List subtitles = bundle.getSubtitles(bundle, docs); + assertEquals(11, subtitles.size()); + assertEquals("Level 0", subtitles.getFirst()); + assertEquals("Level 10", subtitles.get(10)); + + int count = bundle.getNumberOfSubtitles(bundle, docs); + assertEquals(11, count); + } + } + + @Test + void testGetSubtitlesIgnoresNullTitles() { + Bundle bundle = getTestBundle(); + bundle.getDocuments().clear(); + + BundleDocument bd = new BundleDocument(); + bd.setDocTitle("Mocked Doc"); + bundle.getDocuments().add(bd); + + HashMap docs = new HashMap<>(); + docs.put(bd, FILE_1); + + PDDocument mockDoc = mock(PDDocument.class); + PDDocumentCatalog mockCatalog = mock(PDDocumentCatalog.class); + PDDocumentOutline mockOutline = mock(PDDocumentOutline.class); + PDOutlineItem item1 = mock(PDOutlineItem.class); + PDOutlineItem item2 = mock(PDOutlineItem.class); + PDOutlineItem item3 = mock(PDOutlineItem.class); + + when(mockDoc.getDocumentCatalog()).thenReturn(mockCatalog); + when(mockCatalog.getDocumentOutline()).thenReturn(mockOutline); + when(mockOutline.getFirstChild()).thenReturn(item1); + + when(item1.getTitle()).thenReturn("Valid Title 1"); + when(item1.getNextSibling()).thenReturn(item2); + + when(item2.getTitle()).thenReturn(null); // Structural metadata node + when(item2.getNextSibling()).thenReturn(item3); + + when(item3.getTitle()).thenReturn("Valid Title 2"); + + try (MockedStatic loader = mockStatic(Loader.class)) { + loader.when(() -> Loader.loadPDF(FILE_1)).thenReturn(mockDoc); + + List subtitles = bundle.getSubtitles(bundle, docs); + assertEquals(2, subtitles.size()); + assertEquals("Valid Title 1", subtitles.get(0)); + assertEquals("Valid Title 2", subtitles.get(1)); + + int count = bundle.getNumberOfSubtitles(bundle, docs); + assertEquals(2, count); + } + } + public static Bundle getTestBundleForFailure() throws IOException { ObjectMapper mapper = new ObjectMapper() - .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + .configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); return mapper.readValue(FILE_3, Bundle.class); } -} +} \ No newline at end of file diff --git a/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFMergerTest.java b/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFMergerTest.java index 6791f97a7..765c3482d 100644 --- a/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFMergerTest.java +++ b/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFMergerTest.java @@ -8,6 +8,8 @@ import org.apache.pdfbox.pdmodel.PDDocumentCatalog; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.documentinterchange.logicalstructure.PDStructureTreeRoot; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDDocumentOutline; +import org.apache.pdfbox.pdmodel.interactive.documentnavigation.outline.PDOutlineItem; import org.apache.pdfbox.text.PDFTextStripper; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; @@ -145,7 +147,7 @@ void mergeWithTableOfContentsAndCoverPage() throws IOException { final int numberOfPagesInTableOfContents = 1; final int numberOfPagesCoverPage = 1; final int expectedPages = doc1.getNumberOfPages() + doc2.getNumberOfPages() - + numberOfPagesInTableOfContents + numberOfPagesCoverPage; + + numberOfPagesInTableOfContents + numberOfPagesCoverPage; doc1.close(); doc2.close(); @@ -171,7 +173,7 @@ void mergeWithTableOfContentsWithMultilineTitlesAndCoverPage() throws IOExceptio final int numberOfPagesInTableOfContents = 1; final int numberOfPagesCoverPage = 1; final int expectedPages = doc1.getNumberOfPages() + doc2.getNumberOfPages() - + numberOfPagesInTableOfContents + numberOfPagesCoverPage; + + numberOfPagesInTableOfContents + numberOfPagesCoverPage; doc1.close(); doc2.close(); @@ -513,8 +515,8 @@ void throwNiceException() { ); assertEquals( - "Error processing, document title: Bundle Doc 1, file name: TestExcelConversion.xlsx", - exception.getMessage() + "Error processing, document title: Bundle Doc 1, file name: TestExcelConversion.xlsx", + exception.getMessage() ); } @@ -587,7 +589,7 @@ void mergeWithTableOfContentsWithUnevenDocumentsAndBundleDocs() throws IOExcepti @Test void mergeWithTableOfContentsbundleWithMultilineDocumentTitlesWithUnevenDocumentsAndBundleDocs() - throws IOException { + throws IOException { HashMap documents2; Bundle newBundle = createFlatTestBundleWithMultilineDocumentTitlesWithAdditionalDoc(); @@ -645,7 +647,7 @@ void specialCharactersInIndexPage() throws IOException { File merged = merger.merge(newBundle, newDocuments2, null); try (PDDocument mergedDocument = Loader.loadPDF(merged)) { assertEquals("ąćęłńóśźż", - mergedDocument.getDocumentCatalog().getDocumentOutline().getFirstChild().getTitle()); + mergedDocument.getDocumentCatalog().getDocumentOutline().getFirstChild().getTitle()); } } @@ -795,4 +797,46 @@ void retriesAppendOnIndexOutOfBoundsException() throws IOException { } } } + + @Test + void testMergeWithDeeplyNestedOutline() throws IOException { + File deepPdf = File.createTempFile("deep_outline", ".pdf"); + deepPdf.deleteOnExit(); + + try (PDDocument doc = new PDDocument()) { + PDPage page = new PDPage(); + doc.addPage(page); + PDDocumentOutline outline = new PDDocumentOutline(); + doc.getDocumentCatalog().setDocumentOutline(outline); + + PDOutlineItem parent = new PDOutlineItem(); + parent.setTitle("Level 0"); + outline.addLast(parent); + + for (int i = 1; i <= 20; i++) { + PDOutlineItem child = new PDOutlineItem(); + child.setTitle("Level " + i); + parent.addLast(child); + parent = child; + } + doc.save(deepPdf); + } + + Bundle deepBundle = createFlatTestBundle(); + deepBundle.setHasTableOfContents(true); + deepBundle.getDocuments().getFirst().setDocTitle("Deep Document"); + + HashMap deepDocs = new HashMap<>(); + deepDocs.put(deepBundle.getDocuments().get(0), deepPdf); + deepDocs.put(deepBundle.getDocuments().get(1), FILE_2); + + PDFMerger merger = new PDFMerger(); + + File merged = merger.merge(deepBundle, deepDocs, null); + + assertNotNull(merged); + try (PDDocument mergedDoc = Loader.loadPDF(merged)) { + assertTrue(mergedDoc.getNumberOfPages() > 2); + } + } } \ No newline at end of file diff --git a/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFUtilityTest.java b/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFUtilityTest.java index dfd525bf6..191951ce7 100644 --- a/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFUtilityTest.java +++ b/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/PDFUtilityTest.java @@ -154,7 +154,7 @@ void addSubtitleLinkAddsLinkToPage() throws IOException { document.addPage(to); PDFUtility.addSubtitleLink(document, from, to, "Subtitle Link", 700, - new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + new PDType1Font(Standard14Fonts.FontName.HELVETICA), 0); String text = new PDFTextStripper().getText(document); assertTrue(text.contains("Subtitle Link")); @@ -171,7 +171,7 @@ void addSubtitleLinkHandlesNullText() throws IOException { document.addPage(to); PDFUtility.addSubtitleLink(document, from, to, null, 700, - new PDType1Font(Standard14Fonts.FontName.HELVETICA)); + new PDType1Font(Standard14Fonts.FontName.HELVETICA), 0); String text = new PDFTextStripper().getText(document); assertTrue(text.isEmpty()); diff --git a/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/TableOfContentsTest.java b/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/TableOfContentsTest.java index df44a2f36..67afe211f 100644 --- a/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/TableOfContentsTest.java +++ b/src/test/java/uk/gov/hmcts/reform/em/stitching/pdf/TableOfContentsTest.java @@ -42,7 +42,6 @@ import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; - @ExtendWith(MockitoExtension.class) class TableOfContentsTest { @@ -78,7 +77,8 @@ void setUp() { anyInt())).thenAnswer(invocation -> null); mockedPdfUtility.when(() -> PDFUtility.addCenterText(any(PDDocument.class), any(PDPage.class), anyString(), anyInt())).thenAnswer(invocation -> null); - mockedPdfUtility.when(() -> PDFUtility.addSubtitleLink(any(), any(), any(), anyString(), anyFloat(), any())) + mockedPdfUtility.when(() -> PDFUtility.addSubtitleLink(any(), any(), any(), anyString(), + anyFloat(), any(), anyInt())) .thenAnswer(invocation -> null); when(mockBundle.getDescription()).thenReturn("Default Bundle Description"); @@ -112,7 +112,6 @@ private void mockSpecificSplitString(String textToMatch, int spacePerLine, float .thenReturn(resultLines); } - private void setupBundleForLineCounting(String description, List docs, List subtitles) { when(mockBundle.getDescription()).thenReturn(description); mockSpecificSplitString(description, TableOfContents.SPACE_PER_LINE, 12f, @@ -264,8 +263,8 @@ void addDocumentWithOutlineWhenPageRetrievalFails() throws IOException { toc.addDocumentWithOutline("Main Doc Title", 10, mockSibling); - mockedPdfUtility.verify(() -> PDFUtility - .addSubtitleLink(any(), any(), any(), anyString(), anyFloat(), any()), never()); + mockedPdfUtility.verify(() -> PDFUtility.addSubtitleLink( + any(), any(), any(), any(), anyFloat(), any(), anyInt()), never()); } @Test @@ -299,7 +298,8 @@ void addDocumentWithOutlineWithValidSibling() throws IOException { eq(document.getPage(requiredPageIndex)), eq(siblingTitle), anyFloat(), - any(PDType1Font.class) + any(PDType1Font.class), + eq(0) )); } @@ -309,17 +309,12 @@ void addDocumentWhenDocumentTitleEqualsSiblingTitle() throws IOException { final TableOfContents toc = new TableOfContents(document, mockBundle, documentsMap); PDOutlineItem mockSibling = mock(PDOutlineItem.class); - PDPageDestination mockPageDest = mock(PDPageDestination.class); - final int siblingDestPageIdx = 5; - when(mockSibling.getTitle()).thenReturn("Same Title"); - when(mockSibling.getDestination()).thenReturn(mockPageDest); - when(mockPageDest.retrievePageNumber()).thenReturn(2); - toc.addDocumentWithOutline("Same Title", siblingDestPageIdx, mockSibling); + toc.addDocumentWithOutline("Same Title", 5, mockSibling); - mockedPdfUtility.verify(() -> - PDFUtility.addSubtitleLink(any(), any(), any(), anyString(), anyFloat(), any()), never()); + mockedPdfUtility.verify(() -> PDFUtility.addSubtitleLink( + any(), any(), any(), any(), anyFloat(), any(), anyInt()), never()); } @Test @@ -327,10 +322,10 @@ void addDocumentWithOutlineWithNullSibling() throws IOException { setupBundleForLineCounting("Desc", Collections.emptyList(), Collections.emptyList()); TableOfContents toc = new TableOfContents(document, mockBundle, documentsMap); toc.addDocumentWithOutline("Doc Title", 1, null); - mockedPdfUtility.verify(() -> - PDFUtility.addSubtitleLink(any(), any(), any(), anyString(), anyFloat(), any()), never()); - } + mockedPdfUtility.verify(() -> PDFUtility.addSubtitleLink( + any(), any(), any(), any(), anyFloat(), any(), anyInt()), never()); + } @Test void getPageCyclesThroughPages() throws IOException { @@ -379,34 +374,102 @@ void addDocumentWithOutlineWhenEndOfFolder() throws IOException { toc.setEndOfFolder(true); - String documentTitle = "Doc After Folder"; - int pageNumber = 5; - PDOutlineItem mockSibling = null; - - mockSpecificSplitString(documentTitle, - TableOfContents.SPACE_PER_TITLE_LINE, 12f, new String[]{documentTitle}); - - toc.addDocumentWithOutline(documentTitle, pageNumber, mockSibling); + toc.addDocumentWithOutline("Doc After Folder", 5, null); mockedPdfUtility.verify(() -> PDFUtility.addText( - eq(document), - eq(toc.getPage()), - argThat(isEndOfFolderSpaceLine()) - ), times(1)); + eq(document), eq(toc.getPage()), argThat(isEndOfFolderSpaceLine())), times(1)); String docAfterReset = "DocAfterReset"; - mockSpecificSplitString(docAfterReset, - TableOfContents.SPACE_PER_TITLE_LINE, 12f, new String[]{docAfterReset}); - int nextPageForLink = pageNumber + 1; - while (document.getNumberOfPages() <= nextPageForLink) { + mockSpecificSplitString(docAfterReset, TableOfContents.SPACE_PER_TITLE_LINE, 12f, + new String[]{docAfterReset}); + int nextPage = 6; + while (document.getNumberOfPages() <= nextPage) { document.addPage(new PDPage()); } - toc.addDocument(docAfterReset, nextPageForLink, 1); + toc.addDocument(docAfterReset, nextPage, 1); mockedPdfUtility.verify(() -> PDFUtility.addText(any(PDDocument.class), any(PDPage.class), argThat(isEndOfFolderSpaceLine())), times(1)); } + @Test + void testProcessOutlineNodeDepthLimit() throws IOException { + setupBundleForLineCounting("Desc", Collections.emptyList(), Collections.emptyList()); + final TableOfContents toc = new TableOfContents(document, mockBundle, documentsMap); + + PDOutlineItem level0 = mock(PDOutlineItem.class); + lenient().when(level0.getTitle()).thenReturn("Level 0"); + + PDOutlineItem level0Sibling = mock(PDOutlineItem.class); + lenient().when(level0Sibling.getTitle()).thenReturn("Level 0 Sibling"); + lenient().when(level0.getNextSibling()).thenReturn(level0Sibling); + + PDOutlineItem current = level0; + for (int i = 1; i <= 20; i++) { + PDOutlineItem child = mock(PDOutlineItem.class); + lenient().when(child.getTitle()).thenReturn("Level " + i); + lenient().when(current.getFirstChild()).thenReturn(child); + current = child; + } + + toc.addDocumentWithOutline("Main Doc", 1, level0); + + mockedPdfUtility.verify(() -> PDFUtility.addSubtitleLink( + any(), any(), any(), any(), anyFloat(), any(), anyInt()), times(11)); + } + + @Test + void testProcessOutlineNodeCircularReference() throws IOException { + setupBundleForLineCounting("Desc", Collections.emptyList(), Collections.emptyList()); + final TableOfContents toc = new TableOfContents(document, mockBundle, documentsMap); + + PDOutlineItem root = mock(PDOutlineItem.class); + when(root.getTitle()).thenReturn("Root"); + + PDOutlineItem item1 = mock(PDOutlineItem.class); + PDOutlineItem item2 = mock(PDOutlineItem.class); + + when(root.getFirstChild()).thenReturn(item1); + + when(item1.getTitle()).thenReturn("Item 1"); + when(item1.getNextSibling()).thenReturn(item2); + when(item2.getTitle()).thenReturn("Item 2"); + when(item2.getNextSibling()).thenReturn(item1); + + toc.addDocumentWithOutline("Main Doc", 1, root); + + mockedPdfUtility.verify(() -> PDFUtility.addSubtitleLink( + any(), any(), any(), any(), anyFloat(), any(), anyInt()), times(3)); + } + + @Test + void testProcessOutlineNodeNullTitle() throws IOException { + setupBundleForLineCounting("Desc", Collections.emptyList(), Collections.emptyList()); + final TableOfContents toc = new TableOfContents(document, mockBundle, documentsMap); + + PDOutlineItem root = mock(PDOutlineItem.class); + when(root.getTitle()).thenReturn("Root"); + + PDOutlineItem item1 = mock(PDOutlineItem.class); + PDOutlineItem item2 = mock(PDOutlineItem.class); + PDOutlineItem item3 = mock(PDOutlineItem.class); + + when(root.getFirstChild()).thenReturn(item1); + + when(item1.getTitle()).thenReturn("Valid Title 1"); + when(item1.getNextSibling()).thenReturn(item2); + when(item2.getTitle()).thenReturn(null); + when(item2.getNextSibling()).thenReturn(item3); + when(item3.getTitle()).thenReturn("Valid Title 2"); + + toc.addDocumentWithOutline("Main Doc", 1, root); + + mockedPdfUtility.verify(() -> PDFUtility.addSubtitleLink( + any(), any(), any(), any(), anyFloat(), any(), anyInt()), times(3)); + + mockedPdfUtility.verify(() -> PDFUtility.splitString( + eq(null), anyInt(), any(), anyFloat()), never()); + } private ArgumentMatcher isEndOfFolderSpaceLine() { return pdfText -> " ".equals(pdfText.getText())