TestOptionalContentGroups.java

/*
 * Licensed to the Apache Software Foundation (ASF) under one or more
 * contributor license agreements.  See the NOTICE file distributed with
 * this work for additional information regarding copyright ownership.
 * The ASF licenses this file to You under the Apache License, Version 2.0
 * (the "License"); you may not use this file except in compliance with
 * the License.  You may obtain a copy of the License at
 *
 *      http://www.apache.org/licenses/LICENSE-2.0
 *
 * Unless required by applicable law or agreed to in writing, software
 * distributed under the License is distributed on an "AS IS" BASIS,
 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
 * See the License for the specific language governing permissions and
 * limitations under the License.
 */
package org.apache.pdfbox.pdmodel.graphics.optionalcontent;


import static org.junit.jupiter.api.Assertions.assertArrayEquals;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertNull;
import static org.junit.jupiter.api.Assertions.assertTrue;

import java.awt.Color;
import java.awt.image.BufferedImage;
import java.awt.image.DataBufferInt;
import java.io.File;
import java.io.IOException;
import java.util.Arrays;
import java.util.Collection;
import java.util.HashSet;
import java.util.List;
import java.util.Set;
import java.util.stream.Collectors;
import javax.imageio.ImageIO;

import org.apache.pdfbox.Loader;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDDocumentCatalog;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDResources;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.PDPageContentStream.AppendMode;
import org.apache.pdfbox.pdmodel.PageMode;
import org.apache.pdfbox.pdmodel.documentinterchange.markedcontent.PDMarkedContent;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType1Font;
import org.apache.pdfbox.pdmodel.font.Standard14Fonts.FontName;
import org.apache.pdfbox.pdmodel.graphics.optionalcontent.PDOptionalContentProperties.BaseState;
import org.apache.pdfbox.rendering.PDFRenderer;
import org.apache.pdfbox.text.PDFMarkedContentExtractor;
import org.apache.pdfbox.text.TextPosition;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;

/**
 * Tests optional content group functionality (also called layers).
 */
class TestOptionalContentGroups
{
    private static final File testResultsDir = new File("target/test-output");

    @BeforeAll
    static void setUp()
    {
        testResultsDir.mkdirs();
    }

    /**
     * Tests OCG generation.
     * @throws Exception if an error occurs
     */
    @Test
    void testOCGGeneration() throws Exception
    {
        try (PDDocument doc = new PDDocument())
        {
            //Create new page
            PDPage page = new PDPage();
            doc.addPage(page);
            PDResources resources = page.getResources();
            if( resources == null )
            {
                resources = new PDResources();
                page.setResources( resources );
            }

            //Prepare OCG functionality
            PDOptionalContentProperties ocprops = new PDOptionalContentProperties();
            doc.getDocumentCatalog().setOCProperties(ocprops);
            //ocprops.setBaseState(BaseState.ON); //ON=default

            //Create OCG for background
            PDOptionalContentGroup background = new PDOptionalContentGroup("background");
            ocprops.addGroup(background);
            assertTrue(ocprops.isGroupEnabled("background"));

            //Create OCG for enabled
            PDOptionalContentGroup enabled = new PDOptionalContentGroup("enabled");
            ocprops.addGroup(enabled);
            assertFalse(ocprops.setGroupEnabled("enabled", true));
            assertTrue(ocprops.isGroupEnabled("enabled"));

            //Create OCG for disabled
            PDOptionalContentGroup disabled = new PDOptionalContentGroup("disabled");
            ocprops.addGroup(disabled);
            assertFalse(ocprops.setGroupEnabled("disabled", true));
            assertTrue(ocprops.isGroupEnabled("disabled"));
            assertTrue(ocprops.setGroupEnabled("disabled", false));
            assertFalse(ocprops.isGroupEnabled("disabled"));

            //Setup page content stream and paint background/title
            try (PDPageContentStream contentStream = new PDPageContentStream(doc, page, AppendMode.OVERWRITE, false))
            {
                PDFont font = new PDType1Font(FontName.HELVETICA_BOLD);
                contentStream.beginMarkedContent(COSName.OC, background);
                contentStream.beginText();
                contentStream.setFont(font, 14);
                contentStream.newLineAtOffset(80, 700);
                contentStream.showText("PDF 1.5: Optional Content Groups");
                contentStream.endText();
                font = new PDType1Font(FontName.HELVETICA);
                contentStream.beginText();
                contentStream.setFont(font, 12);
                contentStream.newLineAtOffset(80, 680);
                contentStream.showText("You should see a green textline, but no red text line.");
                contentStream.endText();
                contentStream.endMarkedContent();
                
                //Paint enabled layer
                contentStream.beginMarkedContent(COSName.OC, enabled);
                contentStream.setNonStrokingColor(Color.GREEN);
                contentStream.beginText();
                contentStream.setFont(font, 12);
                contentStream.newLineAtOffset(80, 600);
                contentStream.showText(
                        "This is from an enabled layer. If you see this, that's good.");
                contentStream.endText();
                contentStream.endMarkedContent();
                
                //Paint disabled layer
                contentStream.beginMarkedContent(COSName.OC, disabled);
                contentStream.setNonStrokingColor(Color.RED);
                contentStream.beginText();
                contentStream.setFont(font, 12);
                contentStream.newLineAtOffset(80, 500);
                contentStream.showText(
                        "This is from a disabled layer. If you see this, that's NOT good!");
                contentStream.endText();
                contentStream.endMarkedContent();
            }

            File targetFile = new File(testResultsDir, "ocg-generation.pdf");
            doc.save(targetFile.getAbsolutePath());
        }
    }

