diff --git a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/RemoveHeaderFooterController.java b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/RemoveHeaderFooterController.java index a6549152b..40100a7b9 100644 --- a/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/RemoveHeaderFooterController.java +++ b/stirling-pdf/src/main/java/stirling/software/SPDF/controller/api/RemoveHeaderFooterController.java @@ -1,16 +1,23 @@ package stirling.software.SPDF.controller.api; import java.io.IOException; +import java.io.OutputStream; +import java.lang.reflect.Constructor; import java.util.ArrayList; import java.util.Collections; import java.util.List; -import org.apache.pdfbox.multipdf.LayerUtility; +import org.apache.pdfbox.cos.COSArray; +import org.apache.pdfbox.cos.COSBase; +import org.apache.pdfbox.cos.COSDictionary; +import org.apache.pdfbox.cos.COSStream; +import org.apache.pdfbox.multipdf.PDFCloneUtility; import org.apache.pdfbox.pdmodel.PDDocument; import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode; import org.apache.pdfbox.pdmodel.common.PDRectangle; +import org.apache.pdfbox.pdmodel.common.PDStream; import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.ModelAttribute; @@ -48,15 +55,12 @@ public class RemoveHeaderFooterController { throws IOException { MultipartFile pdfFile = form.getFileInput(); - + PDDocument sourceDoc = pdfDocumentFactory.load(pdfFile); + PDDocument newDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc); String pagesToDelete = form.getPages(); List pagesToRemove = new ArrayList<>(); - PDDocument sourceDoc = pdfDocumentFactory.load(pdfFile); - PDDocument newDoc = new PDDocument(); - LayerUtility layerUtility = new LayerUtility(newDoc); - String sufix; - // Respond with a message + if (form.isRemoveHeader()) { if (form.isRemoveFooter()) { sufix = "_removed_header_footer.pdf"; @@ -64,8 +68,7 @@ public class RemoveHeaderFooterController { } else if (form.isRemoveFooter()) { sufix = "_removed_footer.pdf"; } else { - return ResponseEntity.badRequest() - .body("No header or footer removal options selected".getBytes()); + throw new IllegalArgumentException("Header and/or footer removal must be selected"); } if (pagesToDelete == null || pagesToDelete.isEmpty()) { @@ -81,40 +84,68 @@ public class RemoveHeaderFooterController { Collections.sort(pagesToRemove); } + // Used to clone the old PDF document to a new one, preserving the original document + // structure and properties + PDFCloneUtility cloner; + try { + Constructor constructor = + PDFCloneUtility.class.getDeclaredConstructor(PDDocument.class); + // Enable access to protected constructor + constructor.setAccessible(true); + cloner = constructor.newInstance(newDoc); + } catch (Exception e) { + throw new RuntimeException("Failed to clone the old PDF document to a new one: ", e); + } + for (int pageIndex = 0; pageIndex < sourceDoc.getNumberOfPages(); pageIndex++) { PDPage sourcePage = sourceDoc.getPage(pageIndex); - PDRectangle mediaBox = sourcePage.getMediaBox(); - - PDPage newPage = new PDPage(mediaBox); + PDPage newPage = + new PDPage( + (COSDictionary) cloner.cloneForNewDocument(sourcePage.getCOSObject())); newDoc.addPage(newPage); - try (PDPageContentStream cs = - new PDPageContentStream(newDoc, newPage, AppendMode.OVERWRITE, true, true)) { - PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDoc, pageIndex); + if (pagesToRemove.contains(pageIndex)) { + PDRectangle mediaBox = newPage.getMediaBox(); + Float[][] zones = getRemovalZonesForPage(form, newPage); - // Save the current graphics state to restore later - cs.saveGraphicsState(); + // Extract original content streams + List oldStreams = extractContentStreams(newPage); + + newPage.setContents(new ArrayList<>()); + + COSStream combinedStream = new COSStream(); + try (OutputStream out = combinedStream.createOutputStream()) { + for (PDStream stream : oldStreams) { + out.write(stream.toByteArray()); + } + } + + PDFormXObject formXObject = new PDFormXObject(combinedStream); + formXObject.setResources(newPage.getResources()); + formXObject.setBBox(mediaBox); + formXObject.setFormType(1); // Required form type + + try (PDPageContentStream cs = + new PDPageContentStream( + newDoc, newPage, AppendMode.OVERWRITE, true, true)) { + // Save the current graphics state to restore later + cs.saveGraphicsState(); - if (pagesToRemove.contains(pageIndex)) { - Float[][] zones = getRemovalZonesForPage(form, sourcePage); if (zones != null && zones.length > 0) { cs.addRect(0, 0, mediaBox.getWidth(), mediaBox.getHeight()); // Add rectangles for each zone to remove (header/footer areas) // These will be subtracted from the base rectangle using even-odd clipping // rule for (Float[] zone : zones) { - if (zone != null && zone.length == 4) { - cs.addRect(zone[0], zone[1], zone[2], zone[3]); - } + cs.addRect(zone[0], zone[1], zone[2], zone[3]); } - cs.clipEvenOdd(); } - } - cs.drawForm(formXObject); - // Restore the graphics state to ensure the clipping is applied correctly - cs.restoreGraphicsState(); + cs.drawForm(formXObject); + // Restore the graphics state to ensure the clipping is applied correctly + cs.restoreGraphicsState(); + } } } return WebResponseUtils.pdfDocToWebResponse( @@ -124,6 +155,23 @@ public class RemoveHeaderFooterController { + sufix); } + private List extractContentStreams(PDPage page) { + List streams = new ArrayList<>(); + COSBase contents = page.getCOSObject().getDictionaryObject("Contents"); + + if (contents instanceof COSStream cosStream) { + streams.add(new PDStream(cosStream)); + } else if (contents instanceof COSArray cosArray) { + for (int i = 0; i < cosArray.size(); i++) { + COSBase item = cosArray.get(i); + if (item instanceof COSStream itemStream) { + streams.add(new PDStream(itemStream)); + } + } + } + return streams; + } + /** * Builds the zones for the header and footer removal based on the form data and the page. * @@ -132,30 +180,60 @@ public class RemoveHeaderFooterController { * @return A 2D array of Float representing the zones to remove. */ private Float[][] getRemovalZonesForPage(RemoveHeaderFooterForm form, PDPage page) { - float w = page.getMediaBox().getWidth(); - float h = page.getMediaBox().getHeight(); - Float[][] zones = null; - + PDRectangle mediaBox = page.getMediaBox(); + float w = mediaBox.getWidth(); + float h = mediaBox.getHeight(); + int rotation = page.getRotation(); boolean removeHeader = form.isRemoveHeader(); boolean removeFooter = form.isRemoveFooter(); - zones = new Float[removeHeader && removeFooter ? 2 : 1][]; + Float[][] zones = new Float[removeHeader && removeFooter ? 2 : 1][]; + int zoneIdx = 0; + if (removeHeader) { Float headerH = form.getHeaderMargin(); if (headerH == -1) { headerH = form.getHeaderCustomValue(); // Default value if 'custom' is specified } - zones[0] = new Float[] {0f, h - headerH, w, headerH}; + + Float[] rawZone; + if (rotation == 90 || rotation == 270) { + rawZone = new Float[] {0f, w - headerH, h, headerH}; + } else { + rawZone = new Float[] {0f, h - headerH, w, headerH}; + } + zones[zoneIdx++] = rotateZone(rawZone, mediaBox, rotation); } + if (removeFooter) { Float footerH = form.getFooterMargin(); if (footerH == -1) { footerH = form.getFooterCustomValue(); // Default value if 'custom' is specified } - zones[zones[0] == null ? 0 : 1] = new Float[] {0f, 0f, w, footerH}; - } + Float[] rawZone; + if (rotation == 90 || rotation == 270) { + rawZone = new Float[] {0f, 0f, h, footerH}; + } else { + rawZone = new Float[] {0f, 0f, w, footerH}; + } + zones[zoneIdx] = rotateZone(rawZone, mediaBox, rotation); + } return zones; } + + private Float[] rotateZone(Float[] zone, PDRectangle mediaBox, int rotation) { + float x = zone[0]; + float y = zone[1]; + float w = zone[2]; + float h = zone[3]; + return switch (rotation) { + case 90 -> new Float[] {mediaBox.getWidth() - y - h, x, h, w}; + case 180 -> + new Float[] {mediaBox.getWidth() - x - w, mediaBox.getHeight() - y - h, w, h}; + case 270 -> new Float[] {y, mediaBox.getHeight() - x - w, h, w}; + default -> new Float[] {x, y, w, h}; + }; + } } diff --git a/stirling-pdf/src/main/resources/static/js/pages/remove-header-footer.js b/stirling-pdf/src/main/resources/static/js/pages/remove-header-footer.js index 58c78362e..d4f600e0e 100644 --- a/stirling-pdf/src/main/resources/static/js/pages/remove-header-footer.js +++ b/stirling-pdf/src/main/resources/static/js/pages/remove-header-footer.js @@ -100,30 +100,17 @@ document.addEventListener('DOMContentLoaded', () => { } const page = await loadedPdf.getPage(firstPageNumber); - const canvas = document.createElement("canvas"); + const viewport = page.getViewport({ scale: currentScale }); + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d'); + + canvas.height = viewport.height; + canvas.width = viewport.width; - let scale; - if (page.rotate === 90 || page.rotate === 270) { - canvas.width = page.view[3]; - canvas.height = page.view[2]; - scale = canvas.height / page.view[2]; - } else { - canvas.width = page.view[2]; - canvas.height = page.view[3]; - scale = canvas.height / page.view[3]; - } + await page.render({ canvasContext: ctx, viewport: viewport }).promise; - const ctx = canvas.getContext("2d"); - - const renderContext = { - canvasContext: ctx, - viewport: page.getViewport({ scale: 1 }), - }; - - await page.render(renderContext).promise; - - const headerY = mainHeaderMargin * scale; - const footerY = canvas.height - mainFooterMargin * scale; + const headerY = mainHeaderMargin * currentScale; + const footerY = canvas.height - mainFooterMargin * currentScale; if (mainHeaderCheckbox.checked) { drawMarginLine(ctx, headerY, canvas.width, "header"); @@ -180,20 +167,22 @@ document.addEventListener('DOMContentLoaded', () => { const viewport = page.getViewport({ scale: currentScale }); const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); + canvas.height = viewport.height; canvas.width = viewport.width; canvas.style.display = 'block'; canvas.style.margin = '0 auto 16px auto'; + viewer.appendChild(canvas); + page.render({ canvasContext: ctx, viewport: viewport }).promise.then(() => { if (idx === 0) { pageNumberInput.value = pagesToShow[0]; numPagesLabel.textContent = `/ ${pagesToShow.length}`; } - const scale = canvas.height / page.view[3]; - const headerY = headerMargin * scale; - const footerY = canvas.height - footerMargin * scale; + const headerY = headerMargin * currentScale; + const footerY = canvas.height - footerMargin * currentScale; ctx.strokeStyle = "red"; ctx.lineWidth = 2; ctx.setLineDash([5, 5]);