aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorJay Berkenbilt <ejb@ql.org>2018-12-21 18:00:13 +0100
committerJay Berkenbilt <ejb@ql.org>2018-12-22 01:11:57 +0100
commitdd1aca552c912e1675d8927d9c945d6642cb8241 (patch)
treef6db0ed6b4630322cf8c07360b378e2febc95242
parent3c075fc01740a955eb8f192f565bf1f6c029c73e (diff)
downloadqpdf-dd1aca552c912e1675d8927d9c945d6642cb8241.tar.zst
Support bash completion using complete -C
-rw-r--r--ChangeLog5
-rw-r--r--qpdf/qpdf.cc315
-rw-r--r--qpdf/qtest/qpdf.test48
-rw-r--r--qpdf/qtest/qpdf/completion-decode-l.out2
-rw-r--r--qpdf/qtest/qpdf/completion-decode-level.out4
-rw-r--r--qpdf/qtest/qpdf/completion-enc.out1
-rw-r--r--qpdf/qtest/qpdf/completion-encrypt-128.out3
-rw-r--r--qpdf/qtest/qpdf/completion-encrypt-256.out3
-rw-r--r--qpdf/qtest/qpdf/completion-encrypt-40.out3
-rw-r--r--qpdf/qtest/qpdf/completion-encrypt-bad.out2
-rw-r--r--qpdf/qtest/qpdf/completion-encrypt-u-o.out3
-rw-r--r--qpdf/qtest/qpdf/completion-encrypt-u.out1
-rw-r--r--qpdf/qtest/qpdf/completion-encrypt.out2
-rw-r--r--qpdf/qtest/qpdf/completion-later-arg.out7
-rw-r--r--qpdf/qtest/qpdf/completion-split.out2
-rw-r--r--qpdf/qtest/qpdf/completion-top-arg.out7
-rw-r--r--qpdf/qtest/qpdf/completion-top.out3
-rw-r--r--qpdf/qtest/qpdf/encrypt-u2
-rw-r--r--qpdf/qtest/qpdf/filter-completion.pl39
19 files changed, 424 insertions, 28 deletions
diff --git a/ChangeLog b/ChangeLog
index aaf64d24..7818c98a 100644
--- a/ChangeLog
+++ b/ChangeLog
@@ -1,3 +1,8 @@
+2018-12-21 Jay Berkenbilt <ejb@ql.org>
+
+ * You can now use eval $(qpdf --completion-bash) to enable bash
+ completion for qpdf. It's not perfect, but it works pretty well.
+
2018-12-19 Jay Berkenbilt <ejb@ql.org>
* When splitting pages using --split-pages, the outlines
diff --git a/qpdf/qpdf.cc b/qpdf/qpdf.cc
index 7b348f99..b1028e0e 100644
--- a/qpdf/qpdf.cc
+++ b/qpdf/qpdf.cc
@@ -322,7 +322,7 @@ class ArgParser
void argShowLinearization();
void argShowXref();
void argShowObject(char* parameter);
- void argShowObject();
+ void argRawStreamData();
void argFilteredStreamData();
void argShowNpages();
void argShowPages();
@@ -344,10 +344,16 @@ class ArgParser
void argEndEncrypt();
void usage(std::string const& message);
+ void checkCompletion();
void initOptionTable();
- void handleHelpVersion();
+ void handleHelpArgs();
void handleArgFileArguments();
+ void handleBashArguments();
void readArgsFromFile(char const* filename);
+ void doFinalChecks();
+ void addOptionsToCompletions();
+ void addChoicesToCompletions(std::string const&);
+ void handleCompletion();
std::vector<PageSpec> parsePagesOptions();
void parseRotationParameter(std::string const&);
std::vector<int> parseNumrange(char const* range, int max,
@@ -359,6 +365,12 @@ class ArgParser
char** argv;
Options& o;
int cur_arg;
+ bool bash_completion;
+ std::string bash_prev;
+ std::string bash_cur;
+ std::string bash_line;
+ size_t bash_point;
+ std::set<std::string> completions;
std::map<std::string, OptionEntry>* option_table;
std::map<std::string, OptionEntry> main_option_table;
@@ -366,14 +378,18 @@ class ArgParser
std::map<std::string, OptionEntry> encrypt128_option_table;
std::map<std::string, OptionEntry> encrypt256_option_table;
std::vector<PointerHolder<char> > new_argv;
+ std::vector<PointerHolder<char> > bash_argv;
PointerHolder<char*> argv_ph;
+ PointerHolder<char*> bash_argv_ph;
};
ArgParser::ArgParser(int argc, char* argv[], Options& o) :
argc(argc),
argv(argv),
o(o),
- cur_arg(0)
+ cur_arg(0),
+ bash_completion(false),
+ bash_point(0)
{
option_table = &main_option_table;
initOptionTable();
@@ -496,7 +512,7 @@ ArgParser::initOptionTable()
(*t)["show-xref"] = oe_bare(&ArgParser::argShowXref);
(*t)["show-object"] = oe_requiredParameter(
&ArgParser::argShowObject, "obj[,gen]");
- (*t)["raw-stream-data"] = oe_bare(&ArgParser::argShowObject);
+ (*t)["raw-stream-data"] = oe_bare(&ArgParser::argRawStreamData);
(*t)["filtered-stream-data"] = oe_bare(&ArgParser::argFilteredStreamData);
(*t)["show-npages"] = oe_bare(&ArgParser::argShowNpages);
(*t)["show-pages"] = oe_bare(&ArgParser::argShowPages);
@@ -573,9 +589,30 @@ void
ArgParser::argEncrypt()
{
++cur_arg;
- if (cur_arg + 3 >= argc)
+ if (cur_arg + 3 > argc)
{
- usage("insufficient arguments to --encrypt");
+ if (this->bash_completion)
+ {
+ if (cur_arg == argc)
+ {
+ this->completions.insert("user-password");
+ }
+ else if (cur_arg + 1 == argc)
+ {
+ this->completions.insert("owner-password");
+ }
+ else if (cur_arg + 2 == argc)
+ {
+ this->completions.insert("40");
+ this->completions.insert("128");
+ this->completions.insert("256");
+ }
+ return;
+ }
+ else
+ {
+ usage("insufficient arguments to --encrypt");
+ }
}
o.user_password = argv[cur_arg++];
o.owner_password = argv[cur_arg++];
@@ -904,7 +941,7 @@ ArgParser::argShowObject(char* parameter)
}
void
-ArgParser::argShowObject()
+ArgParser::argRawStreamData()
{
o.show_raw_stream_data = true;
}
@@ -1098,14 +1135,55 @@ ArgParser::handleArgFileArguments()
argv[argc] = 0;
}
-// Note: let's not be too noisy about documenting the fact that this
-// software purposely fails to enforce the distinction between user
-// and owner passwords. A user password is sufficient to gain full
-// access to the PDF file, so there is nothing this software can do
-// with an owner password that it couldn't do with a user password
-// other than changing the /P value in the encryption dictionary.
-// (Setting this value requires the owner password.) The
-// documentation discusses this as well.
+void
+ArgParser::handleBashArguments()
+{
+ // Do a minimal job of parsing bash_line into arguments. This
+ // doesn't do everything the shell does, but it should be good
+ // enough for purposes of handling completion. We can't use
+ // new_argv because this has to interoperate with @file arguments.
+
+ enum { st_top, st_quote } state = st_top;
+ std::string arg;
+ for (std::string::iterator iter = bash_line.begin();
+ iter != bash_line.end(); ++iter)
+ {
+ char ch = (*iter);
+ if ((state == st_top) && QUtil::is_space(ch) && (! arg.empty()))
+ {
+ bash_argv.push_back(
+ PointerHolder<char>(
+ true, QUtil::copy_string(arg.c_str())));
+ arg.clear();
+ }
+ else
+ {
+ if (ch == '"')
+ {
+ state = (state == st_top ? st_quote : st_top);
+ }
+ arg.append(1, ch);
+ }
+ }
+ if (bash_argv.empty())
+ {
+ // This can't happen if properly invoked by bash, but ensure
+ // we have a valid argv[0] regardless.
+ bash_argv.push_back(
+ PointerHolder<char>(
+ true, QUtil::copy_string(argv[0])));
+ }
+ // Explicitly discard any non-space-terminated word. The "current
+ // word" is handled specially.
+ bash_argv_ph = PointerHolder<char*>(true, new char*[1+bash_argv.size()]);
+ argv = bash_argv_ph.getPointer();
+ for (size_t i = 0; i < bash_argv.size(); ++i)
+ {
+ argv[i] = bash_argv.at(i).getPointer();
+ }
+ argc = static_cast<int>(bash_argv.size());
+ argv[argc] = 0;
+}
char const* ArgParser::help = "\
\n\
@@ -1127,6 +1205,7 @@ Basic Options\n\
--version show version of qpdf\n\
--copyright show qpdf's copyright and license information\n\
--help show command-line argument help\n\
+--completion-bash output a bash complete command you can eval\n\
--password=password specify a password for accessing encrypted files\n\
--verbose provide additional informational output\n\
--progress give progress indicators while writing output\n\
@@ -1412,7 +1491,15 @@ void usageExit(std::string const& msg)
void
ArgParser::usage(std::string const& message)
{
- usageExit(message);
+ if (this->bash_completion)
+ {
+ // This will cause bash to fall back to regular file completion.
+ exit(0);
+ }
+ else
+ {
+ usageExit(message);
+ }
}
static JSON json_schema()
@@ -1718,13 +1805,33 @@ ArgParser::readArgsFromFile(char const* filename)
}
void
-ArgParser::handleHelpVersion()
+ArgParser::handleHelpArgs()
{
- // Make sure the output looks right on an 80-column display.
+ // Handle special-case informational options that are only
+ // available as the sole option.
+
+ // The options processed here are also handled as a special case
+ // in handleCompletion.
- if ((argc == 2) &&
- ((strcmp(argv[1], "--version") == 0) ||
- (strcmp(argv[1], "-version") == 0)))
+ if (argc != 2)
+ {
+ return;
+ }
+ char* arg = argv[1];
+ if (*arg != '-')
+ {
+ return;
+ }
+ ++arg;
+ if (*arg == '-')
+ {
+ ++arg;
+ }
+ if (! *arg)
+ {
+ return;
+ }
+ if (strcmp(arg, "version") == 0)
{
std::cout
<< whoami << " version " << QPDF::QPDFVersion() << std::endl
@@ -1733,10 +1840,9 @@ ArgParser::handleHelpVersion()
exit(0);
}
- if ((argc == 2) &&
- ((strcmp(argv[1], "--copyright") == 0) ||
- (strcmp(argv[1], "-copyright") == 0)))
+ if (strcmp(arg, "copyright") == 0)
{
+ // Make sure the output looks right on an 80-column display.
// 1 2 3 4 5 6 7 8
// 12345678901234567890123456789012345678901234567890123456789012345678901234567890
std::cout
@@ -1776,13 +1882,25 @@ ArgParser::handleHelpVersion()
exit(0);
}
- if ((argc == 2) &&
- ((strcmp(argv[1], "--help") == 0) ||
- (strcmp(argv[1], "-help") == 0)))
+ if (strcmp(arg, "help") == 0)
{
std::cout << help;
exit(0);
}
+
+ if (strcmp(arg, "completion-bash") == 0)
+ {
+ std::string path = argv[0];
+ size_t slash = path.find('/');
+ if ((slash != 0) && (slash != std::string::npos))
+ {
+ std::cerr << "WARNING: qpdf completion enabled"
+ << " using relative path to qpdf" << std::endl;
+ }
+ std::cout << "complete -o bashdefault -o default -o nospace"
+ << " -C " << argv[0] << " " << whoami << std::endl;
+ exit(0);
+ }
}
void
@@ -1851,9 +1969,37 @@ ArgParser::parseRotationParameter(std::string const& parameter)
}
void
+ArgParser::checkCompletion()
+{
+ // See if we're being invoked from bash completion.
+ std::string bash_point_env;
+ if (QUtil::get_env("COMP_LINE", &bash_line) &&
+ QUtil::get_env("COMP_POINT", &bash_point_env))
+ {
+ int p = QUtil::string_to_int(bash_point_env.c_str());
+ if ((p > 0) && (p <= static_cast<int>(bash_line.length())))
+ {
+ // Point to the last character
+ bash_point = static_cast<size_t>(p) - 1;
+ }
+ if (argc >= 4)
+ {
+ bash_cur = argv[2];
+ bash_prev = argv[3];
+ handleBashArguments();
+ bash_completion = true;
+ }
+ }
+}
+
+void
ArgParser::parseOptions()
{
- handleHelpVersion(); // QXXXQ calls std::cout
+ checkCompletion();
+ if (! this->bash_completion)
+ {
+ handleHelpArgs();
+ }
handleArgFileArguments();
for (cur_arg = 1; cur_arg < argc; ++cur_arg)
{
@@ -1957,7 +2103,19 @@ ArgParser::parseOptions()
usage(std::string("unknown argument ") + arg);
}
}
+ if (this->bash_completion)
+ {
+ handleCompletion();
+ }
+ else
+ {
+ doFinalChecks();
+ }
+}
+void
+ArgParser::doFinalChecks()
+{
if (this->option_table != &(this->main_option_table))
{
usage("missing -- at end of options");
@@ -2002,6 +2160,107 @@ ArgParser::parseOptions()
}
}
+void
+ArgParser::addChoicesToCompletions(std::string const& option)
+{
+ if (this->option_table->count(option) != 0)
+ {
+ OptionEntry& oe = (*this->option_table)[option];
+ for (std::set<std::string>::iterator iter = oe.choices.begin();
+ iter != oe.choices.end(); ++iter)
+ {
+ completions.insert(*iter);
+ }
+ }
+}
+
+void
+ArgParser::addOptionsToCompletions()
+{
+ for (std::map<std::string, OptionEntry>::iterator iter =
+ this->option_table->begin();
+ iter != this->option_table->end(); ++iter)
+ {
+ std::string const& arg = (*iter).first;
+ OptionEntry& oe = (*iter).second;
+ std::string base = "--" + arg;
+ if (oe.param_arg_handler)
+ {
+ completions.insert(base + "=");
+ }
+ if (! oe.parameter_needed)
+ {
+ completions.insert(base);
+ }
+ }
+}
+
+void
+ArgParser::handleCompletion()
+{
+ if (this->completions.empty())
+ {
+ // Detect --option=... Bash treats the = as a word separator.
+ std::string choice_option;
+ if (bash_cur.empty() && (bash_prev.length() > 2) &&
+ (bash_prev.at(0) == '-') &&
+ (bash_prev.at(1) == '-') &&
+ (bash_line.at(bash_point) == '='))
+ {
+ choice_option = bash_prev.substr(2, std::string::npos);
+ }
+ else if ((bash_prev == "=") &&
+ (bash_line.length() > (bash_cur.length() + 1)))
+ {
+ // We're sitting at --option=x. Find previous option.
+ size_t end_mark = bash_line.length() - bash_cur.length() - 1;
+ char before_cur = bash_line.at(end_mark);
+ if (before_cur == '=')
+ {
+ size_t space = bash_line.find_last_of(' ', end_mark);
+ if (space != std::string::npos)
+ {
+ std::string candidate =
+ bash_line.substr(space + 1, end_mark - space - 1);
+ if ((candidate.length() > 2) &&
+ (candidate.at(0) == '-') &&
+ (candidate.at(1) == '-'))
+ {
+ choice_option =
+ candidate.substr(2, std::string::npos);
+ }
+ }
+ }
+ }
+ if (! choice_option.empty())
+ {
+ addChoicesToCompletions(choice_option);
+ }
+ else if ((! bash_cur.empty()) && (bash_cur.at(0) == '-'))
+ {
+ addOptionsToCompletions();
+ if (this->argc == 1)
+ {
+ // Handle options usually handled by handleHelpArgs.
+ this->completions.insert("--help");
+ this->completions.insert("--version");
+ this->completions.insert("--copyright");
+ this->completions.insert("--completion-bash");
+ }
+ }
+ }
+ for (std::set<std::string>::iterator iter = completions.begin();
+ iter != completions.end(); ++iter)
+ {
+ if (this->bash_cur.empty() ||
+ ((*iter).substr(0, bash_cur.length()) == bash_cur))
+ {
+ std::cout << *iter << std::endl;
+ }
+ }
+ exit(0);
+}
+
static void set_qpdf_options(QPDF& pdf, Options& o)
{
if (o.ignore_xref_streams)
diff --git a/qpdf/qtest/qpdf.test b/qpdf/qtest/qpdf.test
index 3a1f30dd..8a5d2099 100644
--- a/qpdf/qtest/qpdf.test
+++ b/qpdf/qtest/qpdf.test
@@ -100,6 +100,36 @@ $td->runtest("UTF-16 encoding errors",
{$td->FILE => "unicode-errors.out", $td->EXIT_STATUS => 0},
$td->NORMALIZE_NEWLINES);
+my @completion_tests = (
+ ['qpdf ', undef, 'top'],
+ ['qpdf -', undef, 'top-arg'],
+ ['qpdf --enc', undef, 'enc'],
+ ['qpdf --encrypt ', undef, 'encrypt'],
+ ['qpdf --encrypt u ', undef, 'encrypt-u'],
+ ['qpdf --encrypt u o ', undef, 'encrypt-u-o'],
+ ['qpdf @encrypt-u o ', undef, 'encrypt-u-o'],
+ ['qpdf --encrypt u o 40 --', undef, 'encrypt-40'],
+ ['qpdf --encrypt u o 128 --', undef, 'encrypt-128'],
+ ['qpdf --encrypt u o 256 --', undef, 'encrypt-256'],
+ ['qpdf --encrypt u o bad --', undef, 'encrypt-bad'],
+ ['qpdf --split-pag', undef, 'split'],
+ ['qpdf --decode-l', undef, 'decode-l'],
+ ['qpdf --decode-lzzz', 15, 'decode-l'],
+ ['qpdf --decode-level=', undef, 'decode-level'],
+ ['qpdf --check -', undef, 'later-arg'],
+ );
+$n_tests += scalar(@completion_tests);
+foreach my $c (@completion_tests)
+{
+ my ($cmd, $point, $description) = @$c;
+ my $out = "completion-$description.out";
+ $td->runtest("bash completion: $description",
+ {$td->COMMAND => [@{bash_completion($cmd, $point)}],
+ $td->FILTER => "perl filter-completion.pl $out"},
+ {$td->FILE => "$out", $td->EXIT_STATUS => 0},
+ $td->NORMALIZE_NEWLINES);
+}
+
show_ntests();
# ----------
$td->notify("--- Argument Parsing ---");
@@ -3144,6 +3174,24 @@ sub show_ntests
}
}
+sub bash_completion
+{
+ my ($line, $point) = @_;
+ if (! defined $point)
+ {
+ $point = length($line);
+ }
+ my $before_point = substr($line, 0, $point);
+ $before_point =~ m/^(.*)([ =])([^= ]*)$/ or die;
+ my ($first, $sep, $cur) = ($1, $2, $3);
+ my $prev = ($sep eq '=' ? $sep : $first);
+ $prev =~ s/.* (\S+)$/$1/;
+ my $this = $first;
+ $this =~ s/(\S+)\s.*/$1/;
+ ['env', "COMP_LINE=$line", "COMP_POINT=$point",
+ "qpdf", $this, $cur, $prev];
+}
+
sub check_pdf
{
my ($description, $command, $output, $status) = @_;
diff --git a/qpdf/qtest/qpdf/completion-decode-l.out b/qpdf/qtest/qpdf/completion-decode-l.out
new file mode 100644
index 00000000..ca228636
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-decode-l.out
@@ -0,0 +1,2 @@
+--decode-level=
+!--help
diff --git a/qpdf/qtest/qpdf/completion-decode-level.out b/qpdf/qtest/qpdf/completion-decode-level.out
new file mode 100644
index 00000000..776f1e1f
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-decode-level.out
@@ -0,0 +1,4 @@
+all
+generalized
+none
+!--help
diff --git a/qpdf/qtest/qpdf/completion-enc.out b/qpdf/qtest/qpdf/completion-enc.out
new file mode 100644
index 00000000..994ef604
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-enc.out
@@ -0,0 +1 @@
+--encrypt
diff --git a/qpdf/qtest/qpdf/completion-encrypt-128.out b/qpdf/qtest/qpdf/completion-encrypt-128.out
new file mode 100644
index 00000000..2c51a616
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-encrypt-128.out
@@ -0,0 +1,3 @@
+--force-V4
+!--annotate=
+!--force-R5
diff --git a/qpdf/qtest/qpdf/completion-encrypt-256.out b/qpdf/qtest/qpdf/completion-encrypt-256.out
new file mode 100644
index 00000000..7033fbab
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-encrypt-256.out
@@ -0,0 +1,3 @@
+--force-R5
+!--annotate=
+!--force-V4
diff --git a/qpdf/qtest/qpdf/completion-encrypt-40.out b/qpdf/qtest/qpdf/completion-encrypt-40.out
new file mode 100644
index 00000000..3a184a6b
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-encrypt-40.out
@@ -0,0 +1,3 @@
+--annotate=
+!--force-R5
+!--force-V4
diff --git a/qpdf/qtest/qpdf/completion-encrypt-bad.out b/qpdf/qtest/qpdf/completion-encrypt-bad.out
new file mode 100644
index 00000000..3732840b
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-encrypt-bad.out
@@ -0,0 +1,2 @@
+!--help
+!--print
diff --git a/qpdf/qtest/qpdf/completion-encrypt-u-o.out b/qpdf/qtest/qpdf/completion-encrypt-u-o.out
new file mode 100644
index 00000000..ad6bc9cf
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-encrypt-u-o.out
@@ -0,0 +1,3 @@
+128
+256
+40
diff --git a/qpdf/qtest/qpdf/completion-encrypt-u.out b/qpdf/qtest/qpdf/completion-encrypt-u.out
new file mode 100644
index 00000000..0d8a0622
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-encrypt-u.out
@@ -0,0 +1 @@
+owner-password
diff --git a/qpdf/qtest/qpdf/completion-encrypt.out b/qpdf/qtest/qpdf/completion-encrypt.out
new file mode 100644
index 00000000..4577a128
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-encrypt.out
@@ -0,0 +1,2 @@
+user-password
+!--print
diff --git a/qpdf/qtest/qpdf/completion-later-arg.out b/qpdf/qtest/qpdf/completion-later-arg.out
new file mode 100644
index 00000000..33c129ff
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-later-arg.out
@@ -0,0 +1,7 @@
+--check
+--decode-level=
+--encrypt
+!--completion-bash
+!--copyright
+!--help
+!--version
diff --git a/qpdf/qtest/qpdf/completion-split.out b/qpdf/qtest/qpdf/completion-split.out
new file mode 100644
index 00000000..12423c95
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-split.out
@@ -0,0 +1,2 @@
+--split-pages
+--split-pages=
diff --git a/qpdf/qtest/qpdf/completion-top-arg.out b/qpdf/qtest/qpdf/completion-top-arg.out
new file mode 100644
index 00000000..0214970d
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-top-arg.out
@@ -0,0 +1,7 @@
+--check
+--completion-bash
+--copyright
+--decode-level=
+--encrypt
+--help
+--version
diff --git a/qpdf/qtest/qpdf/completion-top.out b/qpdf/qtest/qpdf/completion-top.out
new file mode 100644
index 00000000..26ae7664
--- /dev/null
+++ b/qpdf/qtest/qpdf/completion-top.out
@@ -0,0 +1,3 @@
+!--copyright
+!--help
+!--version
diff --git a/qpdf/qtest/qpdf/encrypt-u b/qpdf/qtest/qpdf/encrypt-u
new file mode 100644
index 00000000..9d413960
--- /dev/null
+++ b/qpdf/qtest/qpdf/encrypt-u
@@ -0,0 +1,2 @@
+--encrypt
+u
diff --git a/qpdf/qtest/qpdf/filter-completion.pl b/qpdf/qtest/qpdf/filter-completion.pl
new file mode 100644
index 00000000..a81178e2
--- /dev/null
+++ b/qpdf/qtest/qpdf/filter-completion.pl
@@ -0,0 +1,39 @@
+use warnings;
+use strict;
+
+# Output every line from STDIN that appears in the file.
+my %wanted = ();
+my %notwanted = ();
+my $f = $ARGV[0];
+if (open(F, "<$f"))
+{
+ while (<F>)
+ {
+ chomp;
+ if (s/^!//)
+ {
+ $notwanted{$_} = 1;
+ }
+ else
+ {
+ $wanted{$_} = 1;
+ }
+ }
+ close(F);
+}
+while (<STDIN>)
+{
+ chomp;
+ if (exists $wanted{$_})
+ {
+ print $_, "\n";
+ }
+ elsif (exists $notwanted{$_})
+ {
+ delete $notwanted{$_};
+ }
+}
+foreach my $k (sort keys %notwanted)
+{
+ print "!$k\n";
+}