    /**
     * Tests OCG functions on a loaded PDF.
     * @throws Exception if an error occurs
     */
    @Test
    void testOCGConsumption() throws Exception
    {
        File pdfFile = new File(testResultsDir, "ocg-generation.pdf");
        if (!pdfFile.exists())
        {
            testOCGGeneration();
        }

        try (PDDocument doc = Loader.loadPDF(pdfFile))
        {
            assertEquals(1.6f, doc.getVersion());
            PDDocumentCatalog catalog = doc.getDocumentCatalog();

            PDPage page = doc.getPage(0);
            PDResources resources = page.getResources();

            COSName mc0 = COSName.getPDFName("oc1");
            PDOptionalContentGroup ocg = (PDOptionalContentGroup)resources.getProperties(mc0);
            assertNotNull(ocg);
            assertEquals("background", ocg.getName());

            assertNull(resources.getProperties(COSName.getPDFName("inexistent")));

            PDOptionalContentProperties ocgs = catalog.getOCProperties();
            assertEquals(BaseState.ON, ocgs.getBaseState());
            Set<String> names = new java.util.HashSet<>(Arrays.asList(ocgs.getGroupNames()));
            assertEquals(3, names.size());
            assertTrue(names.contains("background"));

            assertTrue(ocgs.isGroupEnabled("background"));
            assertTrue(ocgs.isGroupEnabled("enabled"));
            assertFalse(ocgs.isGroupEnabled("disabled"));

            ocgs.setGroupEnabled("background", false);
            assertFalse(ocgs.isGroupEnabled("background"));

            PDOptionalContentGroup background = ocgs.getGroup("background");
            assertEquals(ocg.getName(), background.getName());
            assertNull(ocgs.getGroup("inexistent"));

            Collection<PDOptionalContentGroup> coll = ocgs.getOptionalContentGroups();
            assertEquals(3, coll.size());
            HashSet<String> nameSet = coll.stream().map(PDOptionalContentGroup::getName).
                    collect(Collectors.toCollection(HashSet::new));
            assertTrue(nameSet.contains("background"));
            assertTrue(nameSet.contains("enabled"));
            assertTrue(nameSet.contains("disabled"));

            PDFMarkedContentExtractor extractor = new PDFMarkedContentExtractor();
            extractor.processPage(page);
            List<PDMarkedContent> markedContents = extractor.getMarkedContents();
            assertEquals("oc1", markedContents.get(0).getTag());
            assertEquals("PDF 1.5: Optional Content Groups"
                    + "You should see a green textline, but no red text line.",
                    textPositionListToString(markedContents.get(0).getContents()));
            assertEquals("oc2", markedContents.get(1).getTag());
            assertEquals("This is from an enabled layer. If you see this, that's good.",
                    textPositionListToString(markedContents.get(1).getContents()));
            assertEquals("oc3", markedContents.get(2).getTag());
            assertEquals("This is from a disabled layer. If you see this, that's NOT good!",
                    textPositionListToString(markedContents.get(2).getContents()));
        }
    }

    /**
     * Convert a list of TextPosition objects to a string.
     * 
     * @param contents list of TextPosition objects.
     * @return 
     */
    private String textPositionListToString(List<Object> contents)
    {
        StringBuilder sb = new StringBuilder();
        for (Object o : contents)
        {
            TextPosition tp = (TextPosition) o;
            sb.append(tp.getUnicode());
        }
        return sb.toString();
    }

