org.sejda.impl.sambox.component.TableOfContentsCreator Maven / Gradle / Ivy
Go to download
Show more of this group Show more artifacts with this name
Show all versions of sejda-sambox Show documentation
Show all versions of sejda-sambox Show documentation
Package containing tasks implemented using sambox.
/*
* Created on 16 feb 2016
* Copyright 2015 by Andrea Vacondio ([email protected]).
* This file is part of Sejda.
*
* Sejda is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* Sejda is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with Sejda. If not, see .
*/
package org.sejda.impl.sambox.component;
import static java.util.Objects.nonNull;
import static org.sejda.commons.util.RequireUtils.requireArg;
import static org.sejda.commons.util.RequireUtils.requireNotBlank;
import static org.sejda.commons.util.RequireUtils.requireNotNullArg;
import static org.sejda.impl.sambox.component.OutlineUtils.pageDestinationFor;
import java.awt.Color;
import java.awt.Point;
import java.io.IOException;
import java.util.Deque;
import java.util.LinkedList;
import java.util.List;
import java.util.Optional;
import org.sejda.impl.sambox.util.FontUtils;
import org.sejda.model.exception.TaskException;
import org.sejda.model.exception.TaskIOException;
import org.sejda.model.parameter.MergeParameters;
import org.sejda.model.toc.ToCPolicy;
import org.sejda.sambox.cos.COSArray;
import org.sejda.sambox.cos.COSInteger;
import org.sejda.sambox.pdmodel.PDDocument;
import org.sejda.sambox.pdmodel.PDPage;
import org.sejda.sambox.pdmodel.PDPageContentStream;
import org.sejda.sambox.pdmodel.PDPageTree;
import org.sejda.sambox.pdmodel.common.PDRectangle;
import org.sejda.sambox.pdmodel.font.PDFont;
import org.sejda.sambox.pdmodel.font.PDType1Font;
import org.sejda.sambox.pdmodel.interactive.annotation.PDAnnotation;
import org.sejda.sambox.pdmodel.interactive.annotation.PDAnnotationLink;
import org.sejda.sambox.pdmodel.interactive.documentnavigation.destination.PDPageXYZDestination;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* Component creating a table of content
*
* @author Andrea Vacondio
*/
public class TableOfContentsCreator {
private static final Logger LOG = LoggerFactory.getLogger(TableOfContentsCreator.class);
private static final int DEFAULT_FONT_SIZE = 14;
private static final int DEFAULT_MARGIN = 72;
private static final String SEPARATOR = " ";
private final Deque items = new LinkedList<>();
private PDDocument document;
private PDRectangle pageSize = null;
private float fontSize = DEFAULT_FONT_SIZE;
private float margin = DEFAULT_MARGIN;
private PDFont font = PDType1Font.HELVETICA;
private float lineHeight;
private MergeParameters params;
private PageTextWriter writer;
public TableOfContentsCreator(MergeParameters params, PDDocument document) {
requireNotNullArg(document, "Containing document cannot be null");
requireNotNullArg(params, "Parameters cannot be null");
this.document = document;
this.params = params;
this.writer = new PageTextWriter(document);
}
/**
* Adds to the ToC the given text with the given annotation associated
*
* @param text
* @param pageNumber
* @param page
*/
public void appendItem(String text, long pageNumber, PDPage page) {
requireNotBlank(text, "ToC item cannot be blank");
requireArg(pageNumber > 0, "ToC item cannot point to a negative page");
requireNotNullArg(page, "ToC page cannot be null");
requireNotAlreadyGenerated();
if (shouldGenerateToC()) {
items.add(new ToCItem(text, pageNumber, linkAnnotationFor(page)));
}
}
private PDAnnotationLink linkAnnotationFor(PDPage importedPage) {
PDPageXYZDestination pageDest = pageDestinationFor(importedPage);
PDAnnotationLink link = new PDAnnotationLink();
link.setDestination(pageDest);
link.setBorder(new COSArray(COSInteger.ZERO, COSInteger.ZERO, COSInteger.ZERO));
return link;
}
/**
* Generates a ToC and prepend it to the given document
*
* @throws TaskException
* if there is an error generating the ToC
*/
public int addToC() throws TaskException {
return addToC(0);
}
/**
* Generates a ToC and inserts it in the doc at before the given page number
*
* @throws TaskException
* if there is an error generating the ToC
*/
public int addToC(int beforePageNumber) throws TaskException {
PDPageTree pagesTree = document.getPages();
LinkedList toc = generateToC();
toc.descendingIterator().forEachRemaining(p -> {
if (pagesTree.getCount() > 0) {
pagesTree.insertBefore(p, pagesTree.get(beforePageNumber));
} else {
pagesTree.add(p);
}
});
return toc.size();
}
private LinkedList generatedToC;
private LinkedList generateToC() throws TaskIOException {
if(generatedToC == null) {
generatedToC = _generateToC();
}
return generatedToC;
}
private LinkedList _generateToC() throws TaskIOException {
// we need to know how many pages the ToC itself has
// so we can write the page numbers of the ToC items correctly
// but can only know how many pages the ToC has after we generate it
// therefore, 1) generate ToC using a dummy estimate for the tocNumberOfPages
int tocNumberOfPages = _generateToC(0).size();
// 2) generate ToC again with correct tocNumberOfPages
return _generateToC(tocNumberOfPages);
}
private LinkedList _generateToC(int tocNumberOfPages) throws TaskIOException {
LinkedList pages = new LinkedList<>();
recalculateDimensions();
int maxRowsPerPage = (int) ((pageSize().getHeight() - (margin * 2) + lineHeight) / lineHeight);
Deque items = new LinkedList<>(this.items);
if (shouldGenerateToC()) {
while (!items.isEmpty()) {
int row = 0;
float separatorWidth = stringLength(SEPARATOR);
float separatingLineEndingX = getSeparatingLineEndingX(separatorWidth);
PDPage page = createPage(pages);
try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
while (!items.isEmpty() && row < maxRowsPerPage) {
// peek, don't poll. we don't know yet if the item will fit on this page
// eg: long item that wraps on multiple lines, but there's no room for all of them
// (1 row available on page, but item wraps on 2 rows).
ToCItem i = items.peek();
if (nonNull(i)) {
float y = pageSize().getHeight() - margin - (row * lineHeight);
float x = margin;
List lines = multipleLinesIfRequired(i.text, separatingLineEndingX, separatorWidth);
if (row + lines.size() > maxRowsPerPage) {
// does not fit on multiple lines, write on next page
row = maxRowsPerPage;
continue;
}
// fits even if on multiple lines, take out of the items thing
items.poll();
// write item on multiple lines if it's too long to fit on just one
// regular scenario is a single line
for (int j = 0; j < lines.size(); j++) {
String line = lines.get(j);
writeText(page, line, x, y);
if (j < lines.size() - 1) {
// if we've written the item last line, don't increment the row and y coordinate
// we'll continue writing on the same row the ____________ part.
row++;
y = pageSize().getHeight() - margin - (row * lineHeight);
}
}
long pageNumber = i.page + tocNumberOfPages;
String pageString = SEPARATOR + pageNumber;
float x2 = getPageNumberX(separatorWidth);
writeText(page, pageString, x2, y);
// make the item clickable and link to the page number
// we want a little spacing between link annotations, so they are not adjacent, to prevent mis-clicking
// the spacing will be applied top and bottom and is the difference between line height and font height
float spacing = (lineHeight - fontSize) / 2;
float height = lineHeight * lines.size() - 2 * spacing;
i.annotation.setRectangle(
new PDRectangle(margin, y - spacing, pageSize().getWidth() - (2 * margin), height));
page.getAnnotations().add(i.annotation);
// draw line between item text and page number
// chapter 1 _____________________ 12
// chapter 2 _____________________ 15
// TODO: dots .............. instead of line _________________________
String lastLine = lines.get(lines.size() - 1);
stream.moveTo(margin + separatorWidth + stringLength(lastLine), y);
stream.lineTo(separatingLineEndingX, y);
stream.setLineWidth(0.5f);
stream.stroke();
}
row++;
}
} catch (IOException e) {
throw new TaskIOException("An error occurred while create the ToC", e);
}
}
if (params.isBlankPageIfOdd() && pages.size() % 2 == 1) {
PDPage lastTocPage = pages.getLast();
PDPage blankPage = new PDPage(lastTocPage.getMediaBox());
pages.add(blankPage);
}
}
return pages;
}
private void requireNotAlreadyGenerated() {
if(generatedToC != null) {
throw new IllegalStateException("ToC has already been generated");
}
}
private void writeText(PDPage page, String s, float x, float y) throws TaskIOException {
writer.write(page, new Point.Float(x, y), s, font, (double) fontSize, Color.BLACK);
}
private List multipleLinesIfRequired(String text, float separatingLineEndingX, float separatorWidth)
throws TaskIOException {
float maxWidth = pageSize().getWidth() - margin - (pageSize().getWidth() - separatingLineEndingX)
- separatorWidth;
return FontUtils.wrapLines(text, font, fontSize, maxWidth, document);
}
private PDPage createPage(LinkedList pages) {
LOG.debug("Creating new ToC page");
PDPage page = new PDPage(pageSize());
pages.add(page);
return page;
}
private float getSeparatingLineEndingX(float separatorWidth) throws TaskIOException {
return getPageNumberX(separatorWidth);
}
private float getPageNumberX(float separatorWidth) throws TaskIOException {
return pageSize().getWidth() - margin - separatorWidth
/* leave enough space for a 4 digit page number, assumes 9 to be a wide enough digit */
- stringLength(Long.toString(9999));
}
private float stringLength(String text) throws TaskIOException {
return writer.getStringWidth(text, font, fontSize);
}
public boolean hasToc() {
return !items.isEmpty();
}
public boolean shouldGenerateToC() {
return params.getTableOfContentsPolicy() != ToCPolicy.NONE;
}
public void pageSizeIfNotSet(PDRectangle pageSize) {
requireNotAlreadyGenerated();
if (this.pageSize == null) {
this.pageSize = pageSize;
}
}
private void recalculateDimensions() {
float scalingFactor = pageSize().getHeight() / PDRectangle.A4.getHeight();
this.fontSize = scalingFactor * DEFAULT_FONT_SIZE;
this.margin = scalingFactor * DEFAULT_MARGIN;
this.lineHeight = (float) (fontSize + (fontSize * 0.7));
}
private PDRectangle pageSize() {
return Optional.ofNullable(pageSize).orElse(PDRectangle.A4);
}
public float getFontSize() {
return fontSize;
}
PDDocument getDoc() {
return document;
}
private record ToCItem(String text, long page, PDAnnotation annotation) {
}
}