PDChoice.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.interactive.form;

import java.io.IOException;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.Collections;
import java.util.List;
import org.apache.pdfbox.cos.COSArray;
import org.apache.pdfbox.cos.COSBase;
import org.apache.pdfbox.cos.COSDictionary;
import org.apache.pdfbox.cos.COSName;
import org.apache.pdfbox.cos.COSString;
import org.apache.pdfbox.pdmodel.interactive.form.FieldUtils.KeyValue;

/**
 * A choice field contains several text items, one or more of which shall be selected as the field
 * value.
 * 
 * @author sug
 * @author John Hewson
 */
public abstract class PDChoice extends PDVariableText
{
    static final int FLAG_COMBO = 1 << 17;
    
    private static final int FLAG_SORT = 1 << 19;
    private static final int FLAG_MULTI_SELECT = 1 << 21;
    private static final int FLAG_DO_NOT_SPELL_CHECK = 1 << 22;
    private static final int FLAG_COMMIT_ON_SEL_CHANGE = 1 << 26;
    
    /**
     * @see PDField#PDField(PDAcroForm)
     *
     * @param acroForm The acroform.
     */
    public PDChoice(PDAcroForm acroForm)
    {
        super(acroForm);
        getCOSObject().setItem(COSName.FT, COSName.CH);
    }

    /**
     * Constructor.
     * 
     * @param acroForm The form that this field is part of.
     * @param field the PDF object to represent as a field.
     * @param parent the parent node of the node
     */
    PDChoice(PDAcroForm acroForm, COSDictionary field, PDNonTerminalField parent)
    {
        super(acroForm, field, parent);
    }
    
    /**
     * This will get the option values "Opt".
     * 
     * <p>
     * For a choice field the options array can either be an array
     * of text strings or an array of a two-element arrays.<br>
     * The method always only returns either the text strings or,
     * in case of two-element arrays, an array of the first element of 
     * the two-element arrays
     * </p>   
     * <p>
     * Use {@link #getOptionsExportValues()} and {@link #getOptionsDisplayValues()}
     * to get the entries of two-element arrays.
     * </p>
     * 
     * @return List containing the export values.
     */
    public List<String> getOptions()
    {
        COSBase values = getCOSObject().getDictionaryObject(COSName.OPT);
        return FieldUtils.getPairableItems(values, 0);
    }

    /**
     * This will set the display values - the 'Opt' key.
     * 
     * <p>
     * The Opt array specifies the list of options in the choice field either
     * as an array of text strings representing the display value 
     * or as an array of a two-element array where the
     * first element is the export value and the second the display value.
     * </p>
     * <p>
     * To set both the export and the display value use {@link #setOptions(List, List)}
     * </p> 
     *
     * @param displayValues List containing all possible options.
     */
    public void setOptions(List<String> displayValues)
    {
        if (displayValues != null && !displayValues.isEmpty())
        {
            if (isSort())
            {
                Collections.sort(displayValues);
            }
            getCOSObject().setItem(COSName.OPT,
                    COSArray.ofCOSStrings(displayValues));
        }
        else
        {
            getCOSObject().removeItem(COSName.OPT);
        }
    }

    /**
     * This will set the display and export values - the 'Opt' key.
     *
     * <p>
     * This will set both, the export value and the display value
     * of the choice field. If either one of the parameters is null or an 
     * empty list is supplied the options will
     * be removed.
     * </p>
     * <p>
     * An {@link IllegalArgumentException} will be thrown if the
     * number of items in the list differ.
     * </p>
     *
     * @see #setOptions(List)
     * @param exportValues List containing all possible export values.
     * @param displayValues List containing all possible display values.
     */
    public void setOptions(List<String> exportValues, List<String> displayValues)
    {
        if (exportValues != null && displayValues != null && !exportValues.isEmpty() && !displayValues.isEmpty()) 
        {
            if (exportValues.size() != displayValues.size())
            {
                throw new IllegalArgumentException(
                        "The number of entries for exportValue and displayValue shall be the same.");
            }
            else
            {
                List<KeyValue> keyValuePairs = FieldUtils.toKeyValueList(exportValues, displayValues);

                if (isSort())
                {
                    FieldUtils.sortByValue(keyValuePairs);
                } 

                COSArray options = new COSArray();
                for (int i = 0; i<exportValues.size(); i++)
                {
                    COSArray entry = new COSArray();
                    entry.add(new COSString(keyValuePairs.get(i).getKey()));
                    entry.add(new COSString(keyValuePairs.get(i).getValue()));
                    options.add(entry);
                }
                getCOSObject().setItem(COSName.OPT, options);
            }
        }
        else
        {
            getCOSObject().removeItem(COSName.OPT);
        }      
    }

