2021-01-04 21:10:11 +00:00
|
|
|
"""test_pdfforms_fields.py - Unit tests for PDF forms manipulation"""
|
|
|
|
# Copyright © 2020 Brett Smith
|
|
|
|
# License: AGPLv3-or-later WITH Beancount-Plugin-Additional-Permission-1.0
|
|
|
|
#
|
|
|
|
# Full copyright and licensing details can be found at toplevel file
|
|
|
|
# LICENSE.txt in the repository.
|
|
|
|
|
|
|
|
import codecs
|
|
|
|
import itertools
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
2021-01-05 19:59:36 +00:00
|
|
|
from decimal import Decimal
|
|
|
|
|
2021-01-04 21:10:11 +00:00
|
|
|
from pdfminer.psparser import PSLiteral
|
|
|
|
|
|
|
|
from conservancy_beancount.pdfforms import fields as fieldsmod
|
|
|
|
|
|
|
|
def field_source(
|
|
|
|
name=None,
|
|
|
|
value=None,
|
|
|
|
field_type=None,
|
|
|
|
flags=None,
|
|
|
|
parent=None,
|
|
|
|
kids=None,
|
|
|
|
*,
|
|
|
|
literal=None,
|
|
|
|
):
|
|
|
|
retval = {}
|
|
|
|
if isinstance(name, str):
|
|
|
|
retval['T'] = name.encode('ascii')
|
|
|
|
elif name is not None:
|
|
|
|
retval['T'] = name
|
|
|
|
if value is not None:
|
|
|
|
if literal is None:
|
|
|
|
literal = field_type and field_type != 'Tx'
|
|
|
|
if literal:
|
|
|
|
value = PSLiteral(value)
|
|
|
|
retval['V'] = value
|
|
|
|
if field_type is not None:
|
|
|
|
retval['FT'] = PSLiteral(field_type)
|
|
|
|
if flags is not None:
|
|
|
|
retval['Ff'] = flags
|
|
|
|
if parent is not None:
|
|
|
|
retval['Parent'] = parent
|
|
|
|
if kids is not None:
|
|
|
|
retval['Kids'] = list(kids)
|
|
|
|
return retval
|
|
|
|
|
|
|
|
def appearance_states(*names):
|
|
|
|
return {key: object() for key in names if key is not None}
|
|
|
|
|
|
|
|
def test_empty_field():
|
|
|
|
source = field_source()
|
|
|
|
field = fieldsmod.FormField(source)
|
|
|
|
assert not field.name()
|
|
|
|
assert field.value() is None
|
|
|
|
assert field.parent() is None
|
|
|
|
assert not list(field.kids())
|
|
|
|
assert field.flags() == 0
|
|
|
|
assert field.is_terminal()
|
|
|
|
with pytest.raises(ValueError):
|
|
|
|
field.field_type()
|
|
|
|
|
|
|
|
def test_text_field_base():
|
|
|
|
source = field_source(b's', b'string of text', 'Tx')
|
|
|
|
field = fieldsmod.FormField(source)
|
|
|
|
assert field.field_type() is fieldsmod.FieldType.TEXT
|
|
|
|
assert field.name() == 's'
|
|
|
|
assert field.value() == b'string of text'
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('value', ['Off', 'Yes', 'On'])
|
|
|
|
def test_checkbox_field_base(value):
|
|
|
|
source = field_source(b'cb', value, 'Btn', literal=True)
|
|
|
|
field = fieldsmod.FormField(source)
|
|
|
|
assert field.field_type() is fieldsmod.FieldType.BUTTON
|
|
|
|
assert field.name() == 'cb'
|
|
|
|
assert field.value().name == value
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('flags', range(4))
|
|
|
|
def test_readonly_flag(flags):
|
|
|
|
source = field_source(flags=flags)
|
|
|
|
field = fieldsmod.FormField(source)
|
|
|
|
assert field.flags() == flags
|
|
|
|
assert field.is_readonly() == flags % 2
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('kid_count', range(3))
|
|
|
|
def test_kids(kid_count):
|
|
|
|
kids = [field_source(f'kid{n}', field_type='Ch') for n in range(kid_count)]
|
|
|
|
source = field_source(kids=iter(kids))
|
|
|
|
field = fieldsmod.FormField(source)
|
|
|
|
got_kids = list(field.kids())
|
|
|
|
assert len(got_kids) == len(kids)
|
|
|
|
assert field.is_terminal() == (not kids)
|
|
|
|
for actual, expected in zip(got_kids, kids):
|
|
|
|
assert actual.name() == expected['T'].decode('ascii')
|
|
|
|
|
|
|
|
def test_kids_by_type():
|
|
|
|
kids = [field_source(field_type='Tx'), field_source(field_type='Btn')]
|
|
|
|
source = field_source('topform', kids=iter(kids))
|
|
|
|
actual = fieldsmod.FormField.by_type(source).kids()
|
|
|
|
assert isinstance(next(actual), fieldsmod.TextField)
|
|
|
|
assert isinstance(next(actual), fieldsmod.CheckboxField)
|
|
|
|
assert next(actual, None) is None
|
|
|
|
|
|
|
|
def test_inheritance():
|
|
|
|
parent_source = field_source(b'parent', 'parent value', 'Tx', 17)
|
|
|
|
kid_source = field_source('kid', parent=parent_source)
|
|
|
|
parent_source['Kids'] = [kid_source]
|
|
|
|
field = fieldsmod.FormField(kid_source)
|
|
|
|
parent = field.parent()
|
|
|
|
assert parent is not None
|
|
|
|
assert parent.name() == 'parent'
|
|
|
|
assert not parent.is_terminal()
|
|
|
|
assert field.is_terminal()
|
|
|
|
assert field.name() == 'kid'
|
|
|
|
assert field.field_type() is fieldsmod.FieldType.TEXT
|
|
|
|
assert field.value() == 'parent value'
|
|
|
|
assert field.flags() == 17
|
|
|
|
assert not list(field.kids())
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('field_type,value', [
|
|
|
|
('Tx', b'new value'),
|
|
|
|
('Btn', PSLiteral('Yes')),
|
|
|
|
])
|
|
|
|
def test_set_value(field_type, value):
|
|
|
|
source = field_source(field_type=field_type)
|
|
|
|
field = fieldsmod.FormField(source)
|
|
|
|
assert field.value() is None
|
|
|
|
field.set_value(value)
|
|
|
|
assert field.value() == value
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('field_type,expected', [
|
|
|
|
('Tx', fieldsmod.TextField),
|
|
|
|
('Btn', fieldsmod.CheckboxField),
|
|
|
|
])
|
|
|
|
def test_by_type(field_type, expected):
|
|
|
|
source = field_source(field_type=field_type)
|
|
|
|
field = fieldsmod.FormField.by_type(source)
|
|
|
|
assert isinstance(field, expected)
|
|
|
|
|
|
|
|
def test_container_by_type():
|
|
|
|
kids = [field_source(field_type='Tx'), field_source(field_type='Btn')]
|
|
|
|
source = field_source('topform', kids=iter(kids))
|
|
|
|
field = fieldsmod.FormField.by_type(source)
|
|
|
|
assert isinstance(field, fieldsmod.FormField)
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('flag', [
|
|
|
|
# If you add dedicated classes for these types of buttons, you can remove
|
|
|
|
# their test cases.
|
|
|
|
fieldsmod.FieldFlags.Radio,
|
|
|
|
fieldsmod.FieldFlags.Pushbutton,
|
|
|
|
])
|
|
|
|
def test_unsupported_button_by_type(flag):
|
|
|
|
source = field_source(field_type='Btn', flags=flag)
|
|
|
|
field = fieldsmod.FormField.by_type(source)
|
|
|
|
assert type(field) is fieldsmod.FormField
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('field_type', [
|
|
|
|
# If you add dedicated classes for these types of fields, you can remove
|
|
|
|
# their test cases.
|
|
|
|
'Ch',
|
|
|
|
'Sig',
|
|
|
|
])
|
|
|
|
def test_unsupported_field_by_type(field_type):
|
|
|
|
source = field_source(field_type=field_type)
|
|
|
|
field = fieldsmod.FormField.by_type(source)
|
|
|
|
assert type(field) is fieldsmod.FormField
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('value', [None, 'Off', 'Yes'])
|
|
|
|
def test_checkbox_value(value):
|
|
|
|
source = field_source('cb', value, 'Btn', literal=True)
|
|
|
|
field = fieldsmod.CheckboxField(source)
|
|
|
|
assert field.value() == (value and value == 'Yes')
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('value,expected', [
|
|
|
|
(None, None),
|
|
|
|
(False, 'Off'),
|
|
|
|
(True, 'Yes'),
|
|
|
|
])
|
|
|
|
def test_checkbox_set_value(value, expected):
|
|
|
|
source = field_source('cb', field_type='Btn')
|
|
|
|
field = fieldsmod.CheckboxField(source)
|
|
|
|
field.set_value(value)
|
|
|
|
actual = fieldsmod.FormField.value(field)
|
|
|
|
if expected is None:
|
|
|
|
assert actual is None
|
|
|
|
else:
|
|
|
|
assert actual.name == expected
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('on_key,off_key', itertools.product(
|
|
|
|
['1', '2', 'On', 'Yes'],
|
|
|
|
['Off', None],
|
|
|
|
))
|
|
|
|
def test_checkbox_options(on_key, off_key):
|
|
|
|
source = field_source('cb', field_type='Btn')
|
|
|
|
source['AP'] = {'N': appearance_states(on_key, off_key)}
|
|
|
|
field = fieldsmod.CheckboxField(source)
|
|
|
|
assert field.options() == [on_key, 'Off']
|
|
|
|
|
|
|
|
def test_checkbox_options_yes_no():
|
|
|
|
# I'm not sure this is actually allowed under the spec, but…
|
|
|
|
expected = ['Yes', 'No']
|
|
|
|
source = field_source('cb', field_type='Btn')
|
|
|
|
source['AP'] = {'N': appearance_states(*expected)}
|
|
|
|
field = fieldsmod.CheckboxField(source)
|
|
|
|
assert field.options() == expected
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('on_key,off_key,set_value', itertools.product(
|
|
|
|
['1', '2', 'On', 'Yes'],
|
|
|
|
['Off', None],
|
|
|
|
[True, False, None],
|
|
|
|
))
|
|
|
|
def test_checkbox_set_custom_value(on_key, off_key, set_value):
|
|
|
|
source = field_source('cb', field_type='Btn')
|
|
|
|
source['AP'] = {'N': appearance_states(on_key, off_key)}
|
|
|
|
field = fieldsmod.CheckboxField(source)
|
|
|
|
field.set_value(set_value)
|
|
|
|
actual = fieldsmod.FormField.value(field)
|
|
|
|
if set_value is None:
|
|
|
|
assert actual is None
|
|
|
|
elif set_value:
|
|
|
|
assert actual.name == (on_key or 'Yes')
|
|
|
|
else:
|
|
|
|
assert actual.name == 'Off'
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('encoding,prefix', [
|
|
|
|
('ascii', b''),
|
|
|
|
('utf-16be', codecs.BOM_UTF16_BE),
|
|
|
|
])
|
|
|
|
def test_text_value(encoding, prefix):
|
|
|
|
expected = f'{encoding} encoding test'
|
|
|
|
value = prefix + expected.encode(encoding)
|
|
|
|
source = field_source('t', value, 'Tx')
|
|
|
|
field = fieldsmod.TextField(source)
|
|
|
|
assert field.value() == expected
|
|
|
|
|
|
|
|
def test_text_value_none():
|
|
|
|
source = field_source(field_type='Tx')
|
|
|
|
assert fieldsmod.TextField(source).value() is None
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('text,bprefix', [
|
|
|
|
('ASCII test', b''),
|
|
|
|
('UTF—16 test', codecs.BOM_UTF16_BE),
|
|
|
|
])
|
|
|
|
def test_text_set_value(text, bprefix):
|
|
|
|
source = field_source(field_type='Tx')
|
|
|
|
field = fieldsmod.TextField(source)
|
|
|
|
field.set_value(text)
|
|
|
|
assert field.value() == text
|
|
|
|
actual = fieldsmod.FormField.value(field)
|
|
|
|
assert actual == bprefix + text.encode('utf-16be' if bprefix else 'ascii')
|
|
|
|
|
2021-01-05 19:59:36 +00:00
|
|
|
@pytest.mark.parametrize('expected', [
|
|
|
|
'0',
|
|
|
|
'32',
|
|
|
|
'32.45',
|
|
|
|
'32,768',
|
|
|
|
'32,768.95',
|
|
|
|
])
|
|
|
|
def test_text_set_value_numeric(expected):
|
|
|
|
num_s = expected.replace(',', '')
|
|
|
|
field = fieldsmod.TextField({})
|
|
|
|
num_types = [Decimal, float]
|
|
|
|
if '.' not in expected:
|
|
|
|
num_types.append(int)
|
|
|
|
for num_type in num_types:
|
|
|
|
field.set_value(num_type(num_s))
|
|
|
|
assert field.value() == expected
|
|
|
|
field.set_value(None)
|
|
|
|
|
2021-01-04 21:10:11 +00:00
|
|
|
def test_text_set_value_none():
|
|
|
|
source = field_source('t', b'set None test', 'Tx')
|
|
|
|
field = fieldsmod.TextField(source)
|
|
|
|
field.set_value(None)
|
|
|
|
assert fieldsmod.FormField.value(field) is None
|
|
|
|
|
|
|
|
def test_empty_as_filled_fdf():
|
|
|
|
source = field_source()
|
|
|
|
field = fieldsmod.FormField(source)
|
|
|
|
assert field.as_filled_fdf() == {}
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('field_type,field_class,set_value', [
|
|
|
|
('Btn', fieldsmod.CheckboxField, True),
|
|
|
|
('Btn', fieldsmod.CheckboxField, False),
|
|
|
|
('Ch', fieldsmod.FormField, None),
|
|
|
|
('Tx', fieldsmod.TextField, 'export test'),
|
|
|
|
('Tx', fieldsmod.TextField, 'UTF—16 export'),
|
|
|
|
])
|
|
|
|
def test_as_filled_fdf_after_set_value(field_type, field_class, set_value):
|
|
|
|
source = field_source(field_type, field_type=field_type)
|
|
|
|
field = field_class(source)
|
|
|
|
field.set_value(set_value)
|
|
|
|
actual = field.as_filled_fdf()
|
|
|
|
assert actual['T'] == field_type
|
|
|
|
expect_len = 2
|
|
|
|
if set_value is None:
|
|
|
|
assert 'V' not in actual
|
|
|
|
expect_len = 1
|
|
|
|
elif field_class is fieldsmod.CheckboxField:
|
|
|
|
assert actual['V'].name == ('Yes' if set_value else 'Off')
|
|
|
|
else:
|
|
|
|
assert actual['V'] == set_value
|
|
|
|
assert len(actual) == expect_len
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('field_type,expected', [
|
|
|
|
('Btn', None),
|
|
|
|
('Tx', ''),
|
|
|
|
])
|
|
|
|
def test_as_filled_fdf_default_value(field_type, expected):
|
|
|
|
source = field_source(field_type=field_type)
|
|
|
|
field = fieldsmod.FormField.by_type(source)
|
|
|
|
actual = field.as_filled_fdf()
|
|
|
|
assert actual.get('V') == expected
|
|
|
|
|
|
|
|
def test_as_filled_fdf_recursion():
|
|
|
|
buttons = [field_source(f'bt{n}', field_type='Btn') for n in range(1, 3)]
|
|
|
|
pair = field_source('Buttons', kids=iter(buttons))
|
|
|
|
text = field_source('tx', field_type='Tx')
|
|
|
|
source = field_source('topform', kids=[text, pair])
|
|
|
|
field = fieldsmod.FormField(source)
|
|
|
|
actual = field.as_filled_fdf()
|
|
|
|
assert actual['T'] == 'topform'
|
|
|
|
assert 'V' not in actual
|
|
|
|
actual = iter(actual['Kids'])
|
|
|
|
assert next(actual)['T'] == 'tx'
|
|
|
|
actual = next(actual)
|
|
|
|
assert actual['T'] == 'Buttons'
|
|
|
|
assert 'V' not in actual
|
|
|
|
actual = iter(actual['Kids'])
|
|
|
|
assert next(actual)['T'] == 'bt1'
|
|
|
|
assert next(actual)['T'] == 'bt2'
|
|
|
|
assert next(actual, None) is None
|
|
|
|
|
|
|
|
@pytest.mark.parametrize('name,value,field_type', [
|
|
|
|
(None, None, None),
|
|
|
|
('mt', 'mapping text', 'Tx'),
|
|
|
|
('mb', 'Yes', 'Btn'),
|
|
|
|
])
|
|
|
|
def test_simple_as_mapping(name, value, field_type):
|
|
|
|
source = field_source(name, value, field_type)
|
|
|
|
field = fieldsmod.FormField(source)
|
|
|
|
actual = field.as_mapping()
|
|
|
|
key, mapped = next(actual)
|
|
|
|
assert key == (name or '')
|
|
|
|
assert mapped is field
|
|
|
|
assert next(actual, None) is None
|
|
|
|
|
|
|
|
def test_recursive_as_mapping():
|
|
|
|
btn_kids = [field_source(f'btn{n}', field_type='Btn') for n in range(1, 3)]
|
|
|
|
buttons = field_source('buttons', kids=iter(btn_kids))
|
|
|
|
text_kids = [field_source(f'tx{n}', field_type='Tx') for n in range(1, 3)]
|
|
|
|
texts = field_source('texts', kids=iter(text_kids))
|
|
|
|
source = field_source('root', kids=[texts, buttons])
|
|
|
|
root_field = fieldsmod.FormField(source)
|
|
|
|
actual = root_field.as_mapping()
|
|
|
|
for expected_key in [
|
|
|
|
'root',
|
|
|
|
'root.texts',
|
|
|
|
'root.texts.tx1',
|
|
|
|
'root.texts.tx2',
|
|
|
|
'root.buttons',
|
|
|
|
'root.buttons.btn1',
|
|
|
|
'root.buttons.btn2',
|
|
|
|
]:
|
|
|
|
key, field = next(actual)
|
|
|
|
assert key == expected_key
|
|
|
|
_, _, expected_name = expected_key.rpartition('.')
|
|
|
|
assert field.name() == expected_name
|
|
|
|
assert next(actual, None) is None
|