package com.tom_roush.pdfbox.pdmodel.interactive.form; import com.tom_roush.pdfbox.contentstream.operator.Operator; import com.tom_roush.pdfbox.cos.COSName; import com.tom_roush.pdfbox.pdfparser.PDFStreamParser; import com.tom_roush.pdfbox.pdfwriter.ContentStreamWriter; import com.tom_roush.pdfbox.pdmodel.PDPageContentStream; import com.tom_roush.pdfbox.pdmodel.common.PDRectangle; import com.tom_roush.pdfbox.pdmodel.font.PDFont; import com.tom_roush.pdfbox.pdmodel.interactive.action.PDFormFieldAdditionalActions; import com.tom_roush.pdfbox.pdmodel.interactive.annotation.PDAnnotationWidget; import com.tom_roush.pdfbox.pdmodel.interactive.annotation.PDAppearanceDictionary; import com.tom_roush.pdfbox.pdmodel.interactive.annotation.PDAppearanceEntry; import com.tom_roush.pdfbox.pdmodel.interactive.annotation.PDAppearanceStream; import com.tom_roush.pdfbox.util.awt.AffineTransform; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.OutputStream; import java.util.List; /** * Create the AcroForms field appearance helper. * * @author Stephan Gerhard * @author Ben Litchfield */ class AppearanceGeneratorHelper { private static final Operator BMC = Operator.getOperator("BMC"); private static final Operator EMC = Operator.getOperator("EMC"); private final PDVariableText field; private final PDAppearanceString defaultAppearance; private String value; /** * The highlight color * * The color setting is used by Adobe to display the highlight box for selected entries in a list box. * * Regardless of other settings in an existing appearance stream Adobe will always use this value. */ private static final int[] HIGHLIGHT_COLOR = {153, 193, 215}; /** * The scaling factor for font units to PDF units */ private static final int FONTSCALE = 1000; /** * The default font size used for multiline text */ private static final float DEFAULT_FONT_SIZE = 12; /** * Constructs a COSAppearance from the given field. * * @param field the field which you wish to control the appearance of * @throws IOException If there is an error creating the appearance. */ AppearanceGeneratorHelper(PDVariableText field) throws IOException { this.field = field; this.defaultAppearance = field.getDefaultAppearanceString(); } /** * This is the public method for setting the appearance stream. * * @param apValue the String value which the appearance should represent * @throws IOException If there is an error creating the stream. */ public void setAppearanceValue(String apValue) throws IOException { value = apValue; for (PDAnnotationWidget widget : field.getWidgets()) { PDFormFieldAdditionalActions actions = field.getActions(); // in case all tests fail the field will be formatted by acrobat // when it is opened. See FreedomExpressions.pdf for an example of this. if (actions == null || actions.getF() == null || widget.getCOSObject().getDictionaryObject(COSName.AP) != null) { PDAppearanceDictionary appearanceDict = widget.getAppearance(); if (appearanceDict == null) { appearanceDict = new PDAppearanceDictionary(); widget.setAppearance(appearanceDict); } PDAppearanceEntry appearance = appearanceDict.getNormalAppearance(); // TODO support appearances other than "normal" PDAppearanceStream appearanceStream; if (appearance.isStream()) { appearanceStream = appearance.getAppearanceStream(); } else { appearanceStream = new PDAppearanceStream(field.getAcroForm().getDocument()); appearanceStream.setBBox(widget.getRectangle().createRetranslatedRectangle()); appearanceDict.setNormalAppearance(appearanceStream); // TODO support appearances other than "normal" } setAppearanceContent(widget, appearanceStream); } } } /** * Parses an appearance stream into tokens. */ private List<Object> tokenize(PDAppearanceStream appearanceStream) throws IOException { PDFStreamParser parser = new PDFStreamParser(appearanceStream); parser.parse(); return parser.getTokens(); } /** * Constructs and sets new contents for given appearance stream. */ private void setAppearanceContent(PDAnnotationWidget widget, PDAppearanceStream appearanceStream) throws IOException { // first copy any needed resources from the document’s DR dictionary into // the stream’s Resources dictionary defaultAppearance.copyNeededResourcesTo(appearanceStream); // then replace the existing contents of the appearance stream from /Tx BMC // to the matching EMC ByteArrayOutputStream output = new ByteArrayOutputStream(); ContentStreamWriter writer = new ContentStreamWriter(output); List<Object> tokens = tokenize(appearanceStream); int bmcIndex = tokens.indexOf(BMC); if (bmcIndex == -1) { // append to existing stream writer.writeTokens(tokens); writer.writeTokens(COSName.TX, BMC); } else { // prepend content before BMC writer.writeTokens(tokens.subList(0, bmcIndex + 1)); } // insert field contents insertGeneratedAppearance(widget, appearanceStream, output); int emcIndex = tokens.indexOf(EMC); if (emcIndex == -1) { // append EMC writer.writeTokens(EMC); } else { // append contents after EMC writer.writeTokens(tokens.subList(emcIndex, tokens.size())); } output.close(); writeToStream(output.toByteArray(), appearanceStream); } /** * Generate and insert text content and clipping around it. */ private void insertGeneratedAppearance(PDAnnotationWidget widget, PDAppearanceStream appearanceStream, OutputStream output) throws IOException { PDPageContentStream contents = new PDPageContentStream(field.getAcroForm().getDocument(), appearanceStream, output); appearanceStream.setMatrix(new AffineTransform()); appearanceStream.setFormType(1); // Acrobat calculates the left and right padding dependent on the offset of the border edge // This calculation works for forms having been generated by Acrobat. // The minimum distance is always 1f even if there is no rectangle being drawn around. float borderWidth = 0; if (widget.getBorderStyle() != null) { borderWidth = widget.getBorderStyle().getWidth(); } PDRectangle bbox = resolveBoundingBox(widget, appearanceStream); PDRectangle clipRect = applyPadding(bbox, Math.max(1f, borderWidth)); PDRectangle contentRect = applyPadding(clipRect, Math.max(1f, borderWidth)); contents.saveGraphicsState(); // Acrobat always adds a clipping path contents.addRect(clipRect.getLowerLeftX(), clipRect.getLowerLeftY(), clipRect.getWidth(), clipRect.getHeight()); contents.clip(); // get the font PDFont font = field.getDefaultAppearanceString().getFont(); // calculate the fontSize (because 0 = autosize) float fontSize = calculateFontSize(font, contentRect); // for a listbox generate the highlight rectangle for the selected // options if (field instanceof PDListBox) { insertGeneratedSelectionHighlight(contents, appearanceStream, font, fontSize); } // start the text output contents.beginText(); // write the /DA string field.getDefaultAppearanceString().writeTo(contents, fontSize); // calculate the y-position of the baseline float y; // calculate font metrics at font size float fontScaleY = fontSize / FONTSCALE; float fontBoundingBoxAtSize = font.getBoundingBox().getHeight() * fontScaleY; float fontCapAtSize = font.getFontDescriptor().getCapHeight() * fontScaleY; float fontDescentAtSize = font.getFontDescriptor().getDescent() * fontScaleY; if (field instanceof PDTextField && ((PDTextField) field).isMultiline()) { y = contentRect.getUpperRightY() - fontBoundingBoxAtSize; } else { // Adobe shows the text 'shiftet up' in case the caps don't fit into the clipping area if (fontCapAtSize > clipRect.getHeight()) { y = clipRect.getLowerLeftY() + -fontDescentAtSize; } else { // calculate the position based on the content rectangle y = clipRect.getLowerLeftY() + (clipRect.getHeight() - fontCapAtSize) / 2; // check to ensure that ascents and descents fit if (y - clipRect.getLowerLeftY() < -fontDescentAtSize) { float fontDescentBased = -fontDescentAtSize + contentRect.getLowerLeftY(); float fontCapBased = contentRect.getHeight() - contentRect.getLowerLeftY() - fontCapAtSize; y = Math.min(fontDescentBased, Math.max(y, fontCapBased)); } } } // show the text float x = contentRect.getLowerLeftX(); // special handling for comb boxes as these are like table cells with individual chars if (shallComb()) { insertGeneratedCombAppearance(contents, appearanceStream, font, fontSize); } else if (field instanceof PDListBox) { insertGeneratedListboxAppearance(contents, appearanceStream, contentRect, font, fontSize); } else { PlainText textContent = new PlainText(value); AppearanceStyle appearanceStyle = new AppearanceStyle(); appearanceStyle.setFont(font); appearanceStyle.setFontSize(fontSize); // Adobe Acrobat uses the font's bounding box for the leading between the lines appearanceStyle.setLeading(font.getBoundingBox().getHeight() * fontScaleY); PlainTextFormatter formatter = new PlainTextFormatter .Builder(contents) .style(appearanceStyle) .text(textContent) .width(contentRect.getWidth()) .wrapLines(isMultiLine()) .initialOffset(x, y) .textAlign(field.getQ()) .build(); formatter.format(); } contents.endText(); contents.restoreGraphicsState(); contents.close(); } private boolean isMultiLine() { return field instanceof PDTextField && ((PDTextField) field).isMultiline(); } /** * Determine if the appearance shall provide a comb output. * * <p> * May be set only if the MaxLen entry is present in the text field dictionary * and if the Multiline, Password, and FileSelect flags are clear. * If set, the field shall be automatically divided into as many equally spaced positions, * or combs, as the value of MaxLen, and the text is laid out into those combs. * </p> * * @return the comb state */ private boolean shallComb() { return field instanceof PDTextField && ((PDTextField) field).isComb() && !((PDTextField) field).isMultiline() && !((PDTextField) field).isPassword() && !((PDTextField) field).isFileSelect(); } /** * Generate the appearance for comb fields. * * @param contents the content stream to write to * @param appearanceStream the appearance stream used * @param font the font to be used * @param fontSize the font size to be used * @throws IOException */ private void insertGeneratedCombAppearance(PDPageContentStream contents, PDAppearanceStream appearanceStream, PDFont font, float fontSize) throws IOException { // TODO: Currently the quadding is not taken into account // so the comb is always filled from left to right. int maxLen = ((PDTextField) field).getMaxLen(); int numChars = Math.min(value.length(), maxLen); PDRectangle paddingEdge = applyPadding(appearanceStream.getBBox(), 1); float combWidth = appearanceStream.getBBox().getWidth() / maxLen; float ascentAtFontSize = font.getFontDescriptor().getAscent() / FONTSCALE * fontSize; float baselineOffset = paddingEdge.getLowerLeftY() + (appearanceStream.getBBox().getHeight() - ascentAtFontSize) / 2; float prevCharWidth = 0f; float currCharWidth = 0f; float xOffset = combWidth / 2; String combString = ""; for (int i = 0; i < numChars; i++) { combString = value.substring(i, i + 1); currCharWidth = font.getStringWidth(combString) / FONTSCALE * fontSize / 2; xOffset = xOffset + prevCharWidth / 2 - currCharWidth / 2; contents.newLineAtOffset(xOffset, baselineOffset); contents.showText(combString); baselineOffset = 0; prevCharWidth = currCharWidth; xOffset = combWidth; } } private void insertGeneratedSelectionHighlight(PDPageContentStream contents, PDAppearanceStream appearanceStream, PDFont font, float fontSize) throws IOException { List<Integer> indexEntries = ((PDListBox) field).getSelectedOptionsIndex(); List<String> values = ((PDListBox) field).getValue(); List<String> options = ((PDListBox) field).getOptionsExportValues(); // TODO: support highlighting multiple items if multiselect is set int selectedIndex = 0; if (!values.isEmpty() && !options.isEmpty()) { if (!indexEntries.isEmpty()) { selectedIndex = indexEntries.get(0); } else { selectedIndex = options.indexOf(values.get(0)); } } // The first entry which shall be presented might be adjusted by the optional TI key // If this entry is present the first entry to be displayed is the keys value otherwise // display starts with the first entry in Opt. int topIndex = ((PDListBox) field).getTopIndex(); float highlightBoxHeight = font.getBoundingBox().getHeight() * fontSize / FONTSCALE - 2f; // the padding area PDRectangle paddingEdge = applyPadding(appearanceStream.getBBox(), 1); contents.setNonStrokingColor(HIGHLIGHT_COLOR[0], HIGHLIGHT_COLOR[1], HIGHLIGHT_COLOR[2]); contents.addRect(paddingEdge.getLowerLeftX(), paddingEdge.getUpperRightY() - highlightBoxHeight * (selectedIndex - topIndex + 1), paddingEdge.getWidth(), highlightBoxHeight); contents.fill(); contents.setNonStrokingColor(0); } private void insertGeneratedListboxAppearance(PDPageContentStream contents, PDAppearanceStream appearanceStream, PDRectangle contentRect, PDFont font, float fontSize) throws IOException { contents.setNonStrokingColor(0); int q = field.getQ(); if (q == PDVariableText.QUADDING_CENTERED || q == PDVariableText.QUADDING_RIGHT) { float fieldWidth = appearanceStream.getBBox().getWidth(); float stringWidth = (font.getStringWidth(value) / FONTSCALE) * fontSize; float adjustAmount = fieldWidth - stringWidth - 4; if (q == PDVariableText.QUADDING_CENTERED) { adjustAmount = adjustAmount / 2.0f; } contents.newLineAtOffset(adjustAmount, 0); } else if (q != PDVariableText.QUADDING_LEFT) { throw new IOException("Error: Unknown justification value:" + q); } List<String> options = ((PDListBox) field).getOptionsDisplayValues(); int numOptions = options.size(); float yTextPos = contentRect.getUpperRightY(); int topIndex = ((PDListBox) field).getTopIndex(); for (int i = topIndex; i < numOptions; i++) { if (i == topIndex) { yTextPos = yTextPos - font.getFontDescriptor().getAscent() / FONTSCALE * fontSize; } else { yTextPos = yTextPos - font.getBoundingBox().getHeight() / FONTSCALE * fontSize; contents.beginText(); } contents.newLineAtOffset(contentRect.getLowerLeftX(), yTextPos); contents.showText(options.get(i)); if (i - topIndex != (numOptions - 1)) { contents.endText(); } } } /** * Writes the stream to the actual stream in the COSStream. * * @throws IOException If there is an error writing to the stream */ private void writeToStream(byte[] data, PDAppearanceStream appearanceStream) throws IOException { OutputStream out = appearanceStream.getCOSStream().createOutputStream(); out.write(data); out.close(); } /** * My "not so great" method for calculating the fontsize. It does not work superb, but it * handles ok. * @return the calculated font-size * * @throws IOException If there is an error getting the font information. */ private float calculateFontSize(PDFont font, PDRectangle contentRect) throws IOException { float fontSize = defaultAppearance.getFontSize(); // zero is special, it means the text is auto-sized if (fontSize == 0) { if (isMultiLine()) { // Acrobat defaults to 12 for multiline text with size 0 return DEFAULT_FONT_SIZE; } else { float yScalingFactor = FONTSCALE * font.getFontMatrix().getScaleY(); float xScalingFactor = FONTSCALE * font.getFontMatrix().getScaleX(); // fit width float width = font.getStringWidth(value) * font.getFontMatrix().getScaleX(); float widthBasedFontSize = contentRect.getWidth() / width * xScalingFactor; // fit height float height = (font.getFontDescriptor().getCapHeight() + -font.getFontDescriptor().getDescent()) * font.getFontMatrix().getScaleY(); if (height <= 0) { height = font.getBoundingBox().getHeight() * font.getFontMatrix().getScaleY(); } float heightBasedFontSize = contentRect.getHeight() / height * yScalingFactor; return Math.min(heightBasedFontSize, widthBasedFontSize); } } return fontSize; } /** * Resolve the bounding box. * * @param fieldWidget the annotation widget. * @param appearanceStream the annotations appearance stream. * @return the resolved boundingBox. */ private PDRectangle resolveBoundingBox(PDAnnotationWidget fieldWidget, PDAppearanceStream appearanceStream) { PDRectangle boundingBox = appearanceStream.getBBox(); if (boundingBox == null) { boundingBox = fieldWidget.getRectangle().createRetranslatedRectangle(); } return boundingBox; } /** * Apply padding to a box. * * @param box box * @return the padded box. */ private PDRectangle applyPadding(PDRectangle box, float padding) { return new PDRectangle( box.getLowerLeftX() + padding, box.getLowerLeftY() + padding, box.getWidth() - 2 * padding, box.getHeight() - 2 * padding); } }