    @Test
    void testOCGsWithSameNameCanHaveDifferentVisibility() throws Exception
    {
        try (PDDocument doc = new PDDocument())
        {
            //Create new page
            PDPage page = new PDPage();
            doc.addPage(page);
            PDResources resources = page.getResources();
            if( resources == null )
            {
                resources = new PDResources();
                page.setResources( resources );
            }

            //Prepare OCG functionality
            PDOptionalContentProperties ocprops = new PDOptionalContentProperties();
            doc.getDocumentCatalog().setOCProperties(ocprops);
            //ocprops.setBaseState(BaseState.ON); //ON=default

            //Create visible OCG
            PDOptionalContentGroup visible = new PDOptionalContentGroup("layer");
            ocprops.addGroup(visible);
            assertTrue(ocprops.isGroupEnabled(visible));

            //Create invisible OCG
            PDOptionalContentGroup invisible = new PDOptionalContentGroup("layer");
            ocprops.addGroup(invisible);
            assertFalse(ocprops.setGroupEnabled(invisible, false));
            assertFalse(ocprops.isGroupEnabled(invisible));

            //Check that visible layer is still visible
            assertTrue(ocprops.isGroupEnabled(visible));

            //Setup page content stream and paint background/title
            try (PDPageContentStream contentStream = new PDPageContentStream(doc, page, AppendMode.OVERWRITE, false))
            {
                PDFont font = new PDType1Font(FontName.HELVETICA_BOLD);
                contentStream.beginMarkedContent(COSName.OC, visible);
                contentStream.beginText();
                contentStream.setFont(font, 14);
                contentStream.newLineAtOffset(80, 700);
                contentStream.showText("PDF 1.5: Optional Content Groups");
                contentStream.endText();
                font = new PDType1Font(FontName.HELVETICA);
                contentStream.beginText();
                contentStream.setFont(font, 12);
                contentStream.newLineAtOffset(80, 680);
                contentStream.showText("You should see this text, but no red text line.");
                contentStream.endText();
                contentStream.endMarkedContent();
                
                //Paint disabled layer
                contentStream.beginMarkedContent(COSName.OC, invisible);
                contentStream.setNonStrokingColor(Color.RED);
                contentStream.beginText();
                contentStream.setFont(font, 12);
                contentStream.newLineAtOffset(80, 500);
                contentStream.showText(
                        "This is from a disabled layer. If you see this, that's NOT good!");
                contentStream.endText();
                contentStream.endMarkedContent();
            }

            File targetFile = new File(testResultsDir, "ocg-generation-same-name.pdf");
            doc.save(targetFile.getAbsolutePath());
        }
    }

