/*
* Created on 16 feb 2016
* Copyright 2015 by Andrea Vacondio (andrea.vacondio@gmail.com).
* 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 <http://www.gnu.org/licenses/>.
*/
package org.sejda.impl.sambox.component;
import static java.util.Objects.nonNull;
import static java.util.Optional.ofNullable;
import static org.sejda.util.RequireUtils.requireArg;
import static org.sejda.util.RequireUtils.requireNotBlank;
import static org.sejda.util.RequireUtils.requireNotNullArg;
import java.awt.Color;
import java.awt.Point;
import java.io.IOException;
import java.util.Deque;
import java.util.LinkedList;
import java.util.Optional;
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.PDPageFitWidthDestination;
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<ToCItem> 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 int maxRowsPerPage;
private int tocNumberOfPages;
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);
recalculateFontSize();
}
/**
* 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");
if (shouldGenerateToC()) {
items.add(new ToCItem(text, pageNumber, linkAnnotationFor(page)));
}
}
private PDAnnotationLink linkAnnotationFor(PDPage importedPage) {
PDPageFitWidthDestination pageDest = new PDPageFitWidthDestination();
pageDest.setPage(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
*/
public void addToC() {
try {
PDPageTree pagesTree = document.getPages();
ofNullable(generateToC()).filter(l -> !l.isEmpty()).ifPresent(t -> {
int toCPagesCount = t.size();
t.descendingIterator().forEachRemaining(p -> {
if (pagesTree.getCount() > 0) {
pagesTree.insertBefore(p, pagesTree.get(0));
} else {
pagesTree.add(p);
}
});
if (params.isBlankPageIfOdd() && toCPagesCount % 2 == 1) {
PDPage lastTocPage = pagesTree.get(toCPagesCount - 1);
PDPage blankPage = new PDPage(lastTocPage.getMediaBox());
pagesTree.insertAfter(blankPage, lastTocPage);
}
});
} catch (IOException | TaskIOException e) {
LOG.error("An error occurred while create the ToC. Skipping ToC creation.", e);
}
}
private LinkedList<PDPage> generateToC() throws TaskIOException, IOException {
LinkedList<PDPage> pages = new LinkedList<>();
if (shouldGenerateToC()) {
while (!items.isEmpty()) {
int row = 0;
float separatorWidth = stringLength(SEPARATOR);
float separatingLineEndingX = getSeparatingLineEndingX(separatorWidth, tocNumberOfPages);
PDPage page = createPage(pages);
try (PDPageContentStream stream = new PDPageContentStream(document, page)) {
while (!items.isEmpty() && row < maxRowsPerPage) {
ToCItem i = items.poll();
if (nonNull(i)) {
float y = pageSize().getHeight() - margin - (row * lineHeight);
float x = margin;
String itemText = sanitize(i.text, separatingLineEndingX, separatorWidth);
writeText(page, itemText, x, y);
String pageString = SEPARATOR + Long.toString(i.page + tocNumberOfPages);
float x2 = getPageNumberX(separatorWidth, i.page + tocNumberOfPages);
writeText(page, pageString, x2, y);
i.annotation.setRectangle(
new PDRectangle(margin, y, pageSize().getWidth() - (2 * margin), fontSize));
page.getAnnotations().add(i.annotation);
// we didn't sanitize the text so it's shorter then the available space and needs a separator line
if (itemText.equals(i.text)) {
stream.moveTo(margin + separatorWidth + stringLength(i.text), y);
stream.lineTo(separatingLineEndingX, y);
stream.setLineWidth(0.5f);
stream.stroke();
}
}
row++;
}
}
}
}
return pages;
}
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 String sanitize(String text, float separatingLineEndingX, float separatorWidth) throws TaskIOException {
float maxLen = pageSize().getWidth() - margin - (pageSize().getWidth() - separatingLineEndingX)
- separatorWidth;
if (stringLength(text) > maxLen) {
LOG.debug("Truncating ToC text to fit available space");
int currentLength = text.length() / 2;
while (stringLength(text.substring(0, currentLength)) > maxLen) {
currentLength /= 2;
}
int currentChunk = currentLength;
while (currentChunk > 1) {
currentChunk /= 2;
if (stringLength(text.substring(0, currentLength + currentChunk)) < maxLen) {
currentLength += currentChunk;
}
}
return text.substring(0, currentLength);
}
return text;
}
private PDPage createPage(LinkedList<PDPage> pages) {
LOG.debug("Creating new ToC page");
PDPage page = new PDPage(pageSize());
pages.add(page);
return page;
}
private float getSeparatingLineEndingX(float separatorWidth, long indexPages) throws TaskIOException {
return getPageNumberX(separatorWidth, items.peekLast().page + indexPages);
}
private float getPageNumberX(float separatorWidth, long pageNumber) throws TaskIOException {
return pageSize().getWidth() - margin - separatorWidth - stringLength(Long.toString(pageNumber));
}
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) {
if (this.pageSize == null) {
this.pageSize = pageSize;
recalculateFontSize();
}
}
private void recalculateFontSize() {
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));
this.maxRowsPerPage = (int) ((pageSize().getHeight() - (margin * 2) + lineHeight) / lineHeight);
if (shouldGenerateToC()) {
tocNumberOfPages = params.getInputList().size() / maxRowsPerPage
+ (params.getInputList().size() % maxRowsPerPage == 0 ? 0 : 1);
if (params.isBlankPageIfOdd() && tocNumberOfPages % 2 == 1) {
tocNumberOfPages++;
}
}
}
private PDRectangle pageSize() {
return Optional.ofNullable(pageSize).orElse(PDRectangle.A4);
}
public float getFontSize() {
return fontSize;
}
/**
* @return the number of pages this toc will consist of
*/
public long tocNumberOfPages() {
return tocNumberOfPages;
}
private static class ToCItem {
public final String text;
public final long page;
public final PDAnnotation annotation;
public ToCItem(String text, long page, PDAnnotation annotation) {
this.text = text;
this.page = page;
this.annotation = annotation;
}
}
}