PDFBox: удалить одно поле из PDF
Самый простой способ описать проблему состоит в том, что мы используем PDFbox, чтобы удалить только одно поле из PDF, отправленного нам из HelloSign. (например, номер кредитной карты)
- Данные, о которых идет речь, всегда будут на последней странице и всегда будут иметь одинаковые координаты на странице.
- Данные должны быть полностью удалены из PDF. Мы не можем просто поменять шрифт на белый или нарисовать прямоугольник сверху, так как он все еще будет доступен для выбора и, следовательно, может быть скопирован.
- Только это одно поле может быть удалено. Нам все еще нужны другие поля и подписи.
- Я создал образец документа и загрузил его в Dropbox. input.pdf
- Ради этого вопроса давайте предположим, что поле, которое нужно удалить, - это уличный адрес из файла, который я загрузил. Не город, штат, почтовый индекс, подписи или даты. (В реальной жизни это будет поле с конфиденциальными данными, например номер кредитной карты или номер SSN.)
Я привожу подробное объяснение проблемы и того, что я пробовал до сих пор в первом комментарии ниже.
1 ответ
Код в этом ответе, вероятно, выглядит несколько общим, так как он сначала определяет карту полей в документе, а затем позволяет удалить любую комбинацию текстовых полей. Однако имейте в виду, что он был разработан только с одним примером PDF из этого вопроса. Таким образом, я не могу быть уверен, правильно ли я понял, как поля, помеченные для / посредством HelloSign, и, в частности, способ, которым HelloSign заполняет эти поля.
В этом ответе представлены два класса, один из которых анализирует форму HelloSign, а другой управляет ею путем очистки выбранных полей; последний опирается на информацию, собранную первым. Оба класса построены на PDFBox PDFTextStripper
служебный класс.
Код был разработан для текущей версии PDF 2.0.0-SNAPSHOT. Скорее всего, это работает и со всеми версиями 2.0.x.
HelloSignAnalyzer
Этот класс анализирует данное PDDocument
ищет последовательности
[$varname ]
которые появляются для определения заполнителей для размещения содержимого поля формы, и[def:$varname|type|req|signer|display|label]
которые появляются для определения свойств заполнителей.
Создает коллекцию HelloSignField
экземпляры, каждый из которых описывает такой заполнитель. Они также содержат значение соответствующего поля, если можно найти текст, расположенный над заполнителем.
Кроме того, он хранит имя последнего нарисованного на странице xobject, который в случае примера документа является местом, где HelloSign рисует содержимое своего поля.
public class HelloSignAnalyzer extends PDFTextStripper
{
public class HelloSignField
{
public String getName()
{ return name; }
public String getValue()
{ return value; }
public float getX()
{ return x; }
public float getY()
{ return y; }
public float getWidth()
{ return width; }
public String getType()
{ return type; }
public boolean isOptional()
{ return optional; }
public String getSigner()
{ return signer; }
public String getDisplay()
{ return display; }
public String getLabel()
{ return label; }
public float getLastX()
{ return lastX; }
String name = null;
String value = "";
float x = 0, y = 0, width = 0;
String type = null;
boolean optional = false;
String signer = null;
String display = null;
String label = null;
float lastX = 0;
@Override
public String toString()
{
return String.format("[Name: '%s'; Value: `%s` Position: %s, %s; Width: %s; Type: '%s'; Optional: %s; Signer: '%s'; Display: '%s', Label: '%s']",
name, value, x, y, width, type, optional, signer, display, label);
}
void checkForValue(List<TextPosition> textPositions)
{
for (TextPosition textPosition : textPositions)
{
if (inField(textPosition))
{
float textX = textPosition.getTextMatrix().getTranslateX();
if (textX > lastX + textPosition.getWidthOfSpace() / 2 && value.length() > 0)
value += " ";
value += textPosition.getUnicode();
lastX = textX + textPosition.getWidth();
}
}
}
boolean inField(TextPosition textPosition)
{
float yPos = textPosition.getTextMatrix().getTranslateY();
float xPos = textPosition.getTextMatrix().getTranslateX();
return inField(xPos, yPos);
}
boolean inField(float xPos, float yPos)
{
if (yPos < y - 3 || yPos > y + 3)
return false;
if (xPos < x - 1 || xPos > x + width + 1)
return false;
return true;
}
}
public HelloSignAnalyzer(PDDocument pdDocument) throws IOException
{
super();
this.pdDocument = pdDocument;
}
public Map<String, HelloSignField> analyze() throws IOException
{
if (!analyzed)
{
fields = new HashMap<>();
setStartPage(pdDocument.getNumberOfPages());
getText(pdDocument);
analyzed = true;
}
return Collections.unmodifiableMap(fields);
}
public String getLastFormName()
{
return lastFormName;
}
//
// PDFTextStripper overrides
//
@Override
protected void writeString(String text, List<TextPosition> textPositions) throws IOException
{
{
for (HelloSignField field : fields.values())
{
field.checkForValue(textPositions);
}
}
int position = -1;
while ((position = text.indexOf('[', position + 1)) >= 0)
{
int endPosition = text.indexOf(']', position);
if (endPosition < 0)
continue;
if (endPosition > position + 1 && text.charAt(position + 1) == '$')
{
String fieldName = text.substring(position + 2, endPosition);
int spacePosition = fieldName.indexOf(' ');
if (spacePosition >= 0)
fieldName = fieldName.substring(0, spacePosition);
HelloSignField field = getOrCreateField(fieldName);
TextPosition start = textPositions.get(position);
field.x = start.getTextMatrix().getTranslateX();
field.y = start.getTextMatrix().getTranslateY();
TextPosition end = textPositions.get(endPosition);
field.width = end.getTextMatrix().getTranslateX() + end.getWidth() - field.x;
}
else if (endPosition > position + 5 && "def:$".equals(text.substring(position + 1, position + 6)))
{
String definition = text.substring(position + 6, endPosition);
String[] pieces = definition.split("\\|");
if (pieces.length == 0)
continue;
HelloSignField field = getOrCreateField(pieces[0]);
if (pieces.length > 1)
field.type = pieces[1];
if (pieces.length > 2)
field.optional = !"req".equals(pieces[2]);
if (pieces.length > 3)
field.signer = pieces[3];
if (pieces.length > 4)
field.display = pieces[4];
if (pieces.length > 5)
field.label = pieces[5];
}
}
super.writeString(text, textPositions);
}
@Override
protected void processOperator(Operator operator, List<COSBase> operands) throws IOException
{
String currentFormName = formName;
if (operator != null && "Do".equals(operator.getName()) && operands != null && operands.size() > 0)
{
COSBase base0 = operands.get(0);
if (base0 instanceof COSName)
{
formName = ((COSName)base0).getName();
if (currentFormName == null)
lastFormName = formName;
}
}
try
{
super.processOperator(operator, operands);
}
finally
{
formName = currentFormName;
}
}
//
// helper methods
//
HelloSignField getOrCreateField(String name)
{
HelloSignField field = fields.get(name);
if (field == null)
{
field = new HelloSignField();
field.name = name;
fields.put(name, field);
}
return field;
}
//
// inner member variables
//
final PDDocument pdDocument;
boolean analyzed = false;
Map<String, HelloSignField> fields = null;
String formName = null;
String lastFormName = null;
}
использование
Можно применить HelloSignAnalyzer
к документу следующим образом:
PDDocument pdDocument = PDDocument.load(...);
HelloSignAnalyzer helloSignAnalyzer = new HelloSignAnalyzer(pdDocument);
Map<String, HelloSignField> fields = helloSignAnalyzer.analyze();
System.out.printf("Found %s fields:\n\n", fields.size());
for (Map.Entry<String, HelloSignField> entry : fields.entrySet())
{
System.out.printf("%s -> %s\n", entry.getKey(), entry.getValue());
}
System.out.printf("\nLast form name: %s\n", helloSignAnalyzer.getLastFormName());
(Тестовый метод PlayWithHelloSign.java testAnalyzeInput
)
В случае образца документа ОП вывод
Found 8 fields: var1001 -> [Name: 'var1001'; Value: `123 Main St.` Position: 90.0, 580.0; Width: 165.53601; Type: 'text'; Optional: false; Signer: 'signer1'; Display: 'Address', Label: 'address1'] var1004 -> [Name: 'var1004'; Value: `12345` Position: 210.0, 564.0; Width: 45.53601; Type: 'text'; Optional: false; Signer: 'signer1'; Display: 'Postal Code', Label: 'zip'] var1002 -> [Name: 'var1002'; Value: `TestCity` Position: 90.0, 564.0; Width: 65.53601; Type: 'text'; Optional: false; Signer: 'signer1'; Display: 'City', Label: 'city'] var1003 -> [Name: 'var1003'; Value: `AA` Position: 161.0, 564.0; Width: 45.53601; Type: 'text'; Optional: false; Signer: 'signer1'; Display: 'State', Label: 'state'] date2 -> [Name: 'date2'; Value: `2016/12/09` Position: 397.0, 407.0; Width: 124.63202; Type: 'date'; Optional: false; Signer: 'signer2'; Display: 'null', Label: 'null'] signature1 -> [Name: 'signature1'; Value: `` Position: 88.0, 489.0; Width: 236.624; Type: 'sig'; Optional: false; Signer: 'signer1'; Display: 'null', Label: 'null'] date1 -> [Name: 'date1'; Value: `2016/12/09` Position: 397.0, 489.0; Width: 124.63202; Type: 'date'; Optional: false; Signer: 'signer1'; Display: 'null', Label: 'null'] signature2 -> [Name: 'signature2'; Value: `` Position: 88.0, 407.0; Width: 236.624; Type: 'sig'; Optional: false; Signer: 'signer2'; Display: 'null', Label: 'null'] Last form name: Xi0
HelloSignManipulator
Этот класс использует информацию HelloSignAnalyzer
собрал, чтобы очистить содержимое текстовых полей, заданных их именем.
public class HelloSignManipulator extends PDFTextStripper
{
public HelloSignManipulator(HelloSignAnalyzer helloSignAnalyzer) throws IOException
{
super();
this.helloSignAnalyzer = helloSignAnalyzer;
addOperator(new SelectiveDrawObject());
}
public void clearFields(Iterable<String> fieldNames) throws IOException
{
try
{
Map<String, HelloSignField> fieldMap = helloSignAnalyzer.analyze();
List<HelloSignField> selectedFields = new ArrayList<>();
for (String fieldName : fieldNames)
{
selectedFields.add(fieldMap.get(fieldName));
}
fields = selectedFields;
PDDocument pdDocument = helloSignAnalyzer.pdDocument;
setStartPage(pdDocument.getNumberOfPages());
getText(pdDocument);
}
finally
{
fields = null;
}
}
class SelectiveDrawObject extends OperatorProcessor
{
@Override
public void process(Operator operator, List<COSBase> arguments) throws IOException
{
if (arguments.size() < 1)
{
throw new MissingOperandException(operator, arguments);
}
COSBase base0 = arguments.get(0);
if (!(base0 instanceof COSName))
{
return;
}
COSName name = (COSName) base0;
if (replacement != null || !helloSignAnalyzer.getLastFormName().equals(name.getName()))
{
return;
}
if (context.getResources().isImageXObject(name))
{
throw new IllegalArgumentException("The form xobject to edit turned out to be an image.");
}
PDXObject xobject = context.getResources().getXObject(name);
if (xobject instanceof PDTransparencyGroup)
{
throw new IllegalArgumentException("The form xobject to edit turned out to be a transparency group.");
}
else if (xobject instanceof PDFormXObject)
{
PDFormXObject form = (PDFormXObject) xobject;
PDFormXObject formReplacement = new PDFormXObject(helloSignAnalyzer.pdDocument);
formReplacement.setBBox(form.getBBox());
formReplacement.setFormType(form.getFormType());
formReplacement.setMatrix(form.getMatrix().createAffineTransform());
formReplacement.setResources(form.getResources());
OutputStream outputStream = formReplacement.getContentStream().createOutputStream(COSName.FLATE_DECODE);
replacement = new ContentStreamWriter(outputStream);
context.showForm(form);
outputStream.close();
getResources().put(name, formReplacement);
replacement = null;
}
}
@Override
public String getName()
{
return "Do";
}
}
//
// PDFTextStripper overrides
//
@Override
protected void processOperator(Operator operator, List<COSBase> operands) throws IOException
{
if (replacement != null)
{
boolean copy = true;
if (TjTJ.contains(operator.getName()))
{
Matrix transformation = getTextMatrix().multiply(getGraphicsState().getCurrentTransformationMatrix());
float xPos = transformation.getTranslateX();
float yPos = transformation.getTranslateY();
for (HelloSignField field : fields)
{
if (field.inField(xPos, yPos))
{
copy = false;
}
}
}
if (copy)
{
replacement.writeTokens(operands);
replacement.writeToken(operator);
}
}
super.processOperator(operator, operands);
}
//
// helper methods
//
final HelloSignAnalyzer helloSignAnalyzer;
final Collection<String> TjTJ = Arrays.asList("Tj", "TJ");
Iterable<HelloSignField> fields;
ContentStreamWriter replacement = null;
}
Использование: Очистить одно поле
Можно применить HelloSignManipulator
в документе, чтобы очистить одно поле:
PDDocument pdDocument = PDDocument.load(...);
HelloSignAnalyzer helloSignAnalyzer = new HelloSignAnalyzer(pdDocument);
HelloSignManipulator helloSignManipulator = new HelloSignManipulator(helloSignAnalyzer);
helloSignManipulator.clearFields(Collections.singleton("var1001"));
pdDocument.save(...);
(Тестовый метод PlayWithHelloSign.java testClearAddress1Input
)
Использование: Очистить несколько полей одновременно
Можно применить HelloSignManipulator
в документе, чтобы очистить несколько полей одновременно:
PDDocument pdDocument = PDDocument.load(...);
HelloSignAnalyzer helloSignAnalyzer = new HelloSignAnalyzer(pdDocument);
HelloSignManipulator helloSignManipulator = new HelloSignManipulator(helloSignAnalyzer);
helloSignManipulator.clearFields(Arrays.asList("var1004", "var1003", "date2"));
pdDocument.save(...);
(Тестовый метод PlayWithHelloSign.java testClearZipStateDate2Input
)
Использование: Очистить несколько полей подряд
Можно применить HelloSignManipulator
в документе, чтобы очистить несколько полей подряд:
PDDocument pdDocument = PDDocument.load(...);
HelloSignAnalyzer helloSignAnalyzer = new HelloSignAnalyzer(pdDocument);
HelloSignManipulator helloSignManipulator = new HelloSignManipulator(helloSignAnalyzer);
helloSignManipulator.clearFields(Collections.singleton("var1004"));
helloSignManipulator.clearFields(Collections.singleton("var1003"));
helloSignManipulator.clearFields(Collections.singleton("date2"));
pdDocument.save(...);
(Тестовый метод PlayWithHelloSign.java testClearZipStateDate2SuccessivelyInput
)
Предостережение
Эти классы являются просто доказательством концепции. С одной стороны, они построены на основе одного примера файла HelloSign, поэтому существует большая вероятность пропустить важные детали. С другой стороны, есть некоторые встроенные предположения, например, в HelloSignField
метод inField
,
Кроме того, манипулирование подписанными файлами HelloSign в целом может быть плохой идеей. Если я правильно понял их концепцию, они хранят хэш каждого подписанного документа, чтобы разрешить проверку содержимого, и если с документом манипулируют, как показано выше, значение хеша больше не будет совпадать.