    /**
     * PDFBOX-4496: setGroupEnabled(String, boolean) must catch all OCGs of a name even when several
     * names are identical.
     *
     * @throws IOException
     */
    @Test
    void testOCGGenerationSameNameCanHaveSameVisibilityOff() throws IOException
    {
        BufferedImage expectedImage;
        BufferedImage actualImage;

        try (PDDocument doc = new PDDocument())
        {
            //Create new page
            PDPage page = new PDPage();
            doc.addPage(page);
            PDResources resources = page.getResources();
            if (resources == null)
            {
                resources = new PDResources();
                page.setResources(resources);
            }

            //Prepare OCG functionality
            PDOptionalContentProperties ocprops = new PDOptionalContentProperties();
            doc.getDocumentCatalog().setOCProperties(ocprops);
            //ocprops.setBaseState(BaseState.ON); //ON=default

            //Create OCG for background
            PDOptionalContentGroup background = new PDOptionalContentGroup("background");
            ocprops.addGroup(background);
            assertTrue(ocprops.isGroupEnabled("background"));

            //Create OCG for enabled
            PDOptionalContentGroup enabled = new PDOptionalContentGroup("science");
            ocprops.addGroup(enabled);
            assertFalse(ocprops.setGroupEnabled("science", true));
            assertTrue(ocprops.isGroupEnabled("science"));

            //Create OCG for disabled1
            PDOptionalContentGroup disabled1 = new PDOptionalContentGroup("alternative");
            ocprops.addGroup(disabled1);

            //Create OCG for disabled2 with same name as disabled1
            PDOptionalContentGroup disabled2 = new PDOptionalContentGroup("alternative");
            ocprops.addGroup(disabled2);

            assertFalse(ocprops.setGroupEnabled("alternative", false));
            assertFalse(ocprops.isGroupEnabled("alternative"));

            //Setup page content stream and paint background/title
            try (PDPageContentStream contentStream = new PDPageContentStream(doc, page, AppendMode.OVERWRITE, false))
            {
                PDFont font = new PDType1Font(FontName.HELVETICA_BOLD);
                contentStream.beginMarkedContent(COSName.OC, background);
                contentStream.beginText();
                contentStream.setFont(font, 14);
                contentStream.newLineAtOffset(80, 700);
                contentStream.showText("PDF 1.5: Optional Content Groups");
                contentStream.endText();
                contentStream.endMarkedContent();
                
                font = new PDType1Font(FontName.HELVETICA);
                
                //Paint enabled layer
                contentStream.beginMarkedContent(COSName.OC, enabled);
                contentStream.setNonStrokingColor(Color.GREEN);
                contentStream.beginText();
                contentStream.setFont(font, 12);
                contentStream.newLineAtOffset(80, 600);
                contentStream.showText("The earth is a sphere");
                contentStream.endText();
                contentStream.endMarkedContent();
                
                //Paint disabled layer1
                contentStream.beginMarkedContent(COSName.OC, disabled1);
                contentStream.setNonStrokingColor(Color.RED);
                contentStream.beginText();
                contentStream.setFont(font, 12);
                contentStream.newLineAtOffset(80, 500);
                contentStream.showText("Alternative 1: The earth is a flat circle");
                contentStream.endText();
                contentStream.endMarkedContent();
                
                //Paint disabled layer2
                contentStream.beginMarkedContent(COSName.OC, disabled2);
                contentStream.setNonStrokingColor(Color.BLUE);
                contentStream.beginText();
                contentStream.setFont(font, 12);
                contentStream.newLineAtOffset(80, 450);
                contentStream.showText("Alternative 2: The earth is a flat parallelogram");
                contentStream.endText();
                contentStream.endMarkedContent();
            }

            doc.getDocumentCatalog().setPageMode(PageMode.USE_OPTIONAL_CONTENT);

            File targetFile = new File(testResultsDir, "ocg-generation-same-name-off.pdf");
            doc.save(targetFile.getAbsolutePath());
        }

        // create PDF without OCGs to created expected rendering
        try (PDDocument doc = new PDDocument())
        {
            PDPage page = new PDPage();
            doc.addPage(page);
            PDResources resources = page.getResources();
            if (resources == null)
            {
                resources = new PDResources();
                page.setResources(resources);
            }

            try (PDPageContentStream contentStream = new PDPageContentStream(doc, page, AppendMode.OVERWRITE, false))
            {
                PDFont font = new PDType1Font(FontName.HELVETICA);
                
                contentStream.setNonStrokingColor(Color.RED);
                contentStream.beginText();
                contentStream.setFont(font, 12);
                contentStream.newLineAtOffset(80, 500);
                contentStream.showText("Alternative 1: The earth is a flat circle");
                contentStream.endText();
                
                contentStream.setNonStrokingColor(Color.BLUE);
                contentStream.beginText();
                contentStream.setFont(font, 12);
                contentStream.newLineAtOffset(80, 450);
                contentStream.showText("Alternative 2: The earth is a flat parallelogram");
                contentStream.endText();
            }

            expectedImage = new PDFRenderer(doc).renderImage(0, 2);
            ImageIO.write(expectedImage, "png", new File(testResultsDir, "ocg-generation-same-name-off-expected.png"));
        }

        // render PDF with science disabled and alternatives with same name enabled
        try (PDDocument doc = Loader
                .loadPDF(new File(testResultsDir, "ocg-generation-same-name-off.pdf")))
        {
            doc.getDocumentCatalog().getOCProperties().setGroupEnabled("background", false);
            doc.getDocumentCatalog().getOCProperties().setGroupEnabled("science", false);
            doc.getDocumentCatalog().getOCProperties().setGroupEnabled("alternative", true);
            actualImage = new PDFRenderer(doc).renderImage(0, 2);
            ImageIO.write(actualImage, "png", new File(testResultsDir, "ocg-generation-same-name-off-actual.png"));
        }

        // compare images
        DataBufferInt expectedData = (DataBufferInt) expectedImage.getRaster().getDataBuffer();
        DataBufferInt actualData = (DataBufferInt) actualImage.getRaster().getDataBuffer();
        assertArrayEquals(expectedData.getData(), actualData.getData());
    }
}