Code refactoring

Backend:
- Now using createNewDocumentBasedOnOldDocument
- Replaced LayerUtility importPageAsForm with manual
cloning using PDFCloneUtility to avoid rotation issues
- Wrapped original content streams into PDFormXObject
- Updated getRemovalZonesForPage to correctly handle page rotation
Frontend:
- Using viewport instead of manual scale to handle rotation
This commit is contained in:
Tomás Bernardino 2025-06-27 14:55:33 +01:00 committed by Tomás Bernardino
parent dc1050cee5
commit e30756491b
2 changed files with 127 additions and 60 deletions

View File

@ -1,16 +1,23 @@
package stirling.software.SPDF.controller.api; package stirling.software.SPDF.controller.api;
import java.io.IOException; import java.io.IOException;
import java.io.OutputStream;
import java.lang.reflect.Constructor;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; 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.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage; import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream; import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode; import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.common.PDRectangle; import org.apache.pdfbox.pdmodel.common.PDRectangle;
import org.apache.pdfbox.pdmodel.common.PDStream;
import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject; import org.apache.pdfbox.pdmodel.graphics.form.PDFormXObject;
import org.springframework.http.ResponseEntity; import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ModelAttribute; import org.springframework.web.bind.annotation.ModelAttribute;
@ -48,15 +55,12 @@ public class RemoveHeaderFooterController {
throws IOException { throws IOException {
MultipartFile pdfFile = form.getFileInput(); MultipartFile pdfFile = form.getFileInput();
PDDocument sourceDoc = pdfDocumentFactory.load(pdfFile);
PDDocument newDoc = pdfDocumentFactory.createNewDocumentBasedOnOldDocument(sourceDoc);
String pagesToDelete = form.getPages(); String pagesToDelete = form.getPages();
List<Integer> pagesToRemove = new ArrayList<>(); List<Integer> pagesToRemove = new ArrayList<>();
PDDocument sourceDoc = pdfDocumentFactory.load(pdfFile);
PDDocument newDoc = new PDDocument();
LayerUtility layerUtility = new LayerUtility(newDoc);
String sufix; String sufix;
// Respond with a message
if (form.isRemoveHeader()) { if (form.isRemoveHeader()) {
if (form.isRemoveFooter()) { if (form.isRemoveFooter()) {
sufix = "_removed_header_footer.pdf"; sufix = "_removed_header_footer.pdf";
@ -64,8 +68,7 @@ public class RemoveHeaderFooterController {
} else if (form.isRemoveFooter()) { } else if (form.isRemoveFooter()) {
sufix = "_removed_footer.pdf"; sufix = "_removed_footer.pdf";
} else { } else {
return ResponseEntity.badRequest() throw new IllegalArgumentException("Header and/or footer removal must be selected");
.body("No header or footer removal options selected".getBytes());
} }
if (pagesToDelete == null || pagesToDelete.isEmpty()) { if (pagesToDelete == null || pagesToDelete.isEmpty()) {
@ -81,40 +84,68 @@ public class RemoveHeaderFooterController {
Collections.sort(pagesToRemove); 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<PDFCloneUtility> 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++) { for (int pageIndex = 0; pageIndex < sourceDoc.getNumberOfPages(); pageIndex++) {
PDPage sourcePage = sourceDoc.getPage(pageIndex); PDPage sourcePage = sourceDoc.getPage(pageIndex);
PDRectangle mediaBox = sourcePage.getMediaBox(); PDPage newPage =
new PDPage(
PDPage newPage = new PDPage(mediaBox); (COSDictionary) cloner.cloneForNewDocument(sourcePage.getCOSObject()));
newDoc.addPage(newPage); newDoc.addPage(newPage);
try (PDPageContentStream cs = if (pagesToRemove.contains(pageIndex)) {
new PDPageContentStream(newDoc, newPage, AppendMode.OVERWRITE, true, true)) { PDRectangle mediaBox = newPage.getMediaBox();
PDFormXObject formXObject = layerUtility.importPageAsForm(sourceDoc, pageIndex); Float[][] zones = getRemovalZonesForPage(form, newPage);
// Save the current graphics state to restore later // Extract original content streams
cs.saveGraphicsState(); List<PDStream> 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) { if (zones != null && zones.length > 0) {
cs.addRect(0, 0, mediaBox.getWidth(), mediaBox.getHeight()); cs.addRect(0, 0, mediaBox.getWidth(), mediaBox.getHeight());
// Add rectangles for each zone to remove (header/footer areas) // Add rectangles for each zone to remove (header/footer areas)
// These will be subtracted from the base rectangle using even-odd clipping // These will be subtracted from the base rectangle using even-odd clipping
// rule // rule
for (Float[] zone : zones) { 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.clipEvenOdd();
} }
}
cs.drawForm(formXObject); cs.drawForm(formXObject);
// Restore the graphics state to ensure the clipping is applied correctly // Restore the graphics state to ensure the clipping is applied correctly
cs.restoreGraphicsState(); cs.restoreGraphicsState();
}
} }
} }
return WebResponseUtils.pdfDocToWebResponse( return WebResponseUtils.pdfDocToWebResponse(
@ -124,6 +155,23 @@ public class RemoveHeaderFooterController {
+ sufix); + sufix);
} }
private List<PDStream> extractContentStreams(PDPage page) {
List<PDStream> 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. * 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. * @return A 2D array of Float representing the zones to remove.
*/ */
private Float[][] getRemovalZonesForPage(RemoveHeaderFooterForm form, PDPage page) { private Float[][] getRemovalZonesForPage(RemoveHeaderFooterForm form, PDPage page) {
float w = page.getMediaBox().getWidth(); PDRectangle mediaBox = page.getMediaBox();
float h = page.getMediaBox().getHeight(); float w = mediaBox.getWidth();
Float[][] zones = null; float h = mediaBox.getHeight();
int rotation = page.getRotation();
boolean removeHeader = form.isRemoveHeader(); boolean removeHeader = form.isRemoveHeader();
boolean removeFooter = form.isRemoveFooter(); boolean removeFooter = form.isRemoveFooter();
zones = new Float[removeHeader && removeFooter ? 2 : 1][]; Float[][] zones = new Float[removeHeader && removeFooter ? 2 : 1][];
int zoneIdx = 0;
if (removeHeader) { if (removeHeader) {
Float headerH = form.getHeaderMargin(); Float headerH = form.getHeaderMargin();
if (headerH == -1) { if (headerH == -1) {
headerH = form.getHeaderCustomValue(); // Default value if 'custom' is specified 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) { if (removeFooter) {
Float footerH = form.getFooterMargin(); Float footerH = form.getFooterMargin();
if (footerH == -1) { if (footerH == -1) {
footerH = form.getFooterCustomValue(); // Default value if 'custom' is specified 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; 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};
};
}
} }

View File

@ -100,30 +100,17 @@ document.addEventListener('DOMContentLoaded', () => {
} }
const page = await loadedPdf.getPage(firstPageNumber); 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; await page.render({ canvasContext: ctx, viewport: viewport }).promise;
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];
}
const ctx = canvas.getContext("2d"); const headerY = mainHeaderMargin * currentScale;
const footerY = canvas.height - mainFooterMargin * currentScale;
const renderContext = {
canvasContext: ctx,
viewport: page.getViewport({ scale: 1 }),
};
await page.render(renderContext).promise;
const headerY = mainHeaderMargin * scale;
const footerY = canvas.height - mainFooterMargin * scale;
if (mainHeaderCheckbox.checked) { if (mainHeaderCheckbox.checked) {
drawMarginLine(ctx, headerY, canvas.width, "header"); drawMarginLine(ctx, headerY, canvas.width, "header");
@ -180,20 +167,22 @@ document.addEventListener('DOMContentLoaded', () => {
const viewport = page.getViewport({ scale: currentScale }); const viewport = page.getViewport({ scale: currentScale });
const canvas = document.createElement('canvas'); const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d'); const ctx = canvas.getContext('2d');
canvas.height = viewport.height; canvas.height = viewport.height;
canvas.width = viewport.width; canvas.width = viewport.width;
canvas.style.display = 'block'; canvas.style.display = 'block';
canvas.style.margin = '0 auto 16px auto'; canvas.style.margin = '0 auto 16px auto';
viewer.appendChild(canvas); viewer.appendChild(canvas);
page.render({ canvasContext: ctx, viewport: viewport }).promise.then(() => { page.render({ canvasContext: ctx, viewport: viewport }).promise.then(() => {
if (idx === 0) { if (idx === 0) {
pageNumberInput.value = pagesToShow[0]; pageNumberInput.value = pagesToShow[0];
numPagesLabel.textContent = `/ ${pagesToShow.length}`; numPagesLabel.textContent = `/ ${pagesToShow.length}`;
} }
const scale = canvas.height / page.view[3]; const headerY = headerMargin * currentScale;
const headerY = headerMargin * scale; const footerY = canvas.height - footerMargin * currentScale;
const footerY = canvas.height - footerMargin * scale;
ctx.strokeStyle = "red"; ctx.strokeStyle = "red";
ctx.lineWidth = 2; ctx.lineWidth = 2;
ctx.setLineDash([5, 5]); ctx.setLineDash([5, 5]);