    /**
     * This will get the display values from the options.
     * 
     * <p>
     * For options with an array of text strings the display value and export value
     * are the same.<br>
     * For options with an array of two-element arrays the display value is the 
     * second entry in the two-element array.
     * </p>
     * 
     * @return List containing all the display values.
     */
    public List<String> getOptionsDisplayValues()
    {
        COSBase values = getCOSObject().getDictionaryObject(COSName.OPT);
        return FieldUtils.getPairableItems(values, 1);
    }

    /**
     * This will get the export values from the options.
     * 
     * <p>
     * For options with an array of text strings the display value and export value
     * are the same.<br>
     * For options with an array of two-element arrays the export value is the 
     * first entry in the two-element array.
     * </p>
     *
     * @return List containing all export values.
     */
    public List<String> getOptionsExportValues()
    {
        return getOptions();
    }
    
    /**
     * This will get the indices of the selected options - the 'I' key.
     * <p>
     * This is only needed if a choice field allows multiple selections and
     * two different items have the same export value or more than one values
     * is selected.
     * </p>
     * <p>The indices are zero-based</p>
     *
     * @return List containing the indices of all selected options.
     */
    public List<Integer> getSelectedOptionsIndex()
    {
        COSArray value = getCOSObject().getCOSArray(COSName.I);
        return value != null ? value.toCOSNumberIntegerList() : Collections.emptyList();
    }

    /**
     * This will set the indices of the selected options - the 'I' key.
     * <p>
     * This method is preferred over {@link #setValue(List)} for choice fields which
     * <ul>
     *  <li>do support multiple selections</li>
     *  <li>have export values with the same value</li>
     * </ul>
     * <p>
     * Setting the index will set the value too.
     *
     * @param values List containing the indices of all selected options.
     */
    public void setSelectedOptionsIndex(List<Integer> values)
    {
        if (values != null && !values.isEmpty())
        {
            if (!isMultiSelect())
            {
                throw new IllegalArgumentException(
                        "Setting the indices is not allowed for choice fields not allowing multiple selections.");
            }
            getCOSObject().setItem(COSName.I, COSArray.ofCOSIntegers(values));
        }
        else
        {
            getCOSObject().removeItem(COSName.I);
        }
    }

    /**
     * Determines if Sort is set.
     * 
     * <p>
     * If set, the field’s option items shall be sorted alphabetically.
     * The sorting has to be done when writing the PDF. PDF Readers are supposed to
     * display the options in the order in which they occur in the Opt array. 
     * </p>
     * 
     * @return true if the options are sorted.
     */
    public boolean isSort()
    {
        return getCOSObject().getFlag(COSName.FF, FLAG_SORT);
    }

    /**
     * Set the Sort bit.
     * 
     * @see #isSort()
     * @param sort The value for Sort.
     */
    public void setSort(boolean sort)
    {
        getCOSObject().setFlag(COSName.FF, FLAG_SORT, sort);
    }

    /**
     * Determines if MultiSelect is set.
     * 
     * @return true if multi select is allowed.
     */
    public boolean isMultiSelect()
    {
        return getCOSObject().getFlag(COSName.FF, FLAG_MULTI_SELECT);
    }

    /**
     * Set the MultiSelect bit.
     *
     * @param multiSelect The value for MultiSelect.
     */
    public void setMultiSelect(boolean multiSelect)
    {
        getCOSObject().setFlag(COSName.FF, FLAG_MULTI_SELECT, multiSelect);
    }

    /**
     * Determines if DoNotSpellCheck is set.
     * 
     * @return true if spell checker is disabled.
     */
    public boolean isDoNotSpellCheck()
    {
        return getCOSObject().getFlag(COSName.FF, FLAG_DO_NOT_SPELL_CHECK);
    }

    /**
     * Set the DoNotSpellCheck bit.
     *
     * @param doNotSpellCheck The value for DoNotSpellCheck.
     */
    public void setDoNotSpellCheck(boolean doNotSpellCheck)
    {
        getCOSObject().setFlag(COSName.FF, FLAG_DO_NOT_SPELL_CHECK, doNotSpellCheck);
    }

    /**
     * Determines if CommitOnSelChange is set.
     * 
     * @return true if value shall be committed as soon as a selection is made.
     */
    public boolean isCommitOnSelChange()
    {
        return getCOSObject().getFlag(COSName.FF, FLAG_COMMIT_ON_SEL_CHANGE);
    }

    /**
     * Set the CommitOnSelChange bit.
     *
     * @param commitOnSelChange The value for CommitOnSelChange.
     */
    public void setCommitOnSelChange(boolean commitOnSelChange)
    {
        getCOSObject().setFlag(COSName.FF, FLAG_COMMIT_ON_SEL_CHANGE, commitOnSelChange);
    }

    /**
     * Determines if Combo is set.
     * 
     * @return true if value the choice is a combo box..
     */
    public boolean isCombo()
    {
        return getCOSObject().getFlag(COSName.FF, FLAG_COMBO);
    }

    /**
     * Set the Combo bit.
     *
     * @param combo The value for Combo.
     */
    public void setCombo(boolean combo)
    {
        getCOSObject().setFlag(COSName.FF, FLAG_COMBO, combo);
    }

    /**
     * Set the selected value of this field, and try to update the visual appearance.
     *
     * @param value The name of the selected item.
     * @throws IOException if the value could not be set
     */
    @Override
    public void setValue(String value) throws IOException
    {
        getCOSObject().setString(COSName.V, value);
        
        // remove I key for single valued choice field
        setSelectedOptionsIndex(null);
        
        applyChange();
    }

    /**
     * Sets the default value of this field.
     *
     * @param value The name of the selected item.
     */
    public void setDefaultValue(String value)
    {
        getCOSObject().setString(COSName.DV, value);
    }
    
    /**
     * Sets the entry "V" to the given values. Requires {@link #isMultiSelect()} to be true.
     * 
     * @param values the list of values
     * @throws IOException if the appearance couldn't be generated.
     */    
    public void setValue(List<String> values) throws IOException
    {
        if (values != null && !values.isEmpty())
        {
            if (!isMultiSelect())
            {
                throw new IllegalArgumentException("The list box does not allow multiple selections.");
            }
            if (!getOptions().containsAll(values))
            {
                throw new IllegalArgumentException("The values are not contained in the selectable options.");
            }
            getCOSObject().setItem(COSName.V,
                    COSArray.ofCOSStrings(values));
            updateSelectedOptionsIndex(values);
        }
        else
        {
            getCOSObject().removeItem(COSName.V);
            getCOSObject().removeItem(COSName.I);
        }
        applyChange();
    }

    /**
     * Returns the selected values, or an empty List. This list always contains a single item
     * unless {@link #isMultiSelect()} is true.
     *
     * @return A non-null string.
     */
    public List<String> getValue()
    {
        return getValueFor(COSName.V);
    }

    /**
     * Returns the default values, or an empty List. This list always contains a single item
     * unless {@link #isMultiSelect()} is true.
     *
     * @return A non-null string.
     */
    public List<String> getDefaultValue()
    {
        return getValueFor(COSName.DV);
    }

    /**
     * Returns the selected values, or an empty List, for the given key.
     */
    private List<String> getValueFor(COSName name)
    {
        COSBase value = getCOSObject().getDictionaryObject(name);
        if (value instanceof COSString)
        {
            return Collections.singletonList(((COSString) value).getString());
        }
        else if (value instanceof COSArray)
        {
            return ((COSArray) value).toCOSStringStringList();
        }
        return Collections.emptyList();
    }

    @Override
    public String getValueAsString()
    {
        return Arrays.toString(getValue().toArray());
    }
    
    /**
     * Update the 'I' key based on values set.
     */
    private void updateSelectedOptionsIndex(List<String> values)
    {
        List<String> options = getOptions();
        List<Integer> indices = new ArrayList<>();

        for (String value : values)
        {
            indices.add(options.indexOf(value));
        }
        
        // Indices have to be "... array of integers, sorted in ascending order ..."
        Collections.sort(indices);
        setSelectedOptionsIndex(indices);
    }

    @Override
    abstract void constructAppearances() throws IOException;
}