diff --git a/.flake8 b/.flake8
index 44091fbb1..122507c9a 100644
--- a/.flake8
+++ b/.flake8
@@ -4,4 +4,5 @@
 ignore = E203,E501,E741,W503,W604,N817,N814,VNE001,VNE002,VNE003,N802,SIM105,P101
 exclude = build,sample-files
 per-file-ignores =
-    tests/*: ASS001,PT011,B011
+    tests/*: ASS001,PT011,B011,T001
+    make_changelog.py:T001
diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs
new file mode 100644
index 000000000..e4f010aae
--- /dev/null
+++ b/.git-blame-ignore-revs
@@ -0,0 +1,15 @@
+# This file helps us to ignore style / formatting / doc changes
+# in git blame. That is useful when we're trying to find the root cause of an
+# error.
+
+# Docstring formatting
+a89ff74d8c0203278a039d9496a3d8df4d134f84
+
+# STY: Apply pre-commit (black, isort) + use snake_case variables (#832)
+eef03d935dfeacaa75848b39082cf94d833d3174
+
+# STY: Apply black and isort
+baeb7d23278de0f8d00ca9f2b656bf0674f08937
+
+# STY: Documentation, Variable names (#839)
+444fca22836df061d9d23e71ffb7d68edcdfa766
diff --git a/.github/workflows/github-ci.yaml b/.github/workflows/github-ci.yaml
index 67fb0e1a3..8b1583ba8 100644
--- a/.github/workflows/github-ci.yaml
+++ b/.github/workflows/github-ci.yaml
@@ -88,6 +88,13 @@ jobs:
       - run: check-wheel-contents dist/*.whl
       - name: Check long_description
         run: python -m twine check dist/*
+        
+      - name: Test installing package
+        run: python -m pip install .
+
+      - name: Test running installed package
+        working-directory: /tmp
+        run: python -c "import PyPDF2;print(PyPDF2.__version__)"
 
       # - name: Release to pypi if tagged.
       #   if: startsWith(github.ref, 'refs/tags')
diff --git a/.gitignore b/.gitignore
index 97f93ad19..6449fe86b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -25,10 +25,12 @@ docs/_build/
 # Files generated by some of the scripts
 dont_commit_*.pdf
 PyPDF2-output.pdf
+annotated-pdf-link.pdf
 Image9.png
 PyPDF2_pdfLocation.txt
 
 .python-version
 tests/pdf_cache/
 docs/meta/CHANGELOG.md
+docs/meta/CONTRIBUTORS.md
 extracted-images/
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 891d4bcd2..ffb52bab4 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -30,7 +30,7 @@ repos:
     hooks:
     -   id: isort
 -   repo: https://github.com/psf/black
-    rev: 22.3.0
+    rev: 22.6.0
     hooks:
     -   id: black
         args: [--target-version, py36]
@@ -40,7 +40,7 @@ repos:
     -   id: blacken-docs
         additional_dependencies: [black==22.1.0]
 -   repo: https://github.com/asottile/pyupgrade
-    rev: v2.34.0
+    rev: v2.37.3
     hooks:
     -   id: pyupgrade
         args: [--py36-plus]
diff --git a/.pylintrc b/.pylintrc
new file mode 100644
index 000000000..9956cc2fc
--- /dev/null
+++ b/.pylintrc
@@ -0,0 +1,601 @@
+[MASTER]
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-allow-list=
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code. (This is an alternative name to extension-pkg-allow-list
+# for backward compatibility.)
+extension-pkg-whitelist=
+
+# Return non-zero exit code if any of these messages/categories are detected,
+# even if score is above --fail-under value. Syntax same as enable. Messages
+# specified are enabled, while categories only check already-enabled messages.
+fail-on=
+
+# Specify a score threshold to be exceeded before program exits with error.
+fail-under=10.0
+
+# Files or directories to be skipped. They should be base names, not paths.
+ignore=CVS
+
+# Add files or directories matching the regex patterns to the ignore-list. The
+# regex matches against paths and can be in Posix or Windows format.
+ignore-paths=
+
+# Files or directories matching the regex patterns are skipped. The regex
+# matches against base names, not paths. The default value ignores emacs file
+# locks
+ignore-patterns=^\.#
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
+# number of processors available to use.
+jobs=1
+
+# Control the amount of potential inferred values when inferring a single
+# object. This can help the performance when dealing with large functions or
+# complex, nested conditions.
+limit-inference-results=100
+
+# List of plugins (as comma separated values of python module names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# Minimum Python version to use for version dependent checks. Will default to
+# the version used to run pylint.
+py-version=3.6
+
+# Discover python modules and packages in the file system subtree.
+recursive=no
+
+# When enabled, pylint would attempt to guess common misconfiguration and emit
+# user-friendly hints instead of false-positive error messages.
+suggestion-mode=yes
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=no
+
+
+[MESSAGES CONTROL]
+
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
+# UNDEFINED.
+confidence=
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then re-enable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=raw-checker-failed,
+        bad-inline-option,
+        locally-disabled,
+        file-ignored,
+        suppressed-message,
+        useless-suppression,
+        deprecated-pragma,
+        use-symbolic-message-instead,
+        R1705,
+        C0301,  # Line too long => black takes care of it
+        missing-module-docstring,
+        # Temporarily disable as there are too many things to do:
+        missing-function-docstring,
+        missing-class-docstring,
+        # Temporarily disable as we cannot change it at the moment:
+        C0103,  # non-snake-case method => we have many deprecations
+        broad-except,
+        keyword-arg-before-vararg,  # TODO: change this before 3.0.0?
+        redefined-builtin,
+        # Doesn't lead to better code in many cases:
+        no-else-continue,
+        no-else-raise,
+        no-else-break,
+        too-few-public-methods,
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=c-extension-no-member
+
+
+[REPORTS]
+
+# Python expression which should return a score less than or equal to 10. You
+# have access to the variables 'fatal', 'error', 'warning', 'refactor',
+# 'convention', and 'info' which contain the number of messages in each
+# category, as well as 'statement' which is the total number of statements
+# analyzed. This score is used by the global evaluation report (RP0004).
+evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details.
+#msg-template=
+
+# Set the output format. Available formats are text, parseable, colorized, json
+# and msvs (visual studio). You can also give a reporter class, e.g.
+# mypackage.mymodule.MyReporterClass.
+output-format=text
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
+
+
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+# Complete name of functions that never returns. When checking for
+# inconsistent-return-statements if a never returning function is called then
+# it will be considered as an explicit return statement and no message will be
+# printed.
+never-returning-functions=sys.exit,argparse.parse_error
+
+
+[SPELLING]
+
+# Limits count of emitted suggestions for spelling mistakes.
+max-spelling-suggestions=4
+
+# Spelling dictionary name. Available dictionaries: none. To make it work,
+# install the 'python-enchant' package.
+spelling-dict=
+
+# List of comma separated words that should be considered directives if they
+# appear and the beginning of a comment and should not be checked.
+spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains the private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to the private dictionary (see the
+# --spelling-private-dict-file option) instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+      XXX,
+      TODO
+
+# Regular expression of note tags to take in consideration.
+#notes-rgx=
+
+
+[SIMILARITIES]
+
+# Comments are removed from the similarity computation
+ignore-comments=yes
+
+# Docstrings are removed from the similarity computation
+ignore-docstrings=yes
+
+# Imports are removed from the similarity computation
+ignore-imports=no
+
+# Signatures are removed from the similarity computation
+ignore-signatures=no
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+
+[STRING]
+
+# This flag controls whether inconsistent-quotes generates a warning when the
+# character used as a quote delimiter is used inconsistently within a module.
+check-quote-consistency=no
+
+# This flag controls whether the implicit-str-concat should generate a warning
+# on implicit string concatenation in sequences defined over several lines.
+check-str-concat-over-line-jumps=no
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+# Tells whether missing members accessed in mixin class should be ignored. A
+# class is considered mixin if its name matches the mixin-class-rgx option.
+ignore-mixin-members=yes
+
+# Tells whether to warn about missing members when the owner of the attribute
+# is inferred to be None.
+ignore-none=yes
+
+# This flag controls whether pylint should warn about no-member and similar
+# checks whenever an opaque object is returned when inferring. The inference
+# can return multiple potential results while evaluating a Python object, but
+# some branches might not be evaluated, which results in partial inference. In
+# that case, it might be useful to still emit no-member and other checks for
+# the rest of the inferred objects.
+ignore-on-opaque-inference=yes
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local
+
+# List of module names for which member attributes should not be checked
+# (useful for modules/projects where namespaces are manipulated during runtime
+# and thus existing member attributes cannot be deduced by static analysis). It
+# supports qualified module names, as well as Unix pattern matching.
+ignored-modules=
+
+# Show a hint with possible names when a member name was not found. The aspect
+# of finding the hint is based on edit distance.
+missing-member-hint=yes
+
+# The minimum edit distance a name should have in order to be considered a
+# similar match for a missing member name.
+missing-member-hint-distance=1
+
+# The total number of similar names that should be taken in consideration when
+# showing a hint for a missing member.
+missing-member-max-choices=1
+
+# Regex pattern to define which classes are considered mixins ignore-mixin-
+# members is set to 'yes'
+mixin-class-rgx=.*[Mm]ixin
+
+# List of decorators that change the signature of a decorated function.
+signature-mutators=
+
+
+[BASIC]
+
+# Naming style matching correct argument names.
+argument-naming-style=snake_case
+
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style. If left empty, argument names will be checked with the set
+# naming style.
+#argument-rgx=
+
+# Naming style matching correct attribute names.
+attr-naming-style=snake_case
+
+# Regular expression matching correct attribute names. Overrides attr-naming-
+# style. If left empty, attribute names will be checked with the set naming
+# style.
+#attr-rgx=
+
+# Bad variable names which should always be refused, separated by a comma.
+bad-names=foo,
+          bar,
+          baz,
+          toto,
+          tutu,
+          tata
+
+# Bad variable names regexes, separated by a comma. If names match any regex,
+# they will always be refused
+bad-names-rgxs=
+
+# Naming style matching correct class attribute names.
+class-attribute-naming-style=any
+
+# Regular expression matching correct class attribute names. Overrides class-
+# attribute-naming-style. If left empty, class attribute names will be checked
+# with the set naming style.
+#class-attribute-rgx=
+
+# Naming style matching correct class constant names.
+class-const-naming-style=UPPER_CASE
+
+# Regular expression matching correct class constant names. Overrides class-
+# const-naming-style. If left empty, class constant names will be checked with
+# the set naming style.
+#class-const-rgx=
+
+# Naming style matching correct class names.
+class-naming-style=PascalCase
+
+# Regular expression matching correct class names. Overrides class-naming-
+# style. If left empty, class names will be checked with the set naming style.
+#class-rgx=
+
+# Naming style matching correct constant names.
+const-naming-style=UPPER_CASE
+
+# Regular expression matching correct constant names. Overrides const-naming-
+# style. If left empty, constant names will be checked with the set naming
+# style.
+#const-rgx=
+
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=-1
+
+# Naming style matching correct function names.
+function-naming-style=snake_case
+
+# Regular expression matching correct function names. Overrides function-
+# naming-style. If left empty, function names will be checked with the set
+# naming style.
+#function-rgx=
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=i,
+           j,
+           k,
+           ex,
+           Run,
+           _
+
+# Good variable names regexes, separated by a comma. If names match any regex,
+# they will always be accepted
+good-names-rgxs=
+
+# Include a hint for the correct naming format with invalid-name.
+include-naming-hint=no
+
+# Naming style matching correct inline iteration names.
+inlinevar-naming-style=any
+
+# Regular expression matching correct inline iteration names. Overrides
+# inlinevar-naming-style. If left empty, inline iteration names will be checked
+# with the set naming style.
+#inlinevar-rgx=
+
+# Naming style matching correct method names.
+method-naming-style=snake_case
+
+# Regular expression matching correct method names. Overrides method-naming-
+# style. If left empty, method names will be checked with the set naming style.
+#method-rgx=
+
+# Naming style matching correct module names.
+module-naming-style=snake_case
+
+# Regular expression matching correct module names. Overrides module-naming-
+# style. If left empty, module names will be checked with the set naming style.
+#module-rgx=
+
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
+
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=^_
+
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+# These decorators are taken in consideration only for invalid-name.
+property-classes=abc.abstractproperty
+
+# Regular expression matching correct type variable names. If left empty, type
+# variable names will be checked with the set naming style.
+#typevar-rgx=
+
+# Naming style matching correct variable names.
+variable-naming-style=snake_case
+
+# Regular expression matching correct variable names. Overrides variable-
+# naming-style. If left empty, variable names will be checked with the set
+# naming style.
+#variable-rgx=
+
+
+[LOGGING]
+
+# The type of string formatting that logging methods do. `old` means using %
+# formatting, `new` is for `{}` formatting.
+logging-format-style=old
+
+# Logging modules to check that the string format arguments are in logging
+# function parameter format.
+logging-modules=logging
+
+
+[FORMAT]
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
+
+# Regexp for a line that is allowed to be longer than the limit.
+ignore-long-lines=^\s*(# )?<?https?://\S+>?$
+
+# Number of spaces of indent required inside a hanging or continued line.
+indent-after-paren=4
+
+# String used as indentation unit. This is usually "    " (4 spaces) or "\t" (1
+# tab).
+indent-string='    '
+
+# Maximum number of characters on a single line.
+max-line-length=100
+
+# Maximum number of lines in a module.
+max-module-lines=1000
+
+# Allow the body of a class to be on the same line as the declaration if body
+# contains single statement.
+single-line-class-stmt=no
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=no
+
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid defining new builtins when possible.
+additional-builtins=
+
+# Tells whether unused global variables should be treated as a violation.
+allow-global-unused-variables=yes
+
+# List of names allowed to shadow builtins
+allowed-redefined-builtins=
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,
+          _cb
+
+# A regular expression matching the name of dummy variables (i.e. expected to
+# not be used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Argument names that match this expression will be ignored. Default to name
+# with leading underscore.
+ignored-argument-names=_.*|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
+
+
+[IMPORTS]
+
+# List of modules that can be imported at any level, not just the top level
+# one.
+allow-any-import-level=
+
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=no
+
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=no
+
+# Deprecated modules which should not be used, separated by a comma.
+deprecated-modules=
+
+# Output a graph (.gv or any supported image format) of external dependencies
+# to the given file (report RP0402 must not be disabled).
+ext-import-graph=
+
+# Output a graph (.gv or any supported image format) of all (i.e. internal and
+# external) dependencies to the given file (report RP0402 must not be
+# disabled).
+import-graph=
+
+# Output a graph (.gv or any supported image format) of internal dependencies
+# to the given file (report RP0402 must not be disabled).
+int-import-graph=
+
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
+
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant
+
+# Couples of modules and preferred modules, separated by a comma.
+preferred-modules=
+
+
+[DESIGN]
+
+# List of regular expressions of class ancestor names to ignore when counting
+# public methods (see R0903)
+exclude-too-few-public-methods=
+
+# List of qualified class names to ignore when counting class parents (see
+# R0901)
+ignored-parents=
+
+# Maximum number of arguments for function / method.
+max-args=5
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=7
+
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=5
+
+# Maximum number of branch for function / method body.
+max-branches=12
+
+# Maximum number of locals for function / method body.
+max-locals=15
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
+
+# Maximum number of return / yield for function / method body.
+max-returns=6
+
+# Maximum number of statements in function / method body.
+max-statements=50
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
+
+
+[CLASSES]
+
+# Warn about protected attribute access inside special methods
+check-protected-access-in-special-methods=no
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+                      __new__,
+                      setUp,
+                      __post_init__
+
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,
+                  _fields,
+                  _replace,
+                  _source,
+                  _make
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
+
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=cls
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when being caught. Defaults to
+# "BaseException, Exception".
+overgeneral-exceptions=BaseException,
+                       Exception
diff --git a/CHANGELOG.md b/CHANGELOG.md
index cd4e1e021..c41f56340 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,239 @@
 # CHANGELOG
 
+## Version 2.10.4, 2022-08-28
+
+### Robustness (ROB)
+-  Fix errors/warnings on no /Resources within extract_text (#1276)
+-  Add required line separators in ContentStream ArrayObjects (#1281)
+
+### Maintenance (MAINT)
+-  Use NameObject idempotency (#1290)
+
+### Testing (TST)
+-  Rectangle deletion (#1289)
+-  Add workflow tests (#1287)
+-  Remove files after tests ran (#1286)
+
+### Packaging (PKG)
+-  Add minimum version for typing_extensions requirement (#1277)
+
+Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.10.3...2.10.4
+
+## Version 2.10.3, 2022-08-21
+
+### Robustness (ROB)
+-  Decrypt returns empty bytestring (#1258)
+
+### Developer Experience (DEV)
+-  Modify CI to better verify built package contents (#1244)
+
+### Maintenance (MAINT)
+-  Remove 'mine' as PdfMerger always creates the stream (#1261)
+-  Let PdfMerger._create_stream raise NotImplemented (#1251)
+-  password param of _security._alg32(...) is only a string, not bytes (#1259)
+-  Remove unreachable code in read_block_backwards (#1250)
+   and sign function in _extract_text (#1262)
+
+### Testing (TST)
+-  Delete annotations (#1263)
+-  Close PdfMerger in tests (#1260)
+-  PdfReader.xmp_metadata workflow (#1257)
+-  Various PdfWriter (Layout, Bookmark deprecation) (#1249)
+
+Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.10.2...2.10.3
+
+## Version 2.10.2, 2022-08-15
+
+BUG: Add PyPDF2.generic to PyPI distribution
+
+## Version 2.10.1, 2022-08-15
+
+### Bug Fixes (BUG)
+-  TreeObject.remove_child had a non-PdfObject assignment for Count (#1233, #1234)
+-  Fix stream truncated prematurely (#1223)
+
+### Documentation (DOC)
+-  Fix docstring formatting (#1228)
+
+### Maintenance (MAINT)
+-  Split generic.py (#1229)
+
+### Testing (TST)
+-  Decrypt AlgV4 with owner password (#1239)
+-  AlgV5.generate_values (#1238)
+-  TreeObject.remove_child / empty_tree (#1235, #1236)
+-  create_string_object (#1232)
+-  Free-Text annotations (#1231)
+-  generic._base (#1230)
+-  Strict get fonts (#1226)
+-  Increase PdfReader coverage (#1219, #1225)
+-  Increase PdfWriter coverage (#1237)
+-  100% coverage for utils.py (#1217)
+-  PdfWriter exception non-binary stream (#1218)
+-  Don't check coverage for deprecated code (#1216)
+
+Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.10.0...2.10.1
+
+
+## Version 2.10.0, 2022-08-07
+
+### New Features (ENH)
+-  "with" support for PdfMerger and PdfWriter (#1193)
+-  Add AnnotationBuilder.text(...) to build text annotations (#1202)
+
+### Bug Fixes (BUG)
+-  Allow IndirectObjects as stream filters (#1211)
+
+### Documentation (DOC)
+-  Font scrambling
+-  Page vs Content scaling (#1208)
+-  Example for orientation parameter of extract_text (#1206)
+-  Fix AnnotationBuilder parameter formatting (#1204)
+
+### Developer Experience (DEV)
+-  Add flake8-print (#1203)
+
+### Maintenance (MAINT)
+-  Introduce WrongPasswordError / FileNotDecryptedError / EmptyFileError  (#1201)
+
+Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.9.0...2.10.0
+
+## Version 2.9.0, 2022-07-31
+
+### New Features (ENH)
+-  Add ability to add hex encoded colors to outline items (#1186)
+-  Add support for pathlib.Path in PdfMerger.merge (#1190)
+-  Add link annotation (#1189)
+-  Add capability to filter text extraction by orientation (#1175)
+
+### Bug Fixes (BUG)
+-  Named Dest in PDF1.1 (#1174)
+-  Incomplete Graphic State save/restore (#1172)
+
+### Documentation (DOC)
+-  Update changelog url in package metadata (#1180)
+-  Mantion camelot for table extraction (#1179)
+-  Mention pyHanko for signing PDF documents (#1178)
+-  Weow have CMAP support since a while (#1177)
+
+### Maintenance (MAINT)
+-  Consistant usage of warnings / log messages (#1164)
+-  Consistent terminology for outline items (#1156)
+
+
+Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.8.1...2.9.0
+
+## Version 2.8.1, 2022-07-25
+
+### Bug Fixes (BUG)
+-  u_hash in AlgV4.compute_key (#1170)
+
+### Robustness (ROB)
+-  Fix loading of file from #134 (#1167)
+-  Cope with empty DecodeParams (#1165)
+
+### Documentation (DOC)
+-  Typo in merger deprecation warning message (#1166)
+
+### Maintenance (MAINT)
+-  Package updates; solve mypy strict remarks (#1163)
+
+### Testing (TST)
+-  Add test from #325 (#1169)
+
+Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.8.0...2.8.1
+
+
+## Version 2.8.0, 2022-07-24
+
+### New Features (ENH)
+-  Add writer.add_annotation, page.annotations, and generic.AnnotationBuilder (#1120)
+
+### Bug Fixes (BUG)
+-  Set /AS for /Btn form fields in writer (#1161)
+-  Ignore if /Perms verify failed (#1157)
+
+### Robustness (ROB)
+-  Cope with utf16 character for space calculation (#1155)
+-  Cope with null params for FitH / FitV destination (#1152)
+-  Handle outlines without valid destination (#1076)
+
+### Developer Experience (DEV)
+-  Introduce _utils.logger_warning (#1148)
+
+### Maintenance (MAINT)
+-  Break up parse_to_unicode (#1162)
+-  Add diagnostic output to exception in read_from_stream (#1159)
+-  Reduce PdfReader.read complexity (#1151)
+
+### Testing (TST)
+-  Add workflow tests found by arc testing (#1154)
+-  Decrypt file which is not encrypted (#1149)
+-  Test CryptRC4 encryption class; test image extraction filters (#1147)
+
+Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.7.0...2.8.0
+
+## Version 2.7.0, 2022-07-21
+
+### New Features (ENH)
+-  Add `outline_count` property (#1129)
+
+### Bug Fixes (BUG)
+-  Make reader.get_fields also return dropdowns with options (#1114)
+-  Add deprecated EncodedStreamObject functions back until PyPDF2==3.0.0 (#1139)
+
+### Robustness (ROB)
+-  Cope with missing /W entry (#1136)
+-  Cope with invalid parent xref (#1133)
+
+### Documentation (DOC)
+-  Contributors file (#1132)
+-  Fix type in signature of PdfWriter.add_uri (#1131)
+
+### Developer Experience (DEV)
+-  Add .git-blame-ignore-revs (#1141)
+
+### Code Style (STY)
+-  Fixing typos (#1137)
+-  Re-use code via get_outlines_property in tests (#1130)
+
+Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.6.0...2.7.0
+
+## Version 2.6.0, 2022-07-17
+
+### New Features (ENH)
+-  Add color and font_format to PdfReader.outlines[i] (#1104)
+-  Extract Text Enhancement (whitespaces) (#1084)
+
+### Bug Fixes (BUG)
+-  Use `build_destination` for named destination outlines (#1128)
+-  Avoid a crash when a ToUnicode CMap has an empty dstString in beginbfchar (#1118)
+-  Prevent deduplication of PageObject (#1105)
+-  None-check in DictionaryObject.read_from_stream (#1113)
+-  Avoid IndexError in _cmap.parse_to_unicode (#1110)
+
+### Documentation (DOC)
+-  Explanation for git submodule
+-  Watermark and stamp (#1095)
+
+### Maintenance (MAINT)
+-  Text extraction improvements (#1126)
+-  Destination.color returns ArrayObject instead of tuple as fallback (#1119)
+-  Use add_bookmark_destination in add_bookmark (#1100)
+-  Use add_bookmark_destination in add_bookmark_dict (#1099)
+
+### Testing (TST)
+-  Add test for arab text (#1127)
+-  Add xfail for decryption fail (#1125)
+-  Add xfail test for IndexError when extracting text (#1124)
+-  Add MCVE showing outline title issue (#1123)
+
+### Code Style (STY)
+-  Use IntFlag for permissions_flag / update_page_form_field_values (#1094)
+-  Simplify code (#1101)
+
+Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.5.0...2.6.0
+
 ## Version 2.5.0, 2022-07-10
 
 ### New Features (ENH)
@@ -38,7 +272,7 @@
 -  Apply black
 -  Typo in Changelog
 
-Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.4.2...2.4.3
+Full Changelog: https://github.com/py-pdf/PyPDF2/compare/2.4.2...2.5.0
 
 ## Version 2.4.2, 2022-07-05
 
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
new file mode 100644
index 000000000..a0ec2945c
--- /dev/null
+++ b/CONTRIBUTORS.md
@@ -0,0 +1,53 @@
+# Contributors
+
+PyPDF2 had a lot of contributors since it started with pyPdf in 2005. We are
+a free software project without any company affiliation. We cannot pay
+contributors, but we do value their contributions. A lot of time, effort, and
+expertise went into this project. With this list, we recognize those awesome
+people 🤗
+
+The list is definitely not complete. You can find more contributors via the git
+history and [GitHubs 'Contributors' feature](https://github.com/py-pdf/PyPDF2/graphs/contributors).
+
+## Contributors to the pyPdf / PyPDF2 project
+
+* [DL6ER](https://github.com/DL6ER)
+* [JianzhengLuo](https://github.com/JianzhengLuo)
+* [Karvonen, Harry](https://github.com/Hatell/)
+* [KourFrost](https://github.com/KourFrost)
+* [Lightup1](https://github.com/Lightup1)
+* [Pinheiro, Arthur](https://github.com/xilopaint)
+* [pubpub-zz](https://github.com/pubpub-zz): involved in community development
+* [Thoma, Martin](https://github.com/MartinThoma): Maintainer of PyPDF2 since April 2022. I hope to build a great community with many awesome contributors. [LinkedIn](https://www.linkedin.com/in/martin-thoma/) | [StackOverflow](https://stackoverflow.com/users/562769/martin-thoma) | [Blog](https://martin-thoma.com/)
+* [WevertonGomes](https://github.com/WevertonGomesCosta)
+* ztravis
+
+## Adding a new contributor
+
+Contributors are:
+
+* Anybody who has an commit in main - no matter how big/small or how many. Also if it's via co-authored-by.
+* People who opened helpful issues:
+  (1) Bugs: with complete MCVE
+  (2) Well-described feature requests
+  (3) Potentially some more.
+  The maintainers of PyPDF2 have the last call on that one.
+* Community work: This is exceptional. If the maintainers of PyPDF2 see people
+  being super helpful in answering issues / discussions or being very active on
+  Stackoverflow, we also consider them being contributors to PyPDF2.
+
+Contributors can add themselves or ask via an Github Issue to be added.
+
+Please use the following format:
+
+```
+* Last name, First name: 140-characters of text; links to linkedin / github / other profiles and personal pages are ok
+
+OR
+
+* GitHub Username: 140-characters of text; links to linkedin / github / other profiles and personal pages are ok
+```
+
+and add the entry in the alphabetical order. People who . The 140 characters are everything visible after the `Name:`.
+
+Please don't use images.
diff --git a/Makefile b/Makefile
index caa6c463d..b08cb9d51 100644
--- a/Makefile
+++ b/Makefile
@@ -31,3 +31,9 @@ mutation-results:
 
 benchmark:
 	pytest tests/bench.py
+
+mypy:
+	mypy PyPDF2 --ignore-missing-imports --check-untyped --strict
+
+pylint:
+	pylint PyPDF2
diff --git a/PyPDF2/_cmap.py b/PyPDF2/_cmap.py
index a8b06663c..afce26088 100644
--- a/PyPDF2/_cmap.py
+++ b/PyPDF2/_cmap.py
@@ -3,6 +3,7 @@
 from typing import Any, Dict, List, Tuple, Union, cast
 
 from ._codecs import adobe_glyphs, charset_encoding
+from ._utils import logger_warning
 from .errors import PdfReadWarning
 from .generic import DecodedStreamObject, DictionaryObject
 
@@ -35,22 +36,33 @@ def build_char_map(
         for x in int_entry:
             if x <= 255:
                 encoding[x] = chr(x)
-    if font_name in _default_fonts_space_width:
+    try:
         # override space_width with new params
-        space_width = _default_fonts_space_width[font_name]
-    sp_width = compute_space_width(ft, space_code, space_width)
+        space_width = _default_fonts_space_width[cast(str, ft["/BaseFont"])]
+    except Exception:
+        pass
+    # I conside the space_code is available on one byte
+    if isinstance(space_code, str):
+        try:  # one byte
+            sp = space_code.encode("charmap")[0]
+        except Exception:
+            sp = space_code.encode("utf-16-be")
+            sp = sp[0] + 256 * sp[1]
+    else:
+        sp = space_code
+    sp_width = compute_space_width(ft, sp, space_width)
 
     return (
         font_type,
         float(sp_width / 2),
         encoding,
         # https://github.com/python/mypy/issues/4374
-        map_dict,  # type: ignore
-    )  # type: ignore
+        map_dict,
+    )
 
 
 # used when missing data, e.g. font def missing
-unknown_char_map: Tuple[str, float, Union[str, Dict[int, str]], Dict] = (
+unknown_char_map: Tuple[str, float, Union[str, Dict[int, str]], Dict[Any, Any]] = (
     "Unknown",
     9999,
     dict(zip(range(256), ["�"] * 256)),
@@ -97,7 +109,7 @@ def parse_encoding(
     encoding: Union[str, List[str], Dict[int, str]] = []
     if "/Encoding" not in ft:
         try:
-            if "/BaseFont" in ft and ft["/BaseFont"] in charset_encoding:
+            if "/BaseFont" in ft and cast(str, ft["/BaseFont"]) in charset_encoding:
                 encoding = dict(
                     zip(range(256), charset_encoding[cast(str, ft["/BaseFont"])])
                 )
@@ -105,7 +117,7 @@ def parse_encoding(
                 encoding = "charmap"
             return encoding, _default_fonts_space_width[cast(str, ft["/BaseFont"])]
         except Exception:
-            if ft["/Subtype"] == "/Type1":
+            if cast(str, ft["/Subtype"]) == "/Type1":
                 return "charmap", space_code
             else:
                 return "", space_code
@@ -156,19 +168,31 @@ def parse_encoding(
 
 def parse_to_unicode(
     ft: DictionaryObject, space_code: int
-) -> Tuple[Dict, int, List[int]]:
-    map_dict: Dict[
-        Any, Any
-    ] = (
-        {}
-    )  # will store all translation code and map_dict[-1] we will have the number of bytes to convert
-    int_entry: List[
-        int
-    ] = []  # will provide the list of cmap keys as int to correct encoding
+) -> Tuple[Dict[Any, Any], int, List[int]]:
+    # will store all translation code
+    # and map_dict[-1] we will have the number of bytes to convert
+    map_dict: Dict[Any, Any] = {}
+
+    # will provide the list of cmap keys as int to correct encoding
+    int_entry: List[int] = []
+
     if "/ToUnicode" not in ft:
         return {}, space_code, []
     process_rg: bool = False
     process_char: bool = False
+    cm = prepare_cm(ft)
+    for l in cm.split(b"\n"):
+        process_rg, process_char = process_cm_line(
+            l.strip(b" "), process_rg, process_char, map_dict, int_entry
+        )
+
+    for a, value in map_dict.items():
+        if value == " ":
+            space_code = a
+    return map_dict, space_code, int_entry
+
+
+def prepare_cm(ft: DictionaryObject) -> bytes:
     cm: bytes = cast(DecodedStreamObject, ft["/ToUnicode"]).get_data()
     # we need to prepare cm before due to missing return line in pdf printed to pdf from word
     cm = (
@@ -184,74 +208,97 @@ def parse_to_unicode(
     for i in range(len(ll)):
         j = ll[i].find(b">")
         if j >= 0:
-            ll[i] = ll[i][:j].replace(b" ", b"") + b" " + ll[i][j + 1 :]
+            if j == 0:
+                # string is empty: stash a placeholder here (see below)
+                # see https://github.com/py-pdf/PyPDF2/issues/1111
+                content = b"."
+            else:
+                content = ll[i][:j].replace(b" ", b"")
+            ll[i] = content + b" " + ll[i][j + 1 :]
     cm = (
         (b" ".join(ll))
         .replace(b"[", b" [ ")
         .replace(b"]", b" ]\n ")
         .replace(b"\r", b"\n")
     )
+    return cm
 
-    for l in cm.split(b"\n"):
-        if l in (b"", b" "):
-            continue
-        if b"beginbfrange" in l:
-            process_rg = True
-        elif b"endbfrange" in l:
-            process_rg = False
-        elif b"beginbfchar" in l:
-            process_char = True
-        elif b"endbfchar" in l:
-            process_char = False
-        elif process_rg:
-            lst = [x for x in l.split(b" ") if x]
-            a = int(lst[0], 16)
-            b = int(lst[1], 16)
-            nbi = len(lst[0])
-            map_dict[-1] = nbi // 2
-            fmt = b"%%0%dX" % nbi
-            if lst[2] == b"[":
-                for sq in lst[3:]:
-                    if sq == b"]":
-                        break
-                    map_dict[
-                        unhexlify(fmt % a).decode(
-                            "charmap" if map_dict[-1] == 1 else "utf-16-be",
-                            "surrogatepass",
-                        )
-                    ] = unhexlify(sq).decode("utf-16-be", "surrogatepass")
-                    int_entry.append(a)
-                    a += 1
-            else:
-                c = int(lst[2], 16)
-                fmt2 = b"%%0%dX" % len(lst[2])
-                while a <= b:
-                    map_dict[
-                        unhexlify(fmt % a).decode(
-                            "charmap" if map_dict[-1] == 1 else "utf-16-be",
-                            "surrogatepass",
-                        )
-                    ] = unhexlify(fmt2 % c).decode("utf-16-be", "surrogatepass")
-                    int_entry.append(a)
-                    a += 1
-                    c += 1
-        elif process_char:
-            lst = [x for x in l.split(b" ") if x]
-            map_dict[-1] = len(lst[0]) // 2
-            while len(lst) > 0:
-                map_dict[
-                    unhexlify(lst[0]).decode(
-                        "charmap" if map_dict[-1] == 1 else "utf-16-be", "surrogatepass"
-                    )
-                ] = unhexlify(lst[1]).decode(
-                    "utf-16-be", "surrogatepass"
-                )  # join is here as some cases where the code was split
-                int_entry.append(int(lst[0], 16))
-                lst = lst[2:]
-    for a, value in map_dict.items():
-        if value == " ":
-            space_code = a
-    return map_dict, space_code, int_entry
+
+def process_cm_line(
+    l: bytes,
+    process_rg: bool,
+    process_char: bool,
+    map_dict: Dict[Any, Any],
+    int_entry: List[int],
+) -> Tuple[bool, bool]:
+    if l in (b"", b" ") or l[0] == 37:  # 37 = %
+        return process_rg, process_char
+    if b"beginbfrange" in l:
+        process_rg = True
+    elif b"endbfrange" in l:
+        process_rg = False
+    elif b"beginbfchar" in l:
+        process_char = True
+    elif b"endbfchar" in l:
+        process_char = False
+    elif process_rg:
+        parse_bfrange(l, map_dict, int_entry)
+    elif process_char:
+        parse_bfchar(l, map_dict, int_entry)
+    return process_rg, process_char
+
+
+def parse_bfrange(l: bytes, map_dict: Dict[Any, Any], int_entry: List[int]) -> None:
+    lst = [x for x in l.split(b" ") if x]
+    a = int(lst[0], 16)
+    b = int(lst[1], 16)
+    nbi = len(lst[0])
+    map_dict[-1] = nbi // 2
+    fmt = b"%%0%dX" % nbi
+    if lst[2] == b"[":
+        for sq in lst[3:]:
+            if sq == b"]":
+                break
+            map_dict[
+                unhexlify(fmt % a).decode(
+                    "charmap" if map_dict[-1] == 1 else "utf-16-be",
+                    "surrogatepass",
+                )
+            ] = unhexlify(sq).decode("utf-16-be", "surrogatepass")
+            int_entry.append(a)
+            a += 1
+    else:
+        c = int(lst[2], 16)
+        fmt2 = b"%%0%dX" % max(4, len(lst[2]))
+        while a <= b:
+            map_dict[
+                unhexlify(fmt % a).decode(
+                    "charmap" if map_dict[-1] == 1 else "utf-16-be",
+                    "surrogatepass",
+                )
+            ] = unhexlify(fmt2 % c).decode("utf-16-be", "surrogatepass")
+            int_entry.append(a)
+            a += 1
+            c += 1
+
+
+def parse_bfchar(l: bytes, map_dict: Dict[Any, Any], int_entry: List[int]) -> None:
+    lst = [x for x in l.split(b" ") if x]
+    map_dict[-1] = len(lst[0]) // 2
+    while len(lst) > 1:
+        map_to = ""
+        # placeholder (see above) means empty string
+        if lst[1] != b".":
+            map_to = unhexlify(lst[1]).decode(
+                "utf-16-be", "surrogatepass"
+            )  # join is here as some cases where the code was split
+        map_dict[
+            unhexlify(lst[0]).decode(
+                "charmap" if map_dict[-1] == 1 else "utf-16-be", "surrogatepass"
+            )
+        ] = map_to
+        int_entry.append(int(lst[0], 16))
+        lst = lst[2:]
 
 
 def compute_space_width(
@@ -259,30 +306,43 @@ def compute_space_width(
 ) -> float:
     sp_width: float = space_width * 2  # default value
     w = []
+    w1 = {}
     st: int = 0
-    if "/W" in ft:
-        if "/DW" in ft:
-            sp_width = cast(float, ft["/DW"])
-        w = list(ft["/W"])  # type: ignore
+    if "/DescendantFonts" in ft:  # ft["/Subtype"].startswith("/CIDFontType"):
+        ft1 = ft["/DescendantFonts"][0].get_object()  # type: ignore
+        try:
+            w1[-1] = cast(float, ft1["/DW"])
+        except Exception:
+            w1[-1] = 1000.0
+        if "/W" in ft1:
+            w = list(ft1["/W"])
+        else:
+            w = []
         while len(w) > 0:
             st = w[0]
             second = w[1]
-            if isinstance(int, second):
-                if st <= space_code and space_code <= second:
-                    sp_width = w[2]
-                    break
+            if isinstance(second, int):
+                for x in range(st, second):
+                    w1[x] = w[2]
                 w = w[3:]
-            if isinstance(list, second):
-                if st <= space_code and space_code <= st + len(second) - 1:
-                    sp_width = second[space_code - st]
+            elif isinstance(second, list):
+                for y in second:
+                    w1[st] = y
+                    st += 1
                 w = w[2:]
             else:
-                warnings.warn(
-                    "unknown widths : \n" + (ft["/W"]).__repr__(),
-                    PdfReadWarning,
+                logger_warning(
+                    "unknown widths : \n" + (ft1["/W"]).__repr__(),
+                    __name__,
                 )
                 break
-    if "/Widths" in ft:
+        try:
+            sp_width = w1[space_code]
+        except Exception:
+            sp_width = (
+                w1[-1] / 2.0
+            )  # if using default we consider space will be only half size
+    elif "/Widths" in ft:
         w = list(ft["/Widths"])  # type: ignore
         try:
             st = cast(int, ft["/FirstChar"])
diff --git a/PyPDF2/_encryption.py b/PyPDF2/_encryption.py
index 4327baf63..80cd369b6 100644
--- a/PyPDF2/_encryption.py
+++ b/PyPDF2/_encryption.py
@@ -29,8 +29,9 @@
 import random
 import struct
 from enum import IntEnum
-from typing import Optional, Tuple, Union, cast
+from typing import Any, Dict, Optional, Tuple, Union, cast
 
+from PyPDF2._utils import logger_warning
 from PyPDF2.errors import DependencyError
 from PyPDF2.generic import (
     ArrayObject,
@@ -84,7 +85,10 @@ def decrypt(self, data: bytes) -> bytes:
             data = data[16:]
             aes = AES.new(self.key, AES.MODE_CBC, iv)
             d = aes.decrypt(data)
-            return d[: -d[-1]]
+            if len(d) == 0:
+                return d
+            else:
+                return d[: -d[-1]]
 
     def RC4_encrypt(key: bytes, data: bytes) -> bytes:
         return ARC4.ARC4Cipher(key).encrypt(data)
@@ -287,7 +291,7 @@ def compute_key(
         u_hash.update(o_entry)
         u_hash.update(struct.pack("<I", P))
         u_hash.update(id1_entry)
-        if rev >= 3 and not metadata_encrypted:
+        if rev >= 4 and metadata_encrypted is False:
             u_hash.update(b"\xff\xff\xff\xff")
         u_hash_digest = u_hash.digest()
         length = key_size // 8
@@ -565,7 +569,7 @@ def verify_perms(
     @staticmethod
     def generate_values(
         user_pwd: bytes, owner_pwd: bytes, key: bytes, p: int, metadata_encrypted: bool
-    ) -> dict:
+    ) -> Dict[Any, Any]:
         u_value, ue_value = AlgV5.compute_U_value(user_pwd, key)
         o_value, oe_value = AlgV5.compute_O_value(owner_pwd, key, u_value)
         perms = AlgV5.compute_Perms_value(key, p, metadata_encrypted)
@@ -771,7 +775,9 @@ def verify_v4(self, password: bytes) -> Tuple[bytes, PasswordType]:
         R = cast(int, self.entry["/R"])
         P = cast(int, self.entry["/P"])
         P = (P + 0x100000000) % 0x100000000  # maybe < 0
-        metadata_encrypted = self.entry.get("/EncryptMetadata", True)
+        # make type(metadata_encrypted) == bool
+        em = self.entry.get("/EncryptMetadata")
+        metadata_encrypted = em.value if em else True
         o_entry = cast(ByteStringObject, self.entry["/O"].get_object()).original_bytes
         u_entry = cast(ByteStringObject, self.entry["/U"].get_object()).original_bytes
 
@@ -826,7 +832,7 @@ def verify_v5(self, password: bytes) -> Tuple[bytes, PasswordType]:
         P = (P + 0x100000000) % 0x100000000  # maybe < 0
         metadata_encrypted = self.entry.get("/EncryptMetadata", True)
         if not AlgV5.verify_perms(key, perms, P, metadata_encrypted):
-            return b"", PasswordType.NOT_DECRYPTED
+            logger_warning("ignore '/Perms' verify failed", __name__)
         return key, rc
 
     @staticmethod
@@ -845,7 +851,7 @@ def read(encryption_entry: DictionaryObject, first_id_entry: bytes) -> "Encrypti
 
         V = encryption_entry.get("/V", 0)
         if V not in (1, 2, 3, 4, 5):
-            raise NotImplementedError("Encryption V=%d NOT supported" % V)
+            raise NotImplementedError(f"Encryption V={V} NOT supported")
         if V >= 4:
             filters = encryption_entry["/CF"]
 
diff --git a/PyPDF2/_merger.py b/PyPDF2/_merger.py
index 319a47ccd..8fc697f02 100644
--- a/PyPDF2/_merger.py
+++ b/PyPDF2/_merger.py
@@ -26,19 +26,35 @@
 # POSSIBILITY OF SUCH DAMAGE.
 
 from io import BytesIO, FileIO, IOBase
-from typing import Any, Dict, Iterable, List, Optional, Tuple, Union, cast
+from pathlib import Path
+from types import TracebackType
+from typing import (
+    Any,
+    Dict,
+    Iterable,
+    List,
+    Optional,
+    Tuple,
+    Type,
+    Union,
+    cast,
+)
 
 from ._encryption import Encryption
 from ._page import PageObject
 from ._reader import PdfReader
-from ._utils import StrByteType, deprecate_with_replacement, str_
+from ._utils import (
+    StrByteType,
+    deprecate_bookmark,
+    deprecate_with_replacement,
+    str_,
+)
 from ._writer import PdfWriter
 from .constants import GoToActionArguments
 from .constants import PagesAttributes as PA
 from .constants import TypArguments, TypFitArguments
 from .generic import (
     ArrayObject,
-    Bookmark,
     Destination,
     DictionaryObject,
     FloatObject,
@@ -46,11 +62,12 @@
     NameObject,
     NullObject,
     NumberObject,
+    OutlineItem,
     TextStringObject,
     TreeObject,
 )
 from .pagerange import PageRange, PageRangeSpec
-from .types import FitType, LayoutType, OutlinesType, PagemodeType, ZoomArgType
+from .types import FitType, LayoutType, OutlineType, PagemodeType, ZoomArgType
 
 ERR_CLOSED_WRITER = "close() was called and thus the writer cannot be used anymore"
 
@@ -78,24 +95,46 @@ class PdfMerger:
     :param bool strict: Determines whether user should be warned of all
             problems and also causes some correctable problems to be fatal.
             Defaults to ``False``.
+    :param fileobj: Output file. Can be a filename or any kind of
+            file-like object.
     """
 
-    def __init__(self, strict: bool = False) -> None:
-        self.inputs: List[Tuple[Any, PdfReader, bool]] = []
+    @deprecate_bookmark(bookmarks="outline")
+    def __init__(
+        self, strict: bool = False, fileobj: Union[Path, StrByteType] = ""
+    ) -> None:
+        self.inputs: List[Tuple[Any, PdfReader]] = []
         self.pages: List[Any] = []
         self.output: Optional[PdfWriter] = PdfWriter()
-        self.bookmarks: OutlinesType = []
+        self.outline: OutlineType = []
         self.named_dests: List[Any] = []
         self.id_count = 0
+        self.fileobj = fileobj
         self.strict = strict
 
+    def __enter__(self) -> "PdfMerger":
+        # There is nothing to do.
+        return self
+
+    def __exit__(
+        self,
+        exc_type: Optional[Type[BaseException]],
+        exc: Optional[BaseException],
+        traceback: Optional[TracebackType],
+    ) -> None:
+        """Write to the fileobj and close the merger."""
+        if self.fileobj:
+            self.write(self.fileobj)
+        self.close()
+
+    @deprecate_bookmark(bookmark="outline_item", import_bookmarks="import_outline")
     def merge(
         self,
         position: int,
-        fileobj: Union[StrByteType, PdfReader],
-        bookmark: Optional[str] = None,
+        fileobj: Union[Path, StrByteType, PdfReader],
+        outline_item: Optional[str] = None,
         pages: Optional[PageRangeSpec] = None,
-        import_bookmarks: bool = True,
+        import_outline: bool = True,
     ) -> None:
         """
         Merge the pages from the given file into the output file at the
@@ -108,24 +147,25 @@ def merge(
             read and seek methods similar to a File Object. Could also be a
             string representing a path to a PDF file.
 
-        :param str bookmark: Optionally, you may specify a bookmark to be
-            applied at the beginning of the included file by supplying the text
-            of the bookmark.
+        :param str outline_item: Optionally, you may specify an outline item
+            (previously referred to as a 'bookmark') to be applied at the
+            beginning of the included file by supplying the text of the outline item.
 
         :param pages: can be a :class:`PageRange<PyPDF2.pagerange.PageRange>`
             or a ``(start, stop[, step])`` tuple
             to merge only the specified range of pages from the source
             document into the output document.
 
-        :param bool import_bookmarks: You may prevent the source document's
-            bookmarks from being imported by specifying this as ``False``.
+        :param bool import_outline: You may prevent the source document's
+            outline (collection of outline items, previously referred to as
+            'bookmarks') from being imported by specifying this as ``False``.
         """
-        stream, my_file, encryption_obj = self._create_stream(fileobj)
+        stream, encryption_obj = self._create_stream(fileobj)
 
         # Create a new PdfReader instance using the stream
         # (either file or BytesIO or StringIO) created above
         reader = PdfReader(stream, strict=self.strict)  # type: ignore[arg-type]
-        self.inputs.append((stream, reader, my_file))
+        self.inputs.append((stream, reader))
         if encryption_obj is not None:
             reader._encryption = encryption_obj
 
@@ -140,19 +180,19 @@ def merge(
         srcpages = []
 
         outline = []
-        if import_bookmarks:
-            outline = reader.outlines
+        if import_outline:
+            outline = reader.outline
             outline = self._trim_outline(reader, outline, pages)
 
-        if bookmark:
-            bookmark_typ = Bookmark(
-                TextStringObject(bookmark),
+        if outline_item:
+            outline_item_typ = OutlineItem(
+                TextStringObject(outline_item),
                 NumberObject(self.id_count),
                 NameObject(TypFitArguments.FIT),
             )
-            self.bookmarks += [bookmark_typ, outline]  # type: ignore
+            self.outline += [outline_item_typ, outline]  # type: ignore
         else:
-            self.bookmarks += outline
+            self.outline += outline
 
         dests = reader.named_destinations
         trimmed_dests = self._trim_dests(reader, dests, pages)
@@ -170,18 +210,14 @@ def merge(
             srcpages.append(mp)
 
         self._associate_dests_to_pages(srcpages)
-        self._associate_bookmarks_to_pages(srcpages)
+        self._associate_outline_items_to_pages(srcpages)
 
         # Slice to insert the pages at the specified position
         self.pages[position:position] = srcpages
 
     def _create_stream(
-        self, fileobj: Union[StrByteType, PdfReader]
-    ) -> Tuple[IOBase, bool, Optional[Encryption]]:
-        # This parameter is passed to self.inputs.append and means
-        # that the stream used was created in this method.
-        my_file = False
-
+        self, fileobj: Union[Path, StrByteType, PdfReader]
+    ) -> Tuple[IOBase, Optional[Encryption]]:
         # If the fileobj parameter is a string, assume it is a path
         # and create a file object at that location. If it is a file,
         # copy the file's contents into a BytesIO stream object; if
@@ -190,9 +226,8 @@ def _create_stream(
         # If fileobj is none of the above types, it is not modified
         encryption_obj = None
         stream: IOBase
-        if isinstance(fileobj, str):
+        if isinstance(fileobj, (str, Path)):
             stream = FileIO(fileobj, "rb")
-            my_file = True
         elif isinstance(fileobj, PdfReader):
             if fileobj._encryption:
                 encryption_obj = fileobj._encryption
@@ -202,23 +237,26 @@ def _create_stream(
 
             # reset the stream to its original location
             fileobj.stream.seek(orig_tell)
-
-            my_file = True
         elif hasattr(fileobj, "seek") and hasattr(fileobj, "read"):
             fileobj.seek(0)
             filecontent = fileobj.read()
             stream = BytesIO(filecontent)
-            my_file = True
         else:
-            stream = fileobj
-        return stream, my_file, encryption_obj
+            raise NotImplementedError(
+                "PdfMerger.merge requires an object that PdfReader can parse. "
+                "Typically, that is a Path or a string representing a Path, "
+                "a file object, or an object implementing .seek and .read. "
+                "Passing a PdfReader directly works as well."
+            )
+        return stream, encryption_obj
 
+    @deprecate_bookmark(bookmark="outline_item", import_bookmarks="import_outline")
     def append(
         self,
-        fileobj: Union[StrByteType, PdfReader],
-        bookmark: Optional[str] = None,
+        fileobj: Union[StrByteType, PdfReader, Path],
+        outline_item: Optional[str] = None,
         pages: Union[None, PageRange, Tuple[int, int], Tuple[int, int, int]] = None,
-        import_bookmarks: bool = True,
+        import_outline: bool = True,
     ) -> None:
         """
         Identical to the :meth:`merge()<merge>` method, but assumes you want to
@@ -229,21 +267,22 @@ def append(
             read and seek methods similar to a File Object. Could also be a
             string representing a path to a PDF file.
 
-        :param str bookmark: Optionally, you may specify a bookmark to be
-            applied at the beginning of the included file by supplying the text
-            of the bookmark.
+        :param str outline_item: Optionally, you may specify an outline item
+            (previously referred to as a 'bookmark') to be applied at the
+            beginning of the included file by supplying the text of the outline item.
 
         :param pages: can be a :class:`PageRange<PyPDF2.pagerange.PageRange>`
             or a ``(start, stop[, step])`` tuple
             to merge only the specified range of pages from the source
             document into the output document.
 
-        :param bool import_bookmarks: You may prevent the source document's
-            bookmarks from being imported by specifying this as ``False``.
+        :param bool import_outline: You may prevent the source document's
+            outline (collection of outline items, previously referred to as
+            'bookmarks') from being imported by specifying this as ``False``.
         """
-        self.merge(len(self.pages), fileobj, bookmark, pages, import_bookmarks)
+        self.merge(len(self.pages), fileobj, outline_item, pages, import_outline)
 
-    def write(self, fileobj: StrByteType) -> None:
+    def write(self, fileobj: Union[Path, StrByteType]) -> None:
         """
         Write all data that has been merged to the given output file.
 
@@ -252,10 +291,6 @@ def write(self, fileobj: StrByteType) -> None:
         """
         if self.output is None:
             raise RuntimeError(ERR_CLOSED_WRITER)
-        my_file = False
-        if isinstance(fileobj, str):
-            fileobj = FileIO(fileobj, "wb")
-            my_file = True
 
         # Add pages to the PdfWriter
         # The commented out line below was replaced with the two lines below it
@@ -269,22 +304,21 @@ def write(self, fileobj: StrByteType) -> None:
             # idnum = self.output._objects.index(self.output._pages.get_object()[PA.KIDS][-1].get_object()) + 1
             # page.out_pagedata = IndirectObject(idnum, 0, self.output)
 
-        # Once all pages are added, create bookmarks to point at those pages
+        # Once all pages are added, create outline items to point at those pages
         self._write_dests()
-        self._write_bookmarks()
+        self._write_outline()
 
         # Write the output to the file
-        self.output.write(fileobj)
+        my_file, ret_fileobj = self.output.write(fileobj)
 
         if my_file:
-            fileobj.close()
+            ret_fileobj.close()
 
     def close(self) -> None:
         """Shut all file descriptors (input and output) and clear all memory usage."""
         self.pages = []
-        for fo, _reader, mine in self.inputs:
-            if mine:
-                fo.close()
+        for fo, _reader in self.inputs:
+            fo.close()
 
         self.inputs = []
         self.output = None
@@ -366,9 +400,9 @@ def set_page_mode(self, mode: PagemodeType) -> None:
            :widths: 50 200
 
            * - /UseNone
-             - Do not show outlines or thumbnails panels
+             - Do not show outline or thumbnails panels
            * - /UseOutlines
-             - Show outlines (aka bookmarks) panel
+             - Show outline (aka bookmarks) panel
            * - /UseThumbs
              - Show page thumbnails panel
            * - /FullScreen
@@ -402,15 +436,15 @@ def _trim_dests(
     def _trim_outline(
         self,
         pdf: PdfReader,
-        outline: OutlinesType,
+        outline: OutlineType,
         pages: Union[Tuple[int, int], Tuple[int, int, int]],
-    ) -> OutlinesType:
-        """Remove outline/bookmark entries that are not a part of the specified page set."""
+    ) -> OutlineType:
+        """Remove outline item entries that are not a part of the specified page set."""
         new_outline = []
         prev_header_added = True
-        for i, o in enumerate(outline):
-            if isinstance(o, list):
-                sub = self._trim_outline(pdf, o, pages)  # type: ignore
+        for i, outline_item in enumerate(outline):
+            if isinstance(outline_item, list):
+                sub = self._trim_outline(pdf, outline_item, pages)  # type: ignore
                 if sub:
                     if not prev_header_added:
                         new_outline.append(outline[i - 1])
@@ -418,11 +452,13 @@ def _trim_outline(
             else:
                 prev_header_added = False
                 for j in range(*pages):
-                    if o["/Page"] is None:
+                    if outline_item["/Page"] is None:
                         continue
-                    if pdf.pages[j].get_object() == o["/Page"].get_object():
-                        o[NameObject("/Page")] = o["/Page"].get_object()
-                        new_outline.append(o)
+                    if pdf.pages[j].get_object() == outline_item["/Page"].get_object():
+                        outline_item[NameObject("/Page")] = outline_item[
+                            "/Page"
+                        ].get_object()
+                        new_outline.append(outline_item)
                         prev_header_added = True
                         break
         return new_outline
@@ -441,38 +477,40 @@ def _write_dests(self) -> None:
             if pageno is not None:
                 self.output.add_named_destination_object(named_dest)
 
-    def _write_bookmarks(
+    @deprecate_bookmark(bookmarks="outline")
+    def _write_outline(
         self,
-        bookmarks: Optional[Iterable[Bookmark]] = None,
+        outline: Optional[Iterable[OutlineItem]] = None,
         parent: Optional[TreeObject] = None,
     ) -> None:
         if self.output is None:
             raise RuntimeError(ERR_CLOSED_WRITER)
-        if bookmarks is None:
-            bookmarks = self.bookmarks  # type: ignore
-        assert bookmarks is not None, "hint for mypy"  # TODO: is that true?
+        if outline is None:
+            outline = self.outline  # type: ignore
+        assert outline is not None, "hint for mypy"  # TODO: is that true?
 
         last_added = None
-        for bookmark in bookmarks:
-            if isinstance(bookmark, list):
-                self._write_bookmarks(bookmark, last_added)
+        for outline_item in outline:
+            if isinstance(outline_item, list):
+                self._write_outline(outline_item, last_added)
                 continue
 
             page_no = None
-            if "/Page" in bookmark:
+            if "/Page" in outline_item:
                 for page_no, page in enumerate(self.pages):  # noqa: B007
-                    if page.id == bookmark["/Page"]:
-                        self._write_bookmark_on_page(bookmark, page)
+                    if page.id == outline_item["/Page"]:
+                        self._write_outline_item_on_page(outline_item, page)
                         break
             if page_no is not None:
-                del bookmark["/Page"], bookmark["/Type"]
-                last_added = self.output.add_bookmark_dict(bookmark, parent)
+                del outline_item["/Page"], outline_item["/Type"]
+                last_added = self.output.add_outline_item_dict(outline_item, parent)
 
-    def _write_bookmark_on_page(
-        self, bookmark: Union[Bookmark, Destination], page: _MergedPage
+    @deprecate_bookmark(bookmark="outline_item")
+    def _write_outline_item_on_page(
+        self, outline_item: Union[OutlineItem, Destination], page: _MergedPage
     ) -> None:
-        bm_type = cast(str, bookmark["/Type"])
-        args = [NumberObject(page.id), NameObject(bm_type)]
+        oi_type = cast(str, outline_item["/Type"])
+        args = [NumberObject(page.id), NameObject(oi_type)]
         fit2arg_keys: Dict[str, Tuple[str, ...]] = {
             TypFitArguments.FIT_H: (TypArguments.TOP,),
             TypFitArguments.FIT_BH: (TypArguments.TOP,),
@@ -486,14 +524,16 @@ def _write_bookmark_on_page(
                 TypArguments.TOP,
             ),
         }
-        for arg_key in fit2arg_keys.get(bm_type, tuple()):
-            if arg_key in bookmark and not isinstance(bookmark[arg_key], NullObject):
-                args.append(FloatObject(bookmark[arg_key]))
+        for arg_key in fit2arg_keys.get(oi_type, tuple()):
+            if arg_key in outline_item and not isinstance(
+                outline_item[arg_key], NullObject
+            ):
+                args.append(FloatObject(outline_item[arg_key]))
             else:
                 args.append(FloatObject(0))
-            del bookmark[arg_key]
+            del outline_item[arg_key]
 
-        bookmark[NameObject("/A")] = DictionaryObject(
+        outline_item[NameObject("/A")] = DictionaryObject(
             {
                 NameObject(GoToActionArguments.S): NameObject("/GoTo"),
                 NameObject(GoToActionArguments.D): ArrayObject(args),
@@ -517,53 +557,101 @@ def _associate_dests_to_pages(self, pages: List[_MergedPage]) -> None:
             else:
                 raise ValueError(f"Unresolved named destination '{nd['/Title']}'")
 
-    def _associate_bookmarks_to_pages(
-        self, pages: List[_MergedPage], bookmarks: Optional[Iterable[Bookmark]] = None
+    @deprecate_bookmark(bookmarks="outline")
+    def _associate_outline_items_to_pages(
+        self, pages: List[_MergedPage], outline: Optional[Iterable[OutlineItem]] = None
     ) -> None:
-        if bookmarks is None:
-            bookmarks = self.bookmarks  # type: ignore # TODO: self.bookmarks can be None!
-        assert bookmarks is not None, "hint for mypy"
-        for b in bookmarks:
-            if isinstance(b, list):
-                self._associate_bookmarks_to_pages(pages, b)
+        if outline is None:
+            outline = self.outline  # type: ignore # TODO: self.bookmarks can be None!
+        assert outline is not None, "hint for mypy"
+        for outline_item in outline:
+            if isinstance(outline_item, list):
+                self._associate_outline_items_to_pages(pages, outline_item)
                 continue
 
             pageno = None
-            bp = b["/Page"]
+            outline_item_page = outline_item["/Page"]
 
-            if isinstance(bp, NumberObject):
+            if isinstance(outline_item_page, NumberObject):
                 continue
 
             for p in pages:
-                if bp.get_object() == p.pagedata.get_object():
+                if outline_item_page.get_object() == p.pagedata.get_object():
                     pageno = p.id
 
             if pageno is not None:
-                b[NameObject("/Page")] = NumberObject(pageno)
-            else:
-                raise ValueError(f"Unresolved bookmark '{b['/Title']}'")
+                outline_item[NameObject("/Page")] = NumberObject(pageno)
 
-    def find_bookmark(
+    @deprecate_bookmark(bookmark="outline_item")
+    def find_outline_item(
         self,
-        bookmark: Dict[str, Any],
-        root: Optional[OutlinesType] = None,
+        outline_item: Dict[str, Any],
+        root: Optional[OutlineType] = None,
     ) -> Optional[List[int]]:
         if root is None:
-            root = self.bookmarks
+            root = self.outline
 
-        for i, b in enumerate(root):
-            if isinstance(b, list):
-                # b is still an inner node
-                # (OutlinesType, if recursive types were supported by mypy)
-                res = self.find_bookmark(bookmark, b)  # type: ignore
+        for i, oi_enum in enumerate(root):
+            if isinstance(oi_enum, list):
+                # oi_enum is still an inner node
+                # (OutlineType, if recursive types were supported by mypy)
+                res = self.find_outline_item(outline_item, oi_enum)  # type: ignore
                 if res:
                     return [i] + res
-            elif b == bookmark or b["/Title"] == bookmark:
+            elif (
+                oi_enum == outline_item
+                or cast(Dict[Any, Any], oi_enum["/Title"]) == outline_item
+            ):
                 # we found a leaf node
                 return [i]
 
         return None
 
+    @deprecate_bookmark(bookmark="outline_item")
+    def find_bookmark(
+        self,
+        outline_item: Dict[str, Any],
+        root: Optional[OutlineType] = None,
+    ) -> Optional[List[int]]:
+        """
+        .. deprecated:: 2.9.0
+            Use :meth:`find_outline_item` instead.
+        """
+
+        return self.find_outline_item(outline_item, root)
+
+    def add_outline_item(
+        self,
+        title: str,
+        pagenum: int,
+        parent: Union[None, TreeObject, IndirectObject] = None,
+        color: Optional[Tuple[float, float, float]] = None,
+        bold: bool = False,
+        italic: bool = False,
+        fit: FitType = "/Fit",
+        *args: ZoomArgType,
+    ) -> IndirectObject:
+        """
+        Add an outline item (commonly referred to as a "Bookmark") to this PDF file.
+
+        :param str title: Title to use for this outline item.
+        :param int pagenum: Page number this outline item will point to.
+        :param parent: A reference to a parent outline item to create nested
+            outline items.
+        :param tuple color: Color of the outline item's font as a red, green, blue tuple
+            from 0.0 to 1.0
+        :param bool bold: Outline item font is bold
+        :param bool italic: Outline item font is italic
+        :param str fit: The fit of the destination page. See
+            :meth:`add_link()<add_link>` for details.
+        """
+        writer = self.output
+        if writer is None:
+            raise RuntimeError(ERR_CLOSED_WRITER)
+        return writer.add_outline_item(
+            title, pagenum, parent, color, bold, italic, fit, *args
+        )
+
     def addBookmark(
         self,
         title: str,
@@ -577,10 +665,10 @@ def addBookmark(
     ) -> IndirectObject:  # pragma: no cover
         """
         .. deprecated:: 1.28.0
-            Use :meth:`add_bookmark` instead.
+            Use :meth:`add_outline_item` instead.
         """
-        deprecate_with_replacement("addBookmark", "add_bookmark")
-        return self.add_bookmark(
+        deprecate_with_replacement("addBookmark", "add_outline_item")
+        return self.add_outline_item(
             title, pagenum, parent, color, bold, italic, fit, *args
         )
 
@@ -594,25 +682,13 @@ def add_bookmark(
         italic: bool = False,
         fit: FitType = "/Fit",
         *args: ZoomArgType,
-    ) -> IndirectObject:
+    ) -> IndirectObject:  # pragma: no cover
         """
-        Add a bookmark to this PDF file.
-
-        :param str title: Title to use for this bookmark.
-        :param int pagenum: Page number this bookmark will point to.
-        :param parent: A reference to a parent bookmark to create nested
-            bookmarks.
-        :param tuple color: Color of the bookmark as a red, green, blue tuple
-            from 0.0 to 1.0
-        :param bool bold: Bookmark is bold
-        :param bool italic: Bookmark is italic
-        :param str fit: The fit of the destination page. See
-            :meth:`addLink()<addLink>` for details.
+        .. deprecated:: 2.9.0
+            Use :meth:`add_outline_item` instead.
         """
-        writer = self.output
-        if writer is None:
-            raise RuntimeError(ERR_CLOSED_WRITER)
-        return writer.add_bookmark(
+        deprecate_with_replacement("addBookmark", "add_outline_item")
+        return self.add_outline_item(
             title, pagenum, parent, color, bold, italic, fit, *args
         )
 
@@ -642,7 +718,7 @@ def add_named_destination(self, title: str, pagenum: int) -> None:
 
 class PdfFileMerger(PdfMerger):  # pragma: no cover
     def __init__(self, *args: Any, **kwargs: Any) -> None:
-        deprecate_with_replacement("PdfFileMerger", "PdfMerge")
+        deprecate_with_replacement("PdfFileMerger", "PdfMerger")
 
         if "strict" not in kwargs and len(args) < 1:
             kwargs["strict"] = True  # maintain the default
diff --git a/PyPDF2/_page.py b/PyPDF2/_page.py
index 63dd7d913..45bf36662 100644
--- a/PyPDF2/_page.py
+++ b/PyPDF2/_page.py
@@ -51,11 +51,12 @@
     TransformationMatrixType,
     deprecate_no_replacement,
     deprecate_with_replacement,
+    logger_warning,
     matrix_multiply,
 )
 from .constants import PageAttributes as PG
 from .constants import Ressources as RES
-from .errors import PageSizeNotDefinedError, PdfReadWarning
+from .errors import PageSizeNotDefinedError
 from .generic import (
     ArrayObject,
     ContentStream,
@@ -96,8 +97,7 @@ def getRectangle(
 
 
 def _set_rectangle(self: Any, name: str, value: Union[RectangleObject, float]) -> None:
-    if not isinstance(name, NameObject):
-        name = NameObject(name)
+    name = NameObject(name)
     self[name] = value
 
 
@@ -192,6 +192,12 @@ def translate(self, tx: float = 0, ty: float = 0) -> "Transformation":
     def scale(
         self, sx: Optional[float] = None, sy: Optional[float] = None
     ) -> "Transformation":
+        """
+        Scale the contents of a page towards the origin of the coordinate system.
+
+        Typically, that is the lower-left corner of the page. That can be
+        changed by translating the contents / the page boxes.
+        """
         if sx is None and sy is None:
             raise ValueError("Either sx or sy must be specified")
         if sx is None:
@@ -244,6 +250,11 @@ def __init__(
         self.pdf: Optional[PdfReader] = pdf
         self.indirect_ref = indirect_ref
 
+    def hash_value_data(self) -> bytes:
+        data = super().hash_value_data()
+        data += b"%d" % id(self)
+        return data
+
     @staticmethod
     def create_blank_page(
         pdf: Optional[Any] = None,  # PdfReader
@@ -501,9 +512,9 @@ def _merge_page(
         # Combine /ProcSet sets.
         new_resources[NameObject(RES.PROC_SET)] = ArrayObject(
             frozenset(
-                original_resources.get(RES.PROC_SET, ArrayObject()).get_object()  # type: ignore
+                original_resources.get(RES.PROC_SET, ArrayObject()).get_object()
             ).union(
-                frozenset(page2resources.get(RES.PROC_SET, ArrayObject()).get_object())  # type: ignore
+                frozenset(page2resources.get(RES.PROC_SET, ArrayObject()).get_object())
             )
         )
 
@@ -1101,6 +1112,7 @@ def _extract_text(
         self,
         obj: Any,
         pdf: Any,
+        orientations: Tuple[int, ...] = (0, 90, 180, 270),
         space_width: float = 200.0,
         content_key: Optional[str] = PG.CONTENTS,
     ) -> str:
@@ -1112,6 +1124,9 @@ def _extract_text(
         this function, as it will change if this function is made more
         sophisticated.
 
+        :param Tuple[int, ...] orientations: list of orientations text_extraction will look for
+                    default = (0, 90, 180, 270)
+                note: currently only 0(Up),90(turned Left), 180(upside Down), 270 (turned Right)
         :param float space_width: force default space width
                     (if not extracted from font (default 200)
         :param Optional[str] content_key: indicate the default key where to extract data
@@ -1124,13 +1139,23 @@ def _extract_text(
         cmaps: Dict[
             str, Tuple[str, float, Union[str, Dict[int, str]], Dict[str, str]]
         ] = {}
-        resources_dict = cast(DictionaryObject, obj["/Resources"])
+        try:
+            objr = obj
+            while NameObject("/Resources") not in objr:
+                # /Resources can be inherited sometimes so we look to parents
+                objr = objr["/Parent"].get_object()
+                # if no parents we will have no /Resources will be available => an exception wil be raised
+            resources_dict = cast(DictionaryObject, objr["/Resources"])
+        except Exception:
+            return ""  # no resources means no text is possible (no font) we consider the file as not damaged, no need to check for TJ or Tj
         if "/Font" in resources_dict:
             for f in cast(DictionaryObject, resources_dict["/Font"]):
                 cmaps[f] = build_char_map(f, space_width, obj)
-        cmap: Tuple[
-            Union[str, Dict[int, str]], Dict[str, str], str
-        ]  # (encoding,CMAP,font_name)
+        cmap: Tuple[Union[str, Dict[int, str]], Dict[str, str], str] = (
+            "charmap",
+            {},
+            "NotInitialized",
+        )  # (encoding,CMAP,font_name)
         try:
             content = (
                 obj[content_key].get_object() if isinstance(content_key, str) else obj
@@ -1143,22 +1168,50 @@ def _extract_text(
         # are strings where the byte->string encoding was unknown, so adding
         # them to the text here would be gibberish.
 
+        cm_matrix: List[float] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
+        cm_stack = []
         tm_matrix: List[float] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
-        tm_prev: List[float] = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
+        tm_prev: List[float] = [
+            1.0,
+            0.0,
+            0.0,
+            1.0,
+            0.0,
+            0.0,
+        ]  # will store cm_matrix * tm_matrix
         char_scale = 1.0
         space_scale = 1.0
         _space_width: float = 500.0  # will be set correctly at first Tf
         TL = 0.0
         font_size = 12.0  # init just in case of
 
-        # tm_matrix: Tuple = tm_matrix, output: str = output, text: str = text,
-        # char_scale: float = char_scale,space_scale : float = space_scale, _space_width: float = _space_width,
-        # TL: float = TL, font_size: float = font_size, cmap = cmap
+        def mult(m: List[float], n: List[float]) -> List[float]:
+            return [
+                m[0] * n[0] + m[1] * n[2],
+                m[0] * n[1] + m[1] * n[3],
+                m[2] * n[0] + m[3] * n[2],
+                m[2] * n[1] + m[3] * n[3],
+                m[4] * n[0] + m[5] * n[2] + n[4],
+                m[4] * n[1] + m[5] * n[3] + n[5],
+            ]
+
+        def orient(m: List[float]) -> int:
+            if m[3] > 1e-6:
+                return 0
+            elif m[3] < -1e-6:
+                return 180
+            elif m[1] > 0:
+                return 90
+            else:
+                return 270
+
+        def current_spacewidth() -> float:
+            # return space_scale * _space_width * char_scale
+            return _space_width / 1000.0
 
         def process_operation(operator: bytes, operands: List) -> None:
-            nonlocal tm_matrix, tm_prev, output, text, char_scale, space_scale, _space_width, TL, font_size, cmap
-            if tm_matrix[4] != 0 and tm_matrix[5] != 0:  # o reuse of the
-                tm_prev = list(tm_matrix)
+            nonlocal cm_matrix, cm_stack, tm_matrix, tm_prev, output, text, char_scale, space_scale, _space_width, TL, font_size, cmap, orientations
+            check_crlf_space: bool = False
             # Table 5.4 page 405
             if operator == b"BT":
                 tm_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
@@ -1172,6 +1225,47 @@ def process_operation(operator: bytes, operands: List) -> None:
             elif operator == b"ET":
                 output += text
                 text = ""
+            # table 4.7, page 219
+            # cm_matrix calculation is a reserved for the moment
+            elif operator == b"q":
+                cm_stack.append(
+                    (
+                        cm_matrix,
+                        cmap,
+                        font_size,
+                        char_scale,
+                        space_scale,
+                        _space_width,
+                        TL,
+                    )
+                )
+            elif operator == b"Q":
+                try:
+                    (
+                        cm_matrix,
+                        cmap,
+                        font_size,
+                        char_scale,
+                        space_scale,
+                        _space_width,
+                        TL,
+                    ) = cm_stack.pop()
+                except Exception:
+                    cm_matrix = [1.0, 0.0, 0.0, 1.0, 0.0, 0.0]
+            elif operator == b"cm":
+                output += text
+                text = ""
+                cm_matrix = mult(
+                    [
+                        float(operands[0]),
+                        float(operands[1]),
+                        float(operands[2]),
+                        float(operands[3]),
+                        float(operands[4]),
+                        float(operands[5]),
+                    ],
+                    cm_matrix,
+                )
             # Table 5.2 page 398
             elif operator == b"Tz":
                 char_scale = float(operands[0]) / 100.0
@@ -1189,7 +1283,7 @@ def process_operation(operator: bytes, operands: List) -> None:
                         cmaps[operands[0]][2],
                         cmaps[operands[0]][3],
                         operands[0],
-                    )  # type:ignore
+                    )
                 except KeyError:  # font not found
                     _space_width = unknown_char_map[1]
                     cmap = (
@@ -1203,9 +1297,11 @@ def process_operation(operator: bytes, operands: List) -> None:
                     pass  # keep previous size
             # Table 5.5 page 406
             elif operator == b"Td":
-                tm_matrix[5] += float(operands[1])
+                check_crlf_space = True
                 tm_matrix[4] += float(operands[0])
+                tm_matrix[5] += float(operands[1])
             elif operator == b"Tm":
+                check_crlf_space = True
                 tm_matrix = [
                     float(operands[0]),
                     float(operands[1]),
@@ -1215,47 +1311,97 @@ def process_operation(operator: bytes, operands: List) -> None:
                     float(operands[5]),
                 ]
             elif operator == b"T*":
+                check_crlf_space = True
                 tm_matrix[5] -= TL
-            elif operator == b"Tj":
-                t: str = ""
-                tt: bytes = (
-                    encode_pdfdocencoding(operands[0])
-                    if isinstance(operands[0], str)
-                    else operands[0]
-                )
-                if isinstance(cmap[0], str):
-                    try:
-                        t = tt.decode(cmap[0], "surrogatepass")  # apply str encoding
-                    except Exception:  # the data does not match the expectation, we use the alternative ; text extraction may not be good
-                        t = tt.decode(
-                            "utf-16-be" if cmap[0] == "charmap" else "charmap",
-                            "surrogatepass",
-                        )  # apply str encoding
-                else:  # apply dict encoding
-                    t = "".join(
-                        [
-                            cmap[0][x] if x in cmap[0] else bytes((x,)).decode()
-                            for x in tt
-                        ]
-                    )
 
-                text += "".join([cmap[1][x] if x in cmap[1] else x for x in t])
+            elif operator == b"Tj":
+                check_crlf_space = True
+                m = mult(tm_matrix, cm_matrix)
+                o = orient(m)
+                if o in orientations:
+                    if isinstance(operands[0], str):
+                        text += operands[0]
+                    else:
+                        t: str = ""
+                        tt: bytes = (
+                            encode_pdfdocencoding(operands[0])
+                            if isinstance(operands[0], str)
+                            else operands[0]
+                        )
+                        if isinstance(cmap[0], str):
+                            try:
+                                t = tt.decode(
+                                    cmap[0], "surrogatepass"
+                                )  # apply str encoding
+                            except Exception:  # the data does not match the expectation, we use the alternative ; text extraction may not be good
+                                t = tt.decode(
+                                    "utf-16-be" if cmap[0] == "charmap" else "charmap",
+                                    "surrogatepass",
+                                )  # apply str encoding
+                        else:  # apply dict encoding
+                            t = "".join(
+                                [
+                                    cmap[0][x] if x in cmap[0] else bytes((x,)).decode()
+                                    for x in tt
+                                ]
+                            )
+
+                        text += "".join([cmap[1][x] if x in cmap[1] else x for x in t])
             else:
                 return None
-            # process text changes due to positionchange: " "
-            if tm_matrix[5] <= (
-                tm_prev[5]
-                - font_size  # remove scaling * sqrt(tm_matrix[2] ** 2 + tm_matrix[3] ** 2)
-            ):  # it means that we are moving down by one line
-                output += text + "\n"  # .translate(cmap) + "\n"
-                text = ""
-            elif tm_matrix[4] >= (
-                tm_prev[4] + space_scale * _space_width * char_scale
-            ):  # it means that we are moving down by one line
-                text += " "
-            return None
-            # for clarity Operator in (b"g",b"G") : nothing to do
-            # end of process_operation ######
+            if check_crlf_space:
+                m = mult(tm_matrix, cm_matrix)
+                o = orient(m)
+                deltaX = m[4] - tm_prev[4]
+                deltaY = m[5] - tm_prev[5]
+                k = math.sqrt(abs(m[0] * m[3]) + abs(m[1] * m[2]))
+                f = font_size * k
+                tm_prev = m
+                if o not in orientations:
+                    return None
+                try:
+                    if o == 0:
+                        if deltaY < -0.8 * f:
+                            if (output + text)[-1] != "\n":
+                                text += "\n"
+                        elif (
+                            abs(deltaY) < f * 0.3
+                            and abs(deltaX) > current_spacewidth() * f * 10
+                        ):
+                            if (output + text)[-1] != " ":
+                                text += " "
+                    elif o == 180:
+                        if deltaY > 0.8 * f:
+                            if (output + text)[-1] != "\n":
+                                text += "\n"
+                        elif (
+                            abs(deltaY) < f * 0.3
+                            and abs(deltaX) > current_spacewidth() * f * 10
+                        ):
+                            if (output + text)[-1] != " ":
+                                text += " "
+                    elif o == 90:
+                        if deltaX > 0.8 * f:
+                            if (output + text)[-1] != "\n":
+                                text += "\n"
+                        elif (
+                            abs(deltaX) < f * 0.3
+                            and abs(deltaY) > current_spacewidth() * f * 10
+                        ):
+                            if (output + text)[-1] != " ":
+                                text += " "
+                    elif o == 270:
+                        if deltaX < -0.8 * f:
+                            if (output + text)[-1] != "\n":
+                                text += "\n"
+                        elif (
+                            abs(deltaX) < f * 0.3
+                            and abs(deltaY) > current_spacewidth() * f * 10
+                        ):
+                            if (output + text)[-1] != " ":
+                                text += " "
+                except Exception:
+                    pass
 
         for operands, operator in content.operations:
             # multiple operators are defined in here ####
@@ -1263,8 +1409,10 @@ def process_operation(operator: bytes, operands: List) -> None:
                 process_operation(b"T*", [])
                 process_operation(b"Tj", operands)
             elif operator == b'"':
+                process_operation(b"Tw", [operands[0]])
+                process_operation(b"Tc", [operands[1]])
                 process_operation(b"T*", [])
-                process_operation(b"TJ", operands)
+                process_operation(b"Tj", operands[2:])
             elif operator == b"TD":
                 process_operation(b"TL", [-operands[1]])
                 process_operation(b"Td", operands)
@@ -1273,21 +1421,29 @@ def process_operation(operator: bytes, operands: List) -> None:
                     if isinstance(op, (str, bytes)):
                         process_operation(b"Tj", [op])
                     if isinstance(op, (int, float, NumberObject, FloatObject)):
-                        process_operation(b"Td", [-op, 0.0])
+                        if (
+                            (abs(float(op)) >= _space_width)
+                            and (len(text) > 0)
+                            and (text[-1] != " ")
+                        ):
+                            process_operation(b"Tj", [" "])
             elif operator == b"Do":
                 output += text
-                if output != "":
-                    output += "\n"
                 try:
-                    xobj = resources_dict["/XObject"]  # type: ignore
+                    if output[-1] != "\n":
+                        output += "\n"
+                except IndexError:
+                    pass
+                try:
+                    xobj = resources_dict["/XObject"]
                     if xobj[operands[0]]["/Subtype"] != "/Image":  # type: ignore
-                        output += text
-                        text = self.extract_xform_text(xobj[operands[0]], space_width)  # type: ignore
+                        # output += text
+                        text = self.extract_xform_text(xobj[operands[0]], orientations, space_width)  # type: ignore
                         output += text
                 except Exception:
-                    warnings.warn(
+                    logger_warning(
                         f" impossible to decode XFormObject {operands[0]}",
-                        PdfReadWarning,
+                        __name__,
                     )
                 finally:
                     text = ""
@@ -1297,7 +1453,12 @@ def process_operation(operator: bytes, operands: List) -> None:
         return output
 
     def extract_text(
-        self, Tj_sep: str = "", TJ_sep: str = "", space_width: float = 200.0
+        self,
+        *args: Any,
+        Tj_sep: str = None,
+        TJ_sep: str = None,
+        orientations: Union[int, Tuple[int, ...]] = (0, 90, 180, 270),
+        space_width: float = 200.0,
     ) -> str:
         """
         Locate all text drawing commands, in the order they are provided in the
@@ -1309,24 +1470,68 @@ def extract_text(
         Do not rely on the order of text coming out of this function, as it
         will change if this function is made more sophisticated.
 
-
-        :param space_width : force default space width (if not extracted from font (default 200)
-
+        :param Tj_sep: Deprecated. Kept for compatibility until PyPDF2==4.0.0
+        :param TJ_sep: Deprecated. Kept for compatibility until PyPDF2==4.0.0
+        :param orientations: (list of) orientations (of the characters) (default: (0,90,270,360))
+                single int is equivalent to a singleton ( 0 == (0,) )
+                note: currently only 0(Up),90(turned Left), 180(upside Down),270 (turned Right)
+        :param float space_width: force default space width (if not extracted from font (default: 200)
         :return: The extracted text
         """
-        return self._extract_text(self, self.pdf, space_width, PG.CONTENTS)
+        if len(args) >= 1:
+            if isinstance(args[0], str):
+                Tj_sep = args[0]
+                if len(args) >= 2:
+                    if isinstance(args[1], str):
+                        TJ_sep = args[1]
+                    else:
+                        raise TypeError(f"Invalid positional parameter {args[1]}")
+                if len(args) >= 3:
+                    if isinstance(args[2], (tuple, int)):
+                        orientations = args[2]
+                    else:
+                        raise TypeError(f"Invalid positional parameter {args[2]}")
+                if len(args) >= 4:
+                    if isinstance(args[3], (float, int)):
+                        space_width = args[3]
+                    else:
+                        raise TypeError(f"Invalid positional parameter {args[3]}")
+            elif isinstance(args[0], (tuple, int)):
+                orientations = args[0]
+                if len(args) >= 2:
+                    if isinstance(args[1], (float, int)):
+                        space_width = args[1]
+                    else:
+                        raise TypeError(f"Invalid positional parameter {args[1]}")
+            else:
+                raise TypeError(f"Invalid positional parameter {args[0]}")
+        if Tj_sep is not None or TJ_sep is not None:
+            warnings.warn(
+                "parameters Tj_Sep, TJ_sep depreciated, and will be removed in PyPDF2 3.0.0.",
+                DeprecationWarning,
+            )
+
+        if isinstance(orientations, int):
+            orientations = (orientations,)
+
+        return self._extract_text(
+            self, self.pdf, orientations, space_width, PG.CONTENTS
+        )
 
     def extract_xform_text(
-        self, xform: EncodedStreamObject, space_width: float = 200.0
+        self,
+        xform: EncodedStreamObject,
+        orientations: Tuple[int, ...] = (0, 90, 270, 360),
+        space_width: float = 200.0,
     ) -> str:
         """
         Extract text from an XObject.
 
-        space_width : float = force default space width (if not extracted from font (default 200)
+        :param float space_width:  force default space width (if not extracted from font (default 200)
 
         :return: The extracted text
         """
-        return self._extract_text(xform, self.pdf, space_width, None)
+        return self._extract_text(xform, self.pdf, orientations, space_width, None)
 
     def extractText(
         self, Tj_sep: str = "", TJ_sep: str = ""
@@ -1337,7 +1542,7 @@ def extractText(
             Use :meth:`extract_text` instead.
         """
         deprecate_with_replacement("extractText", "extract_text")
-        return self.extract_text(Tj_sep=Tj_sep, TJ_sep=TJ_sep)
+        return self.extract_text()
 
     def _get_fonts(self) -> Tuple[Set[str], Set[str]]:
         """
@@ -1467,6 +1672,27 @@ def artBox(self, value: RectangleObject) -> None:  # pragma: no cover
         deprecate_with_replacement("artBox", "artbox")
         self.artbox = value
 
+    @property
+    def annotations(self) -> Optional[ArrayObject]:
+        if "/Annots" not in self:
+            return None
+        else:
+            return cast(ArrayObject, self["/Annots"])
+
+    @annotations.setter
+    def annotations(self, value: Optional[ArrayObject]) -> None:
+        """
+        Set the annotations array of the page.
+
+        Typically you don't want to set this value, but append to it.
+        If you append to it, don't forget to add the object first to the writer
+        and only add the indirect object.
+        """
+        if value is None:
+            del self[NameObject("/Annots")]
+        else:
+            self[NameObject("/Annots")] = value
+
 
 class _VirtualList:
     def __init__(
diff --git a/PyPDF2/_reader.py b/PyPDF2/_reader.py
index 74b7f36d5..f9d201b12 100644
--- a/PyPDF2/_reader.py
+++ b/PyPDF2/_reader.py
@@ -30,7 +30,6 @@
 import os
 import re
 import struct
-import warnings
 import zlib
 from io import BytesIO
 from pathlib import Path
@@ -54,6 +53,7 @@
     b_,
     deprecate_no_replacement,
     deprecate_with_replacement,
+    logger_warning,
     read_non_whitespace,
     read_previous_line,
     read_until_whitespace,
@@ -63,13 +63,20 @@
 from .constants import CatalogAttributes as CA
 from .constants import CatalogDictionary
 from .constants import CatalogDictionary as CD
+from .constants import CheckboxRadioButtonAttributes
 from .constants import Core as CO
 from .constants import DocumentInformationAttributes as DI
 from .constants import FieldDictionaryAttributes, GoToActionArguments
 from .constants import PageAttributes as PG
 from .constants import PagesAttributes as PA
 from .constants import TrailerKeys as TK
-from .errors import PdfReadError, PdfReadWarning, PdfStreamError
+from .errors import (
+    EmptyFileError,
+    FileNotDecryptedError,
+    PdfReadError,
+    PdfStreamError,
+    WrongPasswordError,
+)
 from .generic import (
     ArrayObject,
     ContentStream,
@@ -78,6 +85,7 @@
     DictionaryObject,
     EncodedStreamObject,
     Field,
+    FloatObject,
     IndirectObject,
     NameObject,
     NullObject,
@@ -87,7 +95,7 @@
     TreeObject,
     read_object,
 )
-from .types import OutlinesType, PagemodeType
+from .types import OutlineType, PagemodeType
 from .xmp import XmpInformation
 
 
@@ -256,10 +264,10 @@ def __init__(
             Dict[Any, Any]
         ] = None  # map page indirect_ref number to Page Number
         if hasattr(stream, "mode") and "b" not in stream.mode:  # type: ignore
-            warnings.warn(
+            logger_warning(
                 "PdfReader stream/file object is not in binary mode. "
                 "It may not be read correctly.",
-                PdfReadWarning,
+                __name__,
             )
         if isinstance(stream, (str, Path)):
             with open(stream, "rb") as fh:
@@ -288,7 +296,7 @@ def __init__(
                 and password is not None
             ):
                 # raise if password provided
-                raise PdfReadError("Wrong password")
+                raise WrongPasswordError("Wrong password")
             self._override_encryption = False
         else:
             if password is not None:
@@ -477,6 +485,7 @@ def get_fields(
             ``None`` if form data could not be located.
         """
         field_attributes = FieldDictionaryAttributes.attributes_dict()
+        field_attributes.update(CheckboxRadioButtonAttributes.attributes_dict())
         if retval is None:
             retval = {}
             catalog = cast(DictionaryObject, self.trailer[TK.ROOT])
@@ -487,7 +496,6 @@ def get_fields(
                 return None
         if tree is None:
             return retval
-
         self._check_kids(tree, retval, fileobj)
         for attr in field_attributes:
             if attr in tree:
@@ -547,7 +555,12 @@ def _check_kids(
                 self.get_fields(kid.get_object(), retval, fileobj)
 
     def _write_field(self, fileobj: Any, field: Any, field_attributes: Any) -> None:
-        for attr in FieldDictionaryAttributes.attributes():
+        field_attributes_tuple = FieldDictionaryAttributes.attributes()
+        field_attributes_tuple = (
+            field_attributes_tuple + CheckboxRadioButtonAttributes.attributes()
+        )
+
+        for attr in field_attributes_tuple:
             if attr in (
                 FieldDictionaryAttributes.Kids,
                 FieldDictionaryAttributes.AA,
@@ -637,9 +650,8 @@ def _get_named_destinations(
             # recurse down the tree
             for kid in cast(ArrayObject, tree[PA.KIDS]):
                 self._get_named_destinations(kid.get_object(), retval)
-
         # TABLE 3.33 Entries in a name tree node dictionary (PDF 1.7 specs)
-        if CA.NAMES in tree:
+        elif CA.NAMES in tree:  # KIDS and NAMES are exclusives (PDF 1.7 specs p 162)
             names = cast(DictionaryObject, tree[CA.NAMES])
             for i in range(0, len(names), 2):
                 key = cast(str, names[i].get_object())
@@ -649,7 +661,12 @@ def _get_named_destinations(
                 dest = self._build_destination(key, value)  # type: ignore
                 if dest is not None:
                     retval[key] = dest
-
+        else:  # case where Dests is in root catalog (PDF 1.7 specs, §2 about PDF1.1
+            for k__, v__ in tree.items():
+                val = v__.get_object()
+                dest = self._build_destination(k__, val)
+                if dest is not None:
+                    retval[k__] = dest
         return retval
 
     def getNamedDestinations(
@@ -666,33 +683,38 @@ def getNamedDestinations(
         return self._get_named_destinations(tree, retval)
 
     @property
-    def outlines(self) -> OutlinesType:
+    def outline(self) -> OutlineType:
         """
-        Read-only property for outlines present in the document.
+        Read-only property for the outline (i.e., a collection of 'outline items'
+        which are also known as 'bookmarks') present in the document.
 
         :return: a nested list of :class:`Destinations<PyPDF2.generic.Destination>`.
         """
-        return self._get_outlines()
+        return self._get_outline()
+
+    @property
+    def outlines(self) -> OutlineType:  # pragma: no cover
+        """
+        .. deprecated:: 2.9.0
 
-    def _get_outlines(
-        self, node: Optional[DictionaryObject] = None, outlines: Optional[Any] = None
-    ) -> OutlinesType:
-        if outlines is None:
-            outlines = []
+            Use :py:attr:`outline` instead.
+        """
+        deprecate_with_replacement("outlines", "outline")
+        return self.outline
+
+    def _get_outline(
+        self, node: Optional[DictionaryObject] = None, outline: Optional[Any] = None
+    ) -> OutlineType:
+        if outline is None:
+            outline = []
             catalog = cast(DictionaryObject, self.trailer[TK.ROOT])
 
             # get the outline dictionary and named destinations
             if CO.OUTLINES in catalog:
-                try:
-                    lines = cast(DictionaryObject, catalog[CO.OUTLINES])
-                except PdfReadError:
-                    # this occurs if the /Outlines object reference is incorrect
-                    # for an example of such a file, see https://unglueit-files.s3.amazonaws.com/ebf/7552c42e9280b4476e59e77acc0bc812.pdf
-                    # so continue to load the file without the Bookmarks
-                    return outlines
+                lines = cast(DictionaryObject, catalog[CO.OUTLINES])
 
                 if isinstance(lines, NullObject):
-                    return outlines
+                    return outline
 
                 # TABLE 8.3 Entries in the outline dictionary
                 if lines is not None and "/First" in lines:
@@ -700,37 +722,37 @@ def _get_outlines(
             self._namedDests = self._get_named_destinations()
 
         if node is None:
-            return outlines
+            return outline
 
-        # see if there are any more outlines
+        # see if there are any more outline items
         while True:
-            outline = self._build_outline(node)
-            if outline:
-                outlines.append(outline)
+            outline_obj = self._build_outline_item(node)
+            if outline_obj:
+                outline.append(outline_obj)
 
-            # check for sub-outlines
+            # check for sub-outline
             if "/First" in node:
-                sub_outlines: List[Any] = []
-                self._get_outlines(cast(DictionaryObject, node["/First"]), sub_outlines)
-                if sub_outlines:
-                    outlines.append(sub_outlines)
+                sub_outline: List[Any] = []
+                self._get_outline(cast(DictionaryObject, node["/First"]), sub_outline)
+                if sub_outline:
+                    outline.append(sub_outline)
 
             if "/Next" not in node:
                 break
             node = cast(DictionaryObject, node["/Next"])
 
-        return outlines
+        return outline
 
     def getOutlines(
-        self, node: Optional[DictionaryObject] = None, outlines: Optional[Any] = None
-    ) -> OutlinesType:  # pragma: no cover
+        self, node: Optional[DictionaryObject] = None, outline: Optional[Any] = None
+    ) -> OutlineType:  # pragma: no cover
         """
         .. deprecated:: 1.28.0
 
-            Use :py:attr:`outlines` instead.
+            Use :py:attr:`outline` instead.
         """
-        deprecate_with_replacement("getOutlines", "outlines")
-        return self._get_outlines(node, outlines)
+        deprecate_with_replacement("getOutlines", "outline")
+        return self._get_outline(node, outline)
 
     def _get_page_number_by_indirect(
         self, indirect_ref: Union[None, int, NullObject, IndirectObject]
@@ -797,15 +819,26 @@ def _build_destination(
         title: str,
         array: List[Union[NumberObject, IndirectObject, NullObject, DictionaryObject]],
     ) -> Destination:
-        page, typ = array[0:2]
-        array = array[2:]
-        try:
-            return Destination(title, page, typ, *array)  # type: ignore
-        except PdfReadError:
-            warnings.warn(f"Unknown destination: {title} {array}", PdfReadWarning)
-            if self.strict:
-                raise
-            else:
+        page, typ = None, None
+        # handle outline items with missing or invalid destination
+        if (
+            isinstance(array, (type(None), NullObject))
+            or (isinstance(array, ArrayObject) and len(array) == 0)
+            or (isinstance(array, str))
+        ):
+
+            page = NullObject()
+            typ = TextStringObject("/Fit")
+            return Destination(title, page, typ)
+        else:
+            page, typ = array[0:2]  # type: ignore
+            array = array[2:]
+            try:
+                return Destination(title, page, typ, *array)  # type: ignore
+            except PdfReadError:
+                logger_warning(f"Unknown destination: {title} {array}", __name__)
+                if self.strict:
+                    raise
                 # create a link to first Page
                 tmp = self.pages[0].indirect_ref
                 indirect_ref = NullObject() if tmp is None else tmp
@@ -813,31 +846,66 @@ def _build_destination(
                     title, indirect_ref, TextStringObject("/Fit")  # type: ignore
                 )
 
-    def _build_outline(self, node: DictionaryObject) -> Optional[Destination]:
-        dest, title, outline = None, None, None
+    def _build_outline_item(self, node: DictionaryObject) -> Optional[Destination]:
+        dest, title, outline_item = None, None, None
 
-        if "/A" in node and "/Title" in node:
-            # Action, section 8.5 (only type GoTo supported)
+        # title required for valid outline
+        # PDF Reference 1.7: TABLE 8.4 Entries in an outline item dictionary
+        try:
             title = node["/Title"]
+        except KeyError:
+            if self.strict:
+                raise PdfReadError(f"Outline Entry Missing /Title attribute: {node!r}")
+            title = ""  # type: ignore
+
+        if "/A" in node:
+            # Action, PDFv1.7 Section 12.6 (only type GoTo supported)
             action = cast(DictionaryObject, node["/A"])
             action_type = cast(NameObject, action[GoToActionArguments.S])
             if action_type == "/GoTo":
                 dest = action[GoToActionArguments.D]
-        elif "/Dest" in node and "/Title" in node:
-            # Destination, section 8.2.1
-            title = node["/Title"]
+        elif "/Dest" in node:
+            # Destination, PDFv1.7 Section 12.3.2
             dest = node["/Dest"]
-
-        # if destination found, then create outline
-        if dest:
-            if isinstance(dest, ArrayObject):
-                outline = self._build_destination(title, dest)  # type: ignore
-            elif isinstance(dest, str) and dest in self._namedDests:
-                outline = self._namedDests[dest]
-                outline[NameObject("/Title")] = title  # type: ignore
-            else:
+            # if array was referenced in another object, will be a dict w/ key "/D"
+            if isinstance(dest, DictionaryObject) and "/D" in dest:
+                dest = dest["/D"]
+
+        if isinstance(dest, ArrayObject):
+            outline_item = self._build_destination(title, dest)  # type: ignore
+        elif isinstance(dest, str):
+            # named destination, addresses NameObject Issue #193
+            try:
+                outline_item = self._build_destination(
+                    title, self._namedDests[dest].dest_array
+                )
+            except KeyError:
+                # named destination not found in Name Dict
+                outline_item = self._build_destination(title, None)
+        elif isinstance(dest, type(None)):
+            # outline item not required to have destination or action
+            # PDFv1.7 Table 153
+            outline_item = self._build_destination(title, dest)  # type: ignore
+        else:
+            if self.strict:
                 raise PdfReadError(f"Unexpected destination {dest!r}")
-        return outline
+            outline_item = self._build_destination(title, None)  # type: ignore
+
+        # if outline item created, add color, format, and child count if present
+        if outline_item:
+            if "/C" in node:
+                # Color of outline item font in (R, G, B) with values ranging 0.0-1.0
+                outline_item[NameObject("/C")] = ArrayObject(FloatObject(c) for c in node["/C"])  # type: ignore
+            if "/F" in node:
+                # specifies style characteristics bold and/or italic
+                # 1=italic, 2=bold, 3=both
+                outline_item[NameObject("/F")] = node["/F"]
+            if "/Count" in node:
+                # absolute value = num. visible children
+                # positive = open/unfolded, negative = closed/folded
+                outline_item[NameObject("/Count")] = node["/Count"]
+
+        return outline_item
 
     @property
     def pages(self) -> _VirtualList:
@@ -904,9 +972,9 @@ def page_mode(self) -> Optional[PagemodeType]:
            :widths: 50 200
 
            * - /UseNone
-             - Do not show outlines or thumbnails panels
+             - Do not show outline or thumbnails panels
            * - /UseOutlines
-             - Show outlines (aka bookmarks) panel
+             - Show outline (aka bookmarks) panel
            * - /UseThumbs
              - Show page thumbnails panel
            * - /FullScreen
@@ -994,7 +1062,7 @@ def _get_object_from_stream(
         stmnum, idx = self.xref_objStm[indirect_reference.idnum]
         obj_stm: EncodedStreamObject = IndirectObject(stmnum, 0, self).get_object()  # type: ignore
         # This is an xref to a stream, so its type better be a stream
-        assert obj_stm["/Type"] == "/ObjStm"
+        assert cast(str, obj_stm["/Type"]) == "/ObjStm"
         # /N is the number of indirect objects in the stream
         assert idx < obj_stm["/N"]
         stream_data = BytesIO(b_(obj_stm.get_data()))  # type: ignore
@@ -1023,11 +1091,11 @@ def _get_object_from_stream(
             except PdfStreamError as exc:
                 # Stream object cannot be read. Normally, a critical error, but
                 # Adobe Reader doesn't complain, so continue (in strict mode?)
-                warnings.warn(
+                logger_warning(
                     f"Invalid stream (index {i}) within object "
                     f"{indirect_reference.idnum} {indirect_reference.generation}: "
                     f"{exc}",
-                    PdfReadWarning,
+                    __name__,
                 )
 
                 if self.strict:
@@ -1070,8 +1138,7 @@ def get_object(self, indirect_reference: IndirectObject) -> Optional[PdfObject]:
                         f"does not match actual ({idnum} {generation}); "
                         "xref table not zero-indexed."
                     )
-                else:
-                    pass  # xref table is corrected in non-strict mode
+                # xref table is corrected in non-strict mode
             elif idnum != indirect_reference.idnum and self.strict:
                 # some other problem
                 raise PdfReadError(
@@ -1087,17 +1154,17 @@ def get_object(self, indirect_reference: IndirectObject) -> Optional[PdfObject]:
             if not self._override_encryption and self._encryption is not None:
                 # if we don't have the encryption key:
                 if not self._encryption.is_decrypted():
-                    raise PdfReadError("File has not been decrypted")
+                    raise FileNotDecryptedError("File has not been decrypted")
                 # otherwise, decrypt here...
                 retval = cast(PdfObject, retval)
                 retval = self._encryption.decrypt_object(
                     retval, indirect_reference.idnum, indirect_reference.generation
                 )
         else:
-            warnings.warn(
+            logger_warning(
                 f"Object {indirect_reference.idnum} {indirect_reference.generation} "
                 "not defined.",
-                PdfReadWarning,
+                __name__,
             )
             if self.strict:
                 raise PdfReadError("Could not find object.")
@@ -1139,9 +1206,9 @@ def read_object_header(self, stream: StreamType) -> Tuple[int, int]:
         read_non_whitespace(stream)
         stream.seek(-1, 1)
         if extra and self.strict:
-            warnings.warn(
+            logger_warning(
                 f"Superfluous whitespace found in object header {idnum} {generation}",  # type: ignore
-                PdfReadWarning,
+                __name__,
             )
         return int(idnum), int(generation)
 
@@ -1181,8 +1248,7 @@ def cache_indirect_object(
             msg = f"Overwriting cache for {generation} {idnum}"
             if self.strict:
                 raise PdfReadError(msg)
-            else:
-                warnings.warn(msg)
+            logger_warning(msg, __name__)
         self.resolved_objects[(generation, idnum)] = obj
         return obj
 
@@ -1198,26 +1264,8 @@ def cacheIndirectObject(
         return self.cache_indirect_object(generation, idnum, obj)
 
     def read(self, stream: StreamType) -> None:
-        # start at the end:
-        stream.seek(0, os.SEEK_END)
-        if not stream.tell():
-            raise PdfReadError("Cannot read an empty file")
-        if self.strict:
-            stream.seek(0, os.SEEK_SET)
-            header_byte = stream.read(5)
-            if header_byte != b"%PDF-":
-                raise PdfReadError(
-                    f"PDF starts with '{header_byte.decode('utf8')}', "
-                    "but '%PDF-' expected"
-                )
-            stream.seek(0, os.SEEK_END)
-        last_mb = stream.tell() - 1024 * 1024 + 1  # offset of last MB of stream
-        line = b""
-        while line[:5] != b"%%EOF":
-            if stream.tell() < last_mb:
-                raise PdfReadError("EOF marker not found")
-            line = read_previous_line(stream)
-
+        self._basic_validation(stream)
+        self._find_eof_marker(stream)
         startxref = self._find_startxref_pos(stream)
 
         # check and eventually correct the startxref only in not strict
@@ -1225,84 +1273,11 @@ def read(self, stream: StreamType) -> None:
         if xref_issue_nr != 0:
             if self.strict and xref_issue_nr:
                 raise PdfReadError("Broken xref table")
-            else:
-                warnings.warn(
-                    f"incorrect startxref pointer({xref_issue_nr})", PdfReadWarning
-                )
+            logger_warning(f"incorrect startxref pointer({xref_issue_nr})", __name__)
 
         # read all cross reference tables and their trailers
-        self.xref: Dict[int, Dict[Any, Any]] = {}
-        self.xref_free_entry: Dict[int, Dict[Any, Any]] = {}
-        self.xref_objStm: Dict[int, Tuple[Any, Any]] = {}
-        self.trailer = DictionaryObject()
-        while True:
-            # load the xref table
-            stream.seek(startxref, 0)
-            x = stream.read(1)
-            if x == b"x":
-                self._read_standard_xref_table(stream)
-                read_non_whitespace(stream)
-                stream.seek(-1, 1)
-                new_trailer = cast(Dict[str, Any], read_object(stream, self))
-                for key, value in new_trailer.items():
-                    if key not in self.trailer:
-                        self.trailer[key] = value
-                if "/Prev" in new_trailer:
-                    startxref = new_trailer["/Prev"]
-                else:
-                    break
-            elif xref_issue_nr:
-                try:
-                    self._rebuild_xref_table(stream)
-                    break
-                except Exception:
-                    xref_issue_nr = 0
-            elif x.isdigit():
-                xrefstream = self._read_pdf15_xref_stream(stream)
+        self._read_xref_tables_and_trailers(stream, startxref, xref_issue_nr)
 
-                trailer_keys = TK.ROOT, TK.ENCRYPT, TK.INFO, TK.ID
-                for key in trailer_keys:
-                    if key in xrefstream and key not in self.trailer:
-                        self.trailer[NameObject(key)] = xrefstream.raw_get(key)
-                if "/Prev" in xrefstream:
-                    startxref = cast(int, xrefstream["/Prev"])
-                else:
-                    break
-            else:
-                # some PDFs have /Prev=0 in the trailer, instead of no /Prev
-                if startxref == 0:
-                    if self.strict:
-                        raise PdfReadError(
-                            "/Prev=0 in the trailer (try opening with strict=False)"
-                        )
-                    else:
-                        warnings.warn(
-                            "/Prev=0 in the trailer - assuming there"
-                            " is no previous xref table"
-                        )
-                        break
-                # bad xref character at startxref.  Let's see if we can find
-                # the xref table nearby, as we've observed this error with an
-                # off-by-one before.
-                stream.seek(-11, 1)
-                tmp = stream.read(20)
-                xref_loc = tmp.find(b"xref")
-                if xref_loc != -1:
-                    startxref -= 10 - xref_loc
-                    continue
-                # No explicit xref table, try finding a cross-reference stream.
-                stream.seek(startxref, 0)
-                found = False
-                for look in range(5):
-                    if stream.read(1).isdigit():
-                        # This is not a standard PDF, consider adding a warning
-                        startxref += look
-                        found = True
-                        break
-                if found:
-                    continue
-                # no xref table found at specified location
-                raise PdfReadError("Could not find xref table at specified location")
         # if not zero-indexed, verify that the table is correct; change it if necessary
         if self.xref_index and not self.strict:
             loc = stream.tell()
@@ -1322,6 +1297,29 @@ def read(self, stream: StreamType) -> None:
                     # non-zero-index is actually correct
             stream.seek(loc, 0)  # return to where it was
 
+    def _basic_validation(self, stream: StreamType) -> None:
+        # start at the end:
+        stream.seek(0, os.SEEK_END)
+        if not stream.tell():
+            raise EmptyFileError("Cannot read an empty file")
+        if self.strict:
+            stream.seek(0, os.SEEK_SET)
+            header_byte = stream.read(5)
+            if header_byte != b"%PDF-":
+                raise PdfReadError(
+                    f"PDF starts with '{header_byte.decode('utf8')}', "
+                    "but '%PDF-' expected"
+                )
+            stream.seek(0, os.SEEK_END)
+
+    def _find_eof_marker(self, stream: StreamType) -> None:
+        last_mb = 8  # to parse whole file
+        line = b""
+        while line[:5] != b"%%EOF":
+            if stream.tell() < last_mb:
+                raise PdfReadError("EOF marker not found")
+            line = read_previous_line(stream)
+
     def _find_startxref_pos(self, stream: StreamType) -> int:
         """Find startxref entry - the location of the xref table"""
         line = read_previous_line(stream)
@@ -1332,7 +1330,7 @@ def _find_startxref_pos(self, stream: StreamType) -> int:
             if not line.startswith(b"startxref"):
                 raise PdfReadError("startxref not found")
             startxref = int(line[9:].strip())
-            warnings.warn("startxref on same line as offset", PdfReadWarning)
+            logger_warning("startxref on same line as offset", __name__)
         else:
             line = read_previous_line(stream)
             if line[:9] != b"startxref":
@@ -1352,9 +1350,9 @@ def _read_standard_xref_table(self, stream: StreamType) -> None:
             if firsttime and num != 0:
                 self.xref_index = num
                 if self.strict:
-                    warnings.warn(
+                    logger_warning(
                         "Xref table not zero-indexed. ID numbers for objects will be corrected.",
-                        PdfReadWarning,
+                        __name__,
                     )
                     # if table not zero indexed, could be due to error from when PDF was created
                     # which will lead to mismatched indices later on, only warned and corrected if self.strict==True
@@ -1414,6 +1412,99 @@ def _read_standard_xref_table(self, stream: StreamType) -> None:
             else:
                 break
 
+    def _read_xref_tables_and_trailers(
+        self, stream: StreamType, startxref: Optional[int], xref_issue_nr: int
+    ) -> None:
+        self.xref: Dict[int, Dict[Any, Any]] = {}
+        self.xref_free_entry: Dict[int, Dict[Any, Any]] = {}
+        self.xref_objStm: Dict[int, Tuple[Any, Any]] = {}
+        self.trailer = DictionaryObject()
+        while startxref is not None:
+            # load the xref table
+            stream.seek(startxref, 0)
+            x = stream.read(1)
+            if x == b"x":
+                startxref = self._read_xref(stream)
+            elif xref_issue_nr:
+                try:
+                    self._rebuild_xref_table(stream)
+                    break
+                except Exception:
+                    xref_issue_nr = 0
+            elif x.isdigit():
+                xrefstream = self._read_pdf15_xref_stream(stream)
+
+                trailer_keys = TK.ROOT, TK.ENCRYPT, TK.INFO, TK.ID
+                for key in trailer_keys:
+                    if key in xrefstream and key not in self.trailer:
+                        self.trailer[NameObject(key)] = xrefstream.raw_get(key)
+                if "/Prev" in xrefstream:
+                    startxref = cast(int, xrefstream["/Prev"])
+                else:
+                    break
+            else:
+                startxref = self._read_xref_other_error(stream, startxref)
+
+    def _read_xref(self, stream: StreamType) -> Optional[int]:
+        self._read_standard_xref_table(stream)
+        read_non_whitespace(stream)
+        stream.seek(-1, 1)
+        new_trailer = cast(Dict[str, Any], read_object(stream, self))
+        for key, value in new_trailer.items():
+            if key not in self.trailer:
+                self.trailer[key] = value
+        if "/Prev" in new_trailer:
+            startxref = new_trailer["/Prev"]
+            return startxref
+        else:
+            return None
+
+    def _read_xref_other_error(
+        self, stream: StreamType, startxref: int
+    ) -> Optional[int]:
+        # some PDFs have /Prev=0 in the trailer, instead of no /Prev
+        if startxref == 0:
+            if self.strict:
+                raise PdfReadError(
+                    "/Prev=0 in the trailer (try opening with strict=False)"
+                )
+            logger_warning(
+                "/Prev=0 in the trailer - assuming there is no previous xref table",
+                __name__,
+            )
+            return None
+        # bad xref character at startxref.  Let's see if we can find
+        # the xref table nearby, as we've observed this error with an
+        # off-by-one before.
+        stream.seek(-11, 1)
+        tmp = stream.read(20)
+        xref_loc = tmp.find(b"xref")
+        if xref_loc != -1:
+            startxref -= 10 - xref_loc
+            return startxref
+        # No explicit xref table, try finding a cross-reference stream.
+        stream.seek(startxref, 0)
+        found = False
+        for look in range(5):
+            if stream.read(1).isdigit():
+                # This is not a standard PDF, consider adding a warning
+                startxref += look
+                found = True
+                break
+        if found:
+            return startxref
+        # no xref table found at specified location
+        if "/Root" in self.trailer and not self.strict:
+            # if Root has been already found, just raise warning
+            logger_warning("Invalid parent xref., rebuild xref", __name__)
+            try:
+                self._rebuild_xref_table(stream)
+                return None
+            except Exception:
+                raise PdfReadError("can not rebuild xref")
+            return None
+        raise PdfReadError("Could not find xref table at specified location")
+
     def _read_pdf15_xref_stream(
         self, stream: StreamType
     ) -> Union[ContentStream, EncodedStreamObject, DecodedStreamObject]:
@@ -1421,7 +1512,7 @@ def _read_pdf15_xref_stream(
         stream.seek(-1, 1)
         idnum, generation = self.read_object_header(stream)
         xrefstream = cast(ContentStream, read_object(stream, self))
-        assert xrefstream["/Type"] == "/XRef"
+        assert cast(str, xrefstream["/Type"]) == "/XRef"
         self.cache_indirect_object(generation, idnum, xrefstream)
         stream_data = BytesIO(b_(xrefstream.get_data()))
         # Index pairs specify the subsections in the dictionary. If
diff --git a/PyPDF2/_security.py b/PyPDF2/_security.py
index 495c2e345..1fc6d1e59 100644
--- a/PyPDF2/_security.py
+++ b/PyPDF2/_security.py
@@ -31,11 +31,18 @@
 
 import struct
 from hashlib import md5
-from typing import Any, Tuple, Union
+from typing import Tuple, Union
 
 from ._utils import b_, ord_, str_
 from .generic import ByteStringObject
 
+try:
+    from typing import Literal  # type: ignore[attr-defined]
+except ImportError:
+    # PEP 586 introduced typing.Literal with Python 3.8
+    # For older Python versions, the backport typing_extensions is necessary:
+    from typing_extensions import Literal  # type: ignore[misc]
+
 # ref: pdf1.8 spec section 3.5.2 algorithm 3.2
 _encryption_padding = (
     b"\x28\xbf\x4e\x5e\x4e\x75\x8a\x41\x64\x00\x4e\x56"
@@ -45,9 +52,9 @@
 
 
 def _alg32(
-    password: Union[str, bytes],
-    rev: Any,
-    keylen: Any,
+    password: str,
+    rev: Literal[2, 3, 4],
+    keylen: int,
     owner_entry: ByteStringObject,
     p_entry: int,
     id1_entry: ByteStringObject,
@@ -98,7 +105,7 @@ def _alg32(
     return md5_hash[:keylen]
 
 
-def _alg33(owner_pwd: str, user_pwd: str, rev: int, keylen: int) -> bytes:
+def _alg33(owner_pwd: str, user_pwd: str, rev: Literal[2, 3, 4], keylen: int) -> bytes:
     """
     Implementation of algorithm 3.3 of the PDF standard security handler,
     section 3.5.2 of the PDF 1.6 reference.
@@ -128,13 +135,11 @@ def _alg33(owner_pwd: str, user_pwd: str, rev: int, keylen: int) -> bytes:
     return val
 
 
-def _alg33_1(password: Union[bytes, str], rev: int, keylen: int) -> bytes:
+def _alg33_1(password: str, rev: Literal[2, 3, 4], keylen: int) -> bytes:
     """Steps 1-4 of algorithm 3.3"""
     # 1. Pad or truncate the owner password string as described in step 1 of
     # algorithm 3.2.  If there is no owner password, use the user password
     # instead.
-    if isinstance(password, bytes):
-        password = password.decode()
     password_bytes = b_((password + str_(_encryption_padding))[:32])
     # 2. Initialize the MD5 hash function and pass the result of step 1 as
     # input to this function.
@@ -154,7 +159,7 @@ def _alg33_1(password: Union[bytes, str], rev: int, keylen: int) -> bytes:
 
 
 def _alg34(
-    password: Union[str, bytes],
+    password: str,
     owner_entry: ByteStringObject,
     p_entry: int,
     id1_entry: ByteStringObject,
@@ -166,7 +171,7 @@ def _alg34(
     """
     # 1. Create an encryption key based on the user password string, as
     # described in algorithm 3.2.
-    rev = 2
+    rev: Literal[2] = 2
     keylen = 5
     key = _alg32(password, rev, keylen, owner_entry, p_entry, id1_entry)
     # 2. Encrypt the 32-byte padding string shown in step 1 of algorithm 3.2,
@@ -179,8 +184,8 @@ def _alg34(
 
 
 def _alg35(
-    password: Union[str, bytes],
-    rev: int,
+    password: str,
+    rev: Literal[2, 3, 4],
     keylen: int,
     owner_entry: ByteStringObject,
     p_entry: int,
diff --git a/PyPDF2/_utils.py b/PyPDF2/_utils.py
index e2521dc7e..eeceda1b4 100644
--- a/PyPDF2/_utils.py
+++ b/PyPDF2/_utils.py
@@ -29,6 +29,8 @@
 __author__ = "Mathieu Fenniak"
 __author_email__ = "biziqe@mathieu.fenniak.net"
 
+import functools
+import logging
 import warnings
 from codecs import getencoder
 from io import (
@@ -39,13 +41,22 @@
     FileIO,
 )
 from os import SEEK_CUR
-from typing import Dict, Optional, Pattern, Tuple, Union, overload
+from typing import (
+    Any,
+    Callable,
+    Dict,
+    Optional,
+    Pattern,
+    Tuple,
+    Union,
+    overload,
+)
 
 try:
     # Python 3.10+: https://www.python.org/dev/peps/pep-0484/
     from typing import TypeAlias  # type: ignore[attr-defined]
 except ImportError:
-    from typing_extensions import TypeAlias  # type: ignore[misc]
+    from typing_extensions import TypeAlias
 
 from .errors import STREAM_TRUNCATED_PREMATURELY, PdfStreamError
 
@@ -129,7 +140,7 @@ def skip_over_comment(stream: StreamType) -> None:
 
 
 def read_until_regex(
-    stream: StreamType, regex: Pattern, ignore_eof: bool = False
+    stream: StreamType, regex: Pattern[bytes], ignore_eof: bool = False
 ) -> bytes:
     """
     Read until the regular expression pattern matched (ignore the match).
@@ -155,9 +166,11 @@ def read_until_regex(
 
 
 def read_block_backwards(stream: StreamType, to_read: int) -> bytes:
-    """Given a stream at position X, read a block of size
-    to_read ending at position X.
-    The stream's position should be unchanged.
+    """
+    Given a stream at position X, read a block of size to_read ending at position X.
+
+    This changes the stream's position to the beginning of where the block was
+    read.
     """
     if stream.tell() < to_read:
         raise PdfStreamError("Could not read malformed PDF file")
@@ -166,8 +179,6 @@ def read_block_backwards(stream: StreamType, to_read: int) -> bytes:
     read = stream.read(to_read)
     # Seek to the start of the block we read after reading it.
     stream.seek(-to_read, SEEK_CUR)
-    if len(read) != to_read:
-        raise PdfStreamError(f"EOF: read {len(read)}, expected {to_read}?")
     return read
 
 
@@ -342,3 +353,63 @@ def deprecate_with_replacement(
 
 def deprecate_no_replacement(name: str, removed_in: str = "3.0.0") -> None:
     deprecate(DEPR_MSG_NO_REPLACEMENT.format(name, removed_in), 4)
+
+
+def logger_warning(msg: str, src: str) -> None:
+    """
+    Use this instead of logger.warning directly.
+
+    That allows people to overwrite it more easily.
+
+    ## Exception, warnings.warn, logger_warning
+    - Exceptions should be used if the user should write code that deals with
+      an error case, e.g. the PDF being completely broken.
+    - warnings.warn should be used if the user needs to fix their code, e.g.
+      DeprecationWarnings
+    - logger_warning should be used if the user needs to know that an issue was
+      handled by PyPDF2, e.g. a non-compliant PDF being read in a way that
+      PyPDF2 could apply a robustness fix to still read it. This applies mainly
+      to strict=False mode.
+    """
+    logging.getLogger(src).warning(msg)
+
+
+def deprecate_bookmark(**aliases: str) -> Callable:
+    """
+    Decorator for deprecated term "bookmark"
+    To be used for methods and function arguments
+        outline_item = a bookmark
+        outline = a collection of outline items
+    """
+
+    def decoration(func: Callable):  # type: ignore
+        @functools.wraps(func)
+        def wrapper(*args, **kwargs):  # type: ignore
+            rename_kwargs(func.__name__, kwargs, aliases)
+            return func(*args, **kwargs)
+
+        return wrapper
+
+    return decoration
+
+
+def rename_kwargs(  # type: ignore
+    func_name: str, kwargs: Dict[str, Any], aliases: Dict[str, str]
+):
+    """
+    Helper function to deprecate arguments.
+    """
+
+    for old_term, new_term in aliases.items():
+        if old_term in kwargs:
+            if new_term in kwargs:
+                raise TypeError(
+                    f"{func_name} received both {old_term} and {new_term} as an argument. "
+                    f"{old_term} is deprecated. Use {new_term} instead."
+                )
+            kwargs[new_term] = kwargs.pop(old_term)
+            warnings.warn(
+                message=(
+                    f"{old_term} is deprecated as an argument. Use {new_term} instead"
+                )
+            )
diff --git a/PyPDF2/_version.py b/PyPDF2/_version.py
index 50062f87c..e3b571e0f 100644
--- a/PyPDF2/_version.py
+++ b/PyPDF2/_version.py
@@ -1 +1 @@
-__version__ = "2.5.0"
+__version__ = "2.10.4"
diff --git a/PyPDF2/_writer.py b/PyPDF2/_writer.py
index bde7d15e6..a8ee9232d 100644
--- a/PyPDF2/_writer.py
+++ b/PyPDF2/_writer.py
@@ -35,8 +35,10 @@
 import struct
 import time
 import uuid
-import warnings
 from hashlib import md5
+from io import BufferedReader, BufferedWriter, BytesIO, FileIO
+from pathlib import Path
+from types import TracebackType
 from typing import (
     Any,
     Callable,
@@ -45,20 +47,22 @@
     List,
     Optional,
     Tuple,
+    Type,
     Union,
     cast,
 )
 
-from PyPDF2.errors import PdfReadWarning
-
 from ._page import PageObject, _VirtualList
 from ._reader import PdfReader
 from ._security import _alg33, _alg34, _alg35
 from ._utils import (
+    StrByteType,
     StreamType,
     _get_max_pdf_version_header,
     b_,
+    deprecate_bookmark,
     deprecate_with_replacement,
+    logger_warning,
 )
 from .constants import AnnotationDictionaryAttributes
 from .constants import CatalogAttributes as CA
@@ -67,6 +71,7 @@
 from .constants import EncryptionDictAttributes as ED
 from .constants import (
     FieldDictionaryAttributes,
+    FieldFlag,
     FileSpecificationDictionaryEntries,
     GoToActionArguments,
     InteractiveFormDictEntries,
@@ -75,8 +80,9 @@
 from .constants import PagesAttributes as PA
 from .constants import StreamAttributes as SA
 from .constants import TrailerKeys as TK
-from .constants import TypFitArguments
+from .constants import TypFitArguments, UserAccessPermissions
 from .generic import (
+    AnnotationBuilder,
     ArrayObject,
     BooleanObject,
     ByteStringObject,
@@ -94,14 +100,14 @@
     StreamObject,
     TextStringObject,
     TreeObject,
-    _create_bookmark,
     create_string_object,
+    hex_to_rgb,
 )
 from .types import (
-    BookmarkTypes,
     BorderArrayType,
     FitType,
     LayoutType,
+    OutlineItemType,
     PagemodeType,
     ZoomArgsType,
     ZoomArgType,
@@ -110,13 +116,17 @@
 logger = logging.getLogger(__name__)
 
 
+OPTIONAL_READ_WRITE_FIELD = FieldFlag(0)
+ALL_DOCUMENT_PERMISSIONS = UserAccessPermissions((2**31 - 1) - 3)
+
+
 class PdfWriter:
     """
     This class supports writing PDF files out, given pages produced by another
     class (typically :class:`PdfReader<PyPDF2.PdfReader>`).
     """
 
-    def __init__(self) -> None:
+    def __init__(self, fileobj: StrByteType = "") -> None:
         self._header = b"%PDF-1.3"
         self._objects: List[Optional[PdfObject]] = []  # array of indirect objects
         self._idnum_hash: Dict[bytes, IndirectObject] = {}
@@ -153,6 +163,23 @@ def __init__(self) -> None:
         )
         self._root: Optional[IndirectObject] = None
         self._root_object = root
+        self.fileobj = fileobj
+        self.with_as_usage = False
+
+    def __enter__(self) -> "PdfWriter":
+        """Store that writer is initialized by 'with'."""
+        self.with_as_usage = True
+        return self
+
+    def __exit__(
+        self,
+        exc_type: Optional[Type[BaseException]],
+        exc: Optional[BaseException],
+        traceback: Optional[TracebackType],
+    ) -> None:
+        """Write data to the fileobj."""
+        if self.fileobj:
+            self.write(self.fileobj)
 
     @property
     def pdf_header(self) -> bytes:
@@ -190,7 +217,7 @@ def getObject(self, ido: IndirectObject) -> PdfObject:  # pragma: no cover
     def _add_page(
         self, page: PageObject, action: Callable[[Any, IndirectObject], None]
     ) -> None:
-        assert page[PA.TYPE] == CO.PAGE
+        assert cast(str, page[PA.TYPE]) == CO.PAGE
         if page.pdf is not None:
             other = page.pdf.pdf_header
             if isinstance(other, str):
@@ -250,8 +277,7 @@ def insert_page(self, page: PageObject, index: int = 0) -> None:
         Insert a page in this PDF file. The page is usually acquired from a
         :class:`PdfReader<PyPDF2.PdfReader>` instance.
 
-        :param PageObject page: The page to add to the document.  This
-            argument should be an instance of :class:`PageObject<PyPDF2._page.PageObject>`.
+        :param PageObject page: The page to add to the document.
         :param int index: Position at which the page will be inserted.
         """
         self._add_page(page, lambda l, p: l.insert(index, p))
@@ -278,16 +304,15 @@ def get_page(
         if pageNumber is not None:  # pragma: no cover
             if page_number is not None:
                 raise ValueError("Please only use the page_number parameter")
-            else:
-                deprecate_with_replacement(
-                    "get_page(pageNumber)", "get_page(page_number)", "4.0.0"
-                )
-                page_number = pageNumber
+            deprecate_with_replacement(
+                "get_page(pageNumber)", "get_page(page_number)", "4.0.0"
+            )
+            page_number = pageNumber
         if page_number is None and pageNumber is None:  # pragma: no cover
             raise ValueError("Please specify the page_number")
         pages = cast(Dict[str, Any], self.get_object(self._pages))
         # TODO: crude hack
-        return pages[PA.KIDS][page_number].get_object()
+        return cast(PageObject, pages[PA.KIDS][page_number].get_object())
 
     def getPage(self, pageNumber: int) -> PageObject:  # pragma: no cover
         """
@@ -541,13 +566,14 @@ def append_pages_from_reader(
         Copy pages from reader to writer. Includes an optional callback parameter
         which is invoked after pages are appended to the writer.
 
-        :param reader: a PdfReader object from which to copy page
+        :param PdfReader reader: a PdfReader object from which to copy page
             annotations to this writer object.  The writer's annots
             will then be updated
-        :callback after_page_append (function): Callback function that is invoked after
-            each page is appended to the writer. Callback signature:
-        :param writer_pageref (PDF page reference): Reference to the page
-            appended to the writer.
+        :param Callable[[PageObject], None] after_page_append:
+            Callback function that is invoked after each page is appended to
+            the writer. Signature includes a reference to the appended page
+            (delegates to append_pages_from_reader). The single parameter of the
+            callback is a reference to the page just appended to the document.
         """
         # Get page count from writer and reader
         reader_num_pages = len(reader.pages)
@@ -576,7 +602,10 @@ def appendPagesFromReader(
         self.append_pages_from_reader(reader, after_page_append)
 
     def update_page_form_field_values(
-        self, page: PageObject, fields: Dict[str, Any], flags: int = 0
+        self,
+        page: PageObject,
+        fields: Dict[str, Any],
+        flags: FieldFlag = OPTIONAL_READ_WRITE_FIELD,
     ) -> None:
         """
         Update the form field values for a given page from a fields dictionary.
@@ -584,11 +613,11 @@ def update_page_form_field_values(
         Copy field texts and values from fields to page.
         If the field links to a parent object, add the information to the parent.
 
-        :param page: Page reference from PDF writer where the annotations
-            and field data will be updated.
-        :param fields: a Python dictionary of field names (/T) and text
+        :param PageObject page: Page reference from PDF writer where the
+            annotations and field data will be updated.
+        :param dict fields: a Python dictionary of field names (/T) and text
             values (/V)
-        :param flags: An integer (0 to 7). The first bit sets ReadOnly, the
+        :param int flags: An integer (0 to 7). The first bit sets ReadOnly, the
             second bit sets Required, the third bit sets NoExport. See
             PDF Reference Table 8.70 for details.
         """
@@ -602,6 +631,14 @@ def update_page_form_field_values(
                 writer_parent_annot = writer_annot[PG.PARENT]
             for field in fields:
                 if writer_annot.get(FieldDictionaryAttributes.T) == field:
+                    if writer_annot.get(FieldDictionaryAttributes.FT) == "/Btn":
+                        writer_annot.update(
+                            {
+                                NameObject(
+                                    AnnotationDictionaryAttributes.AS
+                                ): NameObject(fields[field])
+                            }
+                        )
                     writer_annot.update(
                         {
                             NameObject(FieldDictionaryAttributes.V): TextStringObject(
@@ -627,7 +664,10 @@ def update_page_form_field_values(
                     )
 
     def updatePageFormFieldValues(
-        self, page: PageObject, fields: Dict[str, Any], flags: int = 0
+        self,
+        page: PageObject,
+        fields: Dict[str, Any],
+        flags: FieldFlag = OPTIONAL_READ_WRITE_FIELD,
     ) -> None:  # pragma: no cover
         """
         .. deprecated:: 1.28.0
@@ -644,7 +684,6 @@ def clone_reader_document_root(self, reader: PdfReader) -> None:
         Copy the reader document root to the writer.
 
         :param reader:  PdfReader from the document root should be copied.
-        :callback after_page_append:
         """
         self._root_object = cast(DictionaryObject, reader.trailer[TK.ROOT])
 
@@ -669,12 +708,11 @@ def clone_document_from_reader(
 
         :param reader: PDF file reader instance from which the clone
             should be created.
-        :callback after_page_append (function): Callback function that is invoked after
-            each page is appended to the writer. Signature includes a reference to the
-            appended page (delegates to appendPagesFromReader). Callback signature:
-
-            :param writer_pageref (PDF page reference): Reference to the page just
-                appended to the document.
+        :param Callable[[PageObject], None] after_page_append:
+            Callback function that is invoked after each page is appended to
+            the writer. Signature includes a reference to the appended page
+            (delegates to append_pages_from_reader). The single parameter of the
+            callback is a reference to the page just appended to the document.
         """
         self.clone_reader_document_root(reader)
         self.append_pages_from_reader(reader, after_page_append)
@@ -699,7 +737,7 @@ def encrypt(
         user_pwd: str,
         owner_pwd: Optional[str] = None,
         use_128bit: bool = True,
-        permissions_flag: int = -1,
+        permissions_flag: UserAccessPermissions = ALL_DOCUMENT_PERMISSIONS,
     ) -> None:
         """
         Encrypt this PDF file with the PDF Standard encryption handler.
@@ -731,7 +769,7 @@ def encrypt(
             rev = 2
             keylen = int(40 / 8)
         P = permissions_flag
-        O = ByteStringObject(_alg33(owner_pwd, user_pwd, rev, keylen))
+        O = ByteStringObject(_alg33(owner_pwd, user_pwd, rev, keylen))  # type: ignore[arg-type]
         ID_1 = ByteStringObject(md5((repr(time.time())).encode("utf8")).digest())
         ID_2 = ByteStringObject(md5((repr(random.random())).encode("utf8")).digest())
         self._ID = ArrayObject((ID_1, ID_2))
@@ -739,7 +777,7 @@ def encrypt(
             U, key = _alg34(user_pwd, O, P, ID_1)
         else:
             assert rev == 3
-            U, key = _alg35(user_pwd, rev, keylen, O, P, ID_1, False)
+            U, key = _alg35(user_pwd, rev, keylen, O, P, ID_1, False)  # type: ignore[arg-type]
         encrypt = DictionaryObject()
         encrypt[NameObject(SA.FILTER)] = NameObject("/Standard")
         encrypt[NameObject("/V")] = NumberObject(V)
@@ -752,17 +790,12 @@ def encrypt(
         self._encrypt = self._add_object(encrypt)
         self._encrypt_key = key
 
-    def write(self, stream: StreamType) -> None:
-        """
-        Write the collection of pages added to this object out as a PDF file.
-
-        :param stream: An object to write the file to.  The object must support
-            the write method and the tell method, similar to a file object.
-        """
+    def write_stream(self, stream: StreamType) -> None:
         if hasattr(stream, "mode") and "b" not in stream.mode:
-            warnings.warn(
+            logger_warning(
                 f"File <{stream.name}> to write to is not in binary mode. "  # type: ignore
-                "It may not be written to correctly."
+                "It may not be written to correctly.",
+                __name__,
             )
 
         if not self._root:
@@ -783,6 +816,33 @@ def write(self, stream: StreamType) -> None:
         self._write_trailer(stream)
         stream.write(b_(f"\nstartxref\n{xref_location}\n%%EOF\n"))  # eof
 
+    def write(
+        self, stream: Union[Path, StrByteType]
+    ) -> Tuple[bool, Union[FileIO, BytesIO, BufferedReader, BufferedWriter]]:
+        """
+        Write the collection of pages added to this object out as a PDF file.
+
+        :param stream: An object to write the file to.  The object can support
+            the write method and the tell method, similar to a file object, or
+            be a file path, just like the fileobj, just named it stream to keep
+            existing workflow.
+        """
+        my_file = False
+
+        if stream == "":
+            raise ValueError(f"Output(stream={stream}) is empty.")
+
+        if isinstance(stream, (str, Path)):
+            stream = FileIO(stream, "wb")
+            my_file = True
+
+        self.write_stream(stream)
+
+        if self.with_as_usage:
+            stream.close()
+
+        return my_file, stream
+
     def _write_header(self, stream: StreamType) -> List[int]:
         object_positions = []
         stream.write(self.pdf_header + b"\n")
@@ -946,10 +1006,10 @@ def _resolve_indirect_object(self, data: IndirectObject) -> IndirectObject:
         real_obj = data.pdf.get_object(data)
 
         if real_obj is None:
-            warnings.warn(
+            logger_warning(
                 f"Unable to resolve [{data.__class__.__name__}: {data}], "
                 "returning NullObject instead",
-                PdfReadWarning,
+                __name__,
             )
             real_obj = NullObject()
 
@@ -1054,131 +1114,161 @@ def getNamedDestRoot(self) -> ArrayObject:  # pragma: no cover
         deprecate_with_replacement("getNamedDestRoot", "get_named_dest_root")
         return self.get_named_dest_root()
 
-    def add_bookmark_destination(
-        self, dest: PageObject, parent: Optional[TreeObject] = None
+    def add_outline_item_destination(
+        self,
+        dest: Union[PageObject, TreeObject],
+        parent: Union[None, TreeObject, IndirectObject] = None,
     ) -> IndirectObject:
-        dest_ref = self._add_object(dest)
-
-        outline_ref = self.get_outline_root()
-
         if parent is None:
-            parent = outline_ref
+            parent = self.get_outline_root()
 
         parent = cast(TreeObject, parent.get_object())
+        dest_ref = self._add_object(dest)
         parent.add_child(dest_ref, self)
 
         return dest_ref
 
+    def add_bookmark_destination(
+        self,
+        dest: Union[PageObject, TreeObject],
+        parent: Union[None, TreeObject, IndirectObject] = None,
+    ) -> IndirectObject:  # pragma: no cover
+        """
+        .. deprecated:: 2.9.0
+
+            Use :meth:`add_outline_item_destination` instead.
+        """
+        deprecate_with_replacement(
+            "add_bookmark_destination", "add_outline_item_destination"
+        )
+        return self.add_outline_item_destination(dest, parent)
+
     def addBookmarkDestination(
         self, dest: PageObject, parent: Optional[TreeObject] = None
     ) -> IndirectObject:  # pragma: no cover
         """
         .. deprecated:: 1.28.0
 
-            Use :meth:`add_bookmark_destination` instead.
+            Use :meth:`add_outline_item_destination` instead.
         """
-        deprecate_with_replacement("addBookmarkDestination", "add_bookmark_destination")
-        return self.add_bookmark_destination(dest, parent)
+        deprecate_with_replacement(
+            "addBookmarkDestination", "add_outline_item_destination"
+        )
+        return self.add_outline_item_destination(dest, parent)
 
-    def add_bookmark_dict(
-        self, bookmark: BookmarkTypes, parent: Optional[TreeObject] = None
+    @deprecate_bookmark(bookmark="outline_item")
+    def add_outline_item_dict(
+        self, outline_item: OutlineItemType, parent: Optional[TreeObject] = None
     ) -> IndirectObject:
-        bookmark_obj = TreeObject()
-        for k, v in list(bookmark.items()):
-            bookmark_obj[NameObject(str(k))] = v
-        bookmark_obj.update(bookmark)
+        outline_item_object = TreeObject()
+        for k, v in list(outline_item.items()):
+            outline_item_object[NameObject(str(k))] = v
+        outline_item_object.update(outline_item)
 
-        if "/A" in bookmark:
+        if "/A" in outline_item:
             action = DictionaryObject()
-            a_dict = cast(DictionaryObject, bookmark["/A"])
+            a_dict = cast(DictionaryObject, outline_item["/A"])
             for k, v in list(a_dict.items()):
                 action[NameObject(str(k))] = v
             action_ref = self._add_object(action)
-            bookmark_obj[NameObject("/A")] = action_ref
-
-        bookmark_ref = self._add_object(bookmark_obj)
+            outline_item_object[NameObject("/A")] = action_ref
 
-        outline_ref = self.get_outline_root()
+        return self.add_outline_item_destination(outline_item_object, parent)
 
-        if parent is None:
-            parent = outline_ref
-
-        parent = parent.get_object()  # type: ignore
-        assert parent is not None, "hint for mypy"
-        parent.add_child(bookmark_ref, self)
+    @deprecate_bookmark(bookmark="outline_item")
+    def add_bookmark_dict(
+        self, outline_item: OutlineItemType, parent: Optional[TreeObject] = None
+    ) -> IndirectObject:  # pragma: no cover
+        """
+        .. deprecated:: 2.9.0
 
-        return bookmark_ref
+            Use :meth:`add_outline_item_dict` instead.
+        """
+        deprecate_with_replacement("add_bookmark_dict", "add_outline_item_dict")
+        return self.add_outline_item_dict(outline_item, parent)
 
+    @deprecate_bookmark(bookmark="outline_item")
     def addBookmarkDict(
-        self, bookmark: BookmarkTypes, parent: Optional[TreeObject] = None
+        self, outline_item: OutlineItemType, parent: Optional[TreeObject] = None
     ) -> IndirectObject:  # pragma: no cover
         """
         .. deprecated:: 1.28.0
 
-            Use :meth:`add_bookmark_dict` instead.
+            Use :meth:`add_outline_item_dict` instead.
         """
-        deprecate_with_replacement("addBookmarkDict", "add_bookmark_dict")
-        return self.add_bookmark_dict(bookmark, parent)
+        deprecate_with_replacement("addBookmarkDict", "add_outline_item_dict")
+        return self.add_outline_item_dict(outline_item, parent)
 
-    def add_bookmark(
+    def add_outline_item(
         self,
         title: str,
         pagenum: int,
         parent: Union[None, TreeObject, IndirectObject] = None,
-        color: Optional[Tuple[float, float, float]] = None,
+        color: Optional[Union[Tuple[float, float, float], str]] = None,
         bold: bool = False,
         italic: bool = False,
         fit: FitType = "/Fit",
         *args: ZoomArgType,
     ) -> IndirectObject:
         """
-        Add a bookmark to this PDF file.
+        Add an outline item (commonly referred to as a "Bookmark") to this PDF file.
 
-        :param str title: Title to use for this bookmark.
-        :param int pagenum: Page number this bookmark will point to.
-        :param parent: A reference to a parent bookmark to create nested
-            bookmarks.
-        :param tuple color: Color of the bookmark as a red, green, blue tuple
-            from 0.0 to 1.0
-        :param bool bold: Bookmark is bold
-        :param bool italic: Bookmark is italic
+        :param str title: Title to use for this outline item.
+        :param int pagenum: Page number this outline item will point to.
+        :param parent: A reference to a parent outline item to create nested
+            outline items.
+        :param tuple color: Color of the outline item's font as a red, green, blue tuple
+            from 0.0 to 1.0 or as a Hex String (#RRGGBB)
+        :param bool bold: Outline item font is bold
+        :param bool italic: Outline item font is italic
         :param str fit: The fit of the destination page. See
-            :meth:`addLink()<addLink>` for details.
+            :meth:`add_link()<add_link>` for details.
         """
         page_ref = NumberObject(pagenum)
-        action = DictionaryObject()
-        zoom_args: ZoomArgsType = []
-        for a in args:
-            if a is not None:
-                zoom_args.append(NumberObject(a))
-            else:
-                zoom_args.append(NullObject())
+        zoom_args: ZoomArgsType = [
+            NullObject() if a is None else NumberObject(a) for a in args
+        ]
         dest = Destination(
-            NameObject("/" + title + " bookmark"), page_ref, NameObject(fit), *zoom_args
-        )
-        dest_array = dest.dest_array
-        action.update(
-            {
-                NameObject(GoToActionArguments.D): dest_array,
-                NameObject(GoToActionArguments.S): NameObject("/GoTo"),
-            }
+            NameObject("/" + title + " outline item"),
+            page_ref,
+            NameObject(fit),
+            *zoom_args,
         )
-        action_ref = self._add_object(action)
 
-        outline_ref = self.get_outline_root()
+        action_ref = self._add_object(
+            DictionaryObject(
+                {
+                    NameObject(GoToActionArguments.D): dest.dest_array,
+                    NameObject(GoToActionArguments.S): NameObject("/GoTo"),
+                }
+            )
+        )
+        outline_item = _create_outline_item(action_ref, title, color, italic, bold)
 
         if parent is None:
-            parent = outline_ref
+            parent = self.get_outline_root()
+        return self.add_outline_item_destination(outline_item, parent)
 
-        bookmark = _create_bookmark(action_ref, title, color, italic, bold)
-
-        bookmark_ref = self._add_object(bookmark)
-
-        assert parent is not None, "hint for mypy"
-        parent_obj = cast(TreeObject, parent.get_object())
-        parent_obj.add_child(bookmark_ref, self)
+    def add_bookmark(
+        self,
+        title: str,
+        pagenum: int,
+        parent: Union[None, TreeObject, IndirectObject] = None,
+        color: Optional[Tuple[float, float, float]] = None,
+        bold: bool = False,
+        italic: bool = False,
+        fit: FitType = "/Fit",
+        *args: ZoomArgType,
+    ) -> IndirectObject:  # pragma: no cover
+        """
+        .. deprecated:: 2.9.0
 
-        return bookmark_ref
+            Use :meth:`add_outline_item` instead.
+        """
+        deprecate_with_replacement("add_bookmark", "add_outline_item")
+        return self.add_outline_item(
+            title, pagenum, parent, color, bold, italic, fit, *args
+        )
 
     def addBookmark(
         self,
@@ -1194,13 +1284,18 @@ def addBookmark(
         """
         .. deprecated:: 1.28.0
 
-            Use :meth:`add_bookmark` instead.
+            Use :meth:`add_outline_item` instead.
         """
-        deprecate_with_replacement("addBookmark", "add_bookmark")
-        return self.add_bookmark(
+        deprecate_with_replacement("addBookmark", "add_outline_item")
+        return self.add_outline_item(
             title, pagenum, parent, color, bold, italic, fit, *args
         )
 
+    def add_outline(self) -> None:
+        raise NotImplementedError(
+            "This method is not yet implemented. Use :meth:`add_outline_item` instead."
+        )
+
     def add_named_destination_object(self, dest: PdfObject) -> IndirectObject:
         dest_ref = self._add_object(dest)
 
@@ -1413,7 +1508,7 @@ def removeText(
     def add_uri(
         self,
         pagenum: int,
-        uri: int,
+        uri: str,
         rect: RectangleObject,
         border: Optional[ArrayObject] = None,
     ) -> None:
@@ -1422,11 +1517,11 @@ def add_uri(
         This uses the basic structure of :meth:`add_link`
 
         :param int pagenum: index of the page on which to place the URI action.
-        :param int uri: string -- uri of resource to link to.
-        :param rect: :class:`RectangleObject<PyPDF2.generic.RectangleObject>` or array of four
+        :param str uri: URI of resource to link to.
+        :param Tuple[int, int, int, int] rect: :class:`RectangleObject<PyPDF2.generic.RectangleObject>` or array of four
             integers specifying the clickable rectangular area
             ``[xLL, yLL, xUR, yUR]``, or string in the form ``"[ xLL yLL xUR yUR ]"``.
-        :param border: if provided, an array describing border-drawing
+        :param ArrayObject border: if provided, an array describing border-drawing
             properties. See the PDF spec for details. No border will be
             drawn if this argument is omitted.
         """
@@ -1480,7 +1575,7 @@ def add_uri(
     def addURI(
         self,
         pagenum: int,
-        uri: int,
+        uri: str,
         rect: RectangleObject,
         border: Optional[ArrayObject] = None,
     ) -> None:  # pragma: no cover
@@ -1501,91 +1596,30 @@ def add_link(
         fit: FitType = "/Fit",
         *args: ZoomArgType,
     ) -> None:
-        """
-        Add an internal link from a rectangular area to the specified page.
-
-        :param int pagenum: index of the page on which to place the link.
-        :param int pagedest: index of the page to which the link should go.
-        :param rect: :class:`RectangleObject<PyPDF2.generic.RectangleObject>` or array of four
-            integers specifying the clickable rectangular area
-            ``[xLL, yLL, xUR, yUR]``, or string in the form ``"[ xLL yLL xUR yUR ]"``.
-        :param border: if provided, an array describing border-drawing
-            properties. See the PDF spec for details. No border will be
-            drawn if this argument is omitted.
-        :param str fit: Page fit or 'zoom' option (see below). Additional arguments may need
-            to be supplied. Passing ``None`` will be read as a null value for that coordinate.
-
-        .. list-table:: Valid ``zoom`` arguments (see Table 8.2 of the PDF 1.7 reference for details)
-           :widths: 50 200
-
-           * - /Fit
-             - No additional arguments
-           * - /XYZ
-             - [left] [top] [zoomFactor]
-           * - /FitH
-             - [top]
-           * - /FitV
-             - [left]
-           * - /FitR
-             - [left] [bottom] [right] [top]
-           * - /FitB
-             - No additional arguments
-           * - /FitBH
-             - [top]
-           * - /FitBV
-             - [left]
-        """
-        pages_obj = cast(Dict[str, Any], self.get_object(self._pages))
-        page_link = pages_obj[PA.KIDS][pagenum]
-        page_dest = pages_obj[PA.KIDS][pagedest]  # TODO: switch for external link
-        page_ref = cast(Dict[str, Any], self.get_object(page_link))
-
-        border_arr: BorderArrayType
-        if border is not None:
-            border_arr = [NameObject(n) for n in border[:3]]
-            if len(border) == 4:
-                dash_pattern = ArrayObject([NameObject(n) for n in border[3]])
-                border_arr.append(dash_pattern)
-        else:
-            border_arr = [NumberObject(0)] * 3
+        deprecate_with_replacement(
+            "add_link", "add_annotation(AnnotationBuilder.link(...))"
+        )
 
         if isinstance(rect, str):
-            rect = NameObject(rect)
+            rect = rect.strip()[1:-1]
+            rect = RectangleObject(
+                [float(num) for num in rect.split(" ") if len(num) > 0]
+            )
         elif isinstance(rect, RectangleObject):
             pass
         else:
             rect = RectangleObject(rect)
 
-        zoom_args: ZoomArgsType = []
-        for a in args:
-            if a is not None:
-                zoom_args.append(NumberObject(a))
-            else:
-                zoom_args.append(NullObject())
-        dest = Destination(
-            NameObject("/LinkName"), page_dest, NameObject(fit), *zoom_args
-        )  # TODO: create a better name for the link
-        dest_array = dest.dest_array
-
-        lnk = DictionaryObject()
-        lnk.update(
-            {
-                NameObject("/Type"): NameObject(PG.ANNOTS),
-                NameObject("/Subtype"): NameObject("/Link"),
-                NameObject("/P"): page_link,
-                NameObject("/Rect"): rect,
-                NameObject("/Border"): ArrayObject(border_arr),
-                NameObject("/Dest"): dest_array,
-            }
+        annotation = AnnotationBuilder.link(
+            rect=rect,
+            border=border,
+            target_page_index=pagedest,
+            fit=fit,
+            fit_args=args,
         )
-        lnk_ref = self._add_object(lnk)
+        return self.add_annotation(page_number=pagenum, annotation=annotation)
 
-        if PG.ANNOTS in page_ref:
-            page_ref[PG.ANNOTS].append(lnk_ref)
-        else:
-            page_ref[NameObject(PG.ANNOTS)] = ArrayObject([lnk_ref])
-
-    def addLink(  # pragma: no cover
+    def addLink(
         self,
         pagenum: int,
         pagedest: int,
@@ -1593,13 +1627,15 @@ def addLink(  # pragma: no cover
         border: Optional[ArrayObject] = None,
         fit: FitType = "/Fit",
         *args: ZoomArgType,
-    ) -> None:
+    ) -> None:  # pragma: no cover
         """
         .. deprecated:: 1.28.0
 
             Use :meth:`add_link` instead.
         """
-        deprecate_with_replacement("addLink", "add_link")
+        deprecate_with_replacement(
+            "addLink", "add_annotation(AnnotationBuilder.link(...))", "4.0.0"
+        )
         return self.add_link(pagenum, pagedest, rect, border, fit, *args)
 
     _valid_layouts = (
@@ -1653,8 +1689,9 @@ def _set_page_layout(self, layout: Union[NameObject, LayoutType]) -> None:
         """
         if not isinstance(layout, NameObject):
             if layout not in self._valid_layouts:
-                warnings.warn(
-                    f"Layout should be one of: {'', ''.join(self._valid_layouts)}"
+                logger_warning(
+                    f"Layout should be one of: {'', ''.join(self._valid_layouts)}",
+                    __name__,
                 )
             layout = NameObject(layout)
         self._root_object.update({NameObject("/PageLayout"): layout})
@@ -1753,7 +1790,9 @@ def set_page_mode(self, mode: PagemodeType) -> None:
             mode_name: NameObject = mode
         else:
             if mode not in self._valid_modes:
-                warnings.warn(f"Mode should be one of: {', '.join(self._valid_modes)}")
+                logger_warning(
+                    f"Mode should be one of: {', '.join(self._valid_modes)}", __name__
+                )
             mode_name = NameObject(mode)
         self._root_object.update({NameObject("/PageMode"): mode_name})
 
@@ -1775,9 +1814,9 @@ def page_mode(self) -> Optional[PagemodeType]:
            :widths: 50 200
 
            * - /UseNone
-             - Do not show outlines or thumbnails panels
+             - Do not show outline or thumbnails panels
            * - /UseOutlines
-             - Show outlines (aka bookmarks) panel
+             - Show outline (aka bookmarks) panel
            * - /UseThumbs
              - Show page thumbnails panel
            * - /FullScreen
@@ -1813,6 +1852,88 @@ def pageMode(self, mode: PagemodeType) -> None:  # pragma: no cover
         deprecate_with_replacement("pageMode", "page_mode")
         self.page_mode = mode
 
+    def add_annotation(self, page_number: int, annotation: Dict[str, Any]) -> None:
+        to_add = cast(DictionaryObject, _pdf_objectify(annotation))
+        to_add[NameObject("/P")] = self.get_object(self._pages)["/Kids"][page_number]  # type: ignore
+        page = self.pages[page_number]
+        if page.annotations is None:
+            page[NameObject("/Annots")] = ArrayObject()
+        assert page.annotations is not None
+
+        # Internal link annotations need the correct object type for the
+        # destination
+        if to_add.get("/Subtype") == "/Link" and NameObject("/Dest") in to_add:
+            tmp = cast(dict, to_add[NameObject("/Dest")])
+            dest = Destination(
+                NameObject("/LinkName"),
+                tmp["target_page_index"],
+                tmp["fit"],
+                *tmp["fit_args"],
+            )
+            to_add[NameObject("/Dest")] = dest.dest_array
+
+        ind_obj = self._add_object(to_add)
+
+        page.annotations.append(ind_obj)
+
+
+def _pdf_objectify(obj: Union[Dict[str, Any], str, int, List[Any]]) -> PdfObject:
+    if isinstance(obj, PdfObject):
+        return obj
+    if isinstance(obj, dict):
+        to_add = DictionaryObject()
+        for key, value in obj.items():
+            name_key = NameObject(key)
+            casted_value = _pdf_objectify(value)
+            to_add[name_key] = casted_value
+        return to_add
+    elif isinstance(obj, list):
+        arr = ArrayObject()
+        for el in obj:
+            arr.append(_pdf_objectify(el))
+        return arr
+    elif isinstance(obj, str):
+        if obj.startswith("/"):
+            return NameObject(obj)
+        else:
+            return TextStringObject(obj)
+    elif isinstance(obj, (int, float)):
+        return FloatObject(obj)
+    else:
+        raise NotImplementedError(
+            f"type(obj)={type(obj)} could not be casted to PdfObject"
+        )
+
+
+def _create_outline_item(
+    action_ref: IndirectObject,
+    title: str,
+    color: Union[Tuple[float, float, float], str, None],
+    italic: bool,
+    bold: bool,
+) -> TreeObject:
+    outline_item = TreeObject()
+    outline_item.update(
+        {
+            NameObject("/A"): action_ref,
+            NameObject("/Title"): create_string_object(title),
+        }
+    )
+    if color:
+        if isinstance(color, str):
+            color = hex_to_rgb(color)
+        outline_item.update(
+            {NameObject("/C"): ArrayObject([FloatObject(c) for c in color])}
+        )
+    if italic or bold:
+        format_flag = 0
+        if italic:
+            format_flag += 1
+        if bold:
+            format_flag += 2
+        outline_item.update({NameObject("/F"): NumberObject(format_flag)})
+    return outline_item
+
 
 class PdfFileWriter(PdfWriter):  # pragma: no cover
     def __init__(self, *args: Any, **kwargs: Any) -> None:
diff --git a/PyPDF2/constants.py b/PyPDF2/constants.py
index ab5b55e55..f8d3faf8f 100644
--- a/PyPDF2/constants.py
+++ b/PyPDF2/constants.py
@@ -8,6 +8,7 @@
 PDF Reference, sixth edition, Version 1.7, 2006.
 """
 
+from enum import IntFlag
 from typing import Dict, Tuple
 
 
@@ -47,6 +48,43 @@ class EncryptionDictAttributes:
     ENCRYPT_METADATA = "/EncryptMetadata"  # boolean flag, optional
 
 
+class UserAccessPermissions(IntFlag):
+    """TABLE 3.20 User access permissions"""
+
+    R1 = 1
+    R2 = 2
+    PRINT = 4
+    MODIFY = 8
+    EXTRACT = 16
+    ADD_OR_MODIFY = 32
+    R7 = 64
+    R8 = 128
+    FILL_FORM_FIELDS = 256
+    EXTRACT_TEXT_AND_GRAPHICS = 512
+    ASSEMBLE_DOC = 1024
+    PRINT_TO_REPRESENTATION = 2048
+    R13 = 2**12
+    R14 = 2**13
+    R15 = 2**14
+    R16 = 2**15
+    R17 = 2**16
+    R18 = 2**17
+    R19 = 2**18
+    R20 = 2**19
+    R21 = 2**20
+    R22 = 2**21
+    R23 = 2**22
+    R24 = 2**23
+    R25 = 2**24
+    R26 = 2**25
+    R27 = 2**26
+    R28 = 2**27
+    R29 = 2**28
+    R30 = 2**29
+    R31 = 2**30
+    R32 = 2**31
+
+
 class Ressources:
     """TABLE 3.30 Entries in a resource dictionary."""
 
@@ -294,6 +332,30 @@ def attributes_dict(cls) -> Dict[str, str]:
         }
 
 
+class CheckboxRadioButtonAttributes:
+    """TABLE 8.76 Field flags common to all field types"""
+
+    Opt = "/Opt"  # Options, Optional
+
+    @classmethod
+    def attributes(cls) -> Tuple[str, ...]:
+        return (cls.Opt,)
+
+    @classmethod
+    def attributes_dict(cls) -> Dict[str, str]:
+        return {
+            cls.Opt: "Options",
+        }
+
+
+class FieldFlag(IntFlag):
+    """TABLE 8.70 Field flags common to all field types"""
+
+    READ_ONLY = 1
+    REQUIRED = 2
+    NO_EXPORT = 4
+
+
 class DocumentInformationAttributes:
     """TABLE 10.2 Entries in the document information dictionary."""
 
@@ -360,11 +422,21 @@ class CatalogDictionary:
     NEEDS_RENDERING = "/NeedsRendering"  # boolean, optional
 
 
+class OutlineFontFlag(IntFlag):
+    """
+    A class used as an enumerable flag for formatting an outline font
+    """
+
+    italic = 1
+    bold = 2
+
+
 PDF_KEYS = (
     AnnotationDictionaryAttributes,
     CatalogAttributes,
     CatalogDictionary,
     CcittFaxDecodeParameters,
+    CheckboxRadioButtonAttributes,
     ColorSpaces,
     Core,
     DocumentInformationAttributes,
diff --git a/PyPDF2/errors.py b/PyPDF2/errors.py
index 9ed33150b..d00bc7c12 100644
--- a/PyPDF2/errors.py
+++ b/PyPDF2/errors.py
@@ -33,4 +33,16 @@ class ParseError(Exception):
     pass
 
 
+class FileNotDecryptedError(PdfReadError):
+    pass
+
+
+class WrongPasswordError(FileNotDecryptedError):
+    pass
+
+
+class EmptyFileError(PdfReadError):
+    pass
+
+
 STREAM_TRUNCATED_PREMATURELY = "Stream has ended unexpectedly"
diff --git a/PyPDF2/filters.py b/PyPDF2/filters.py
index 575528c5e..4ac651b39 100644
--- a/PyPDF2/filters.py
+++ b/PyPDF2/filters.py
@@ -38,13 +38,15 @@
 import struct
 import zlib
 from io import BytesIO
-from typing import Any, Dict, Optional, Tuple, Union
+from typing import Any, Dict, Optional, Tuple, Union, cast
 
-from .generic import ArrayObject, DictionaryObject, NameObject
+from .generic import ArrayObject, DictionaryObject, IndirectObject, NameObject
 
 try:
     from typing import Literal  # type: ignore[attr-defined]
 except ImportError:
+    # PEP 586 introduced typing.Literal with Python 3.8
+    # For older Python versions, the backport typing_extensions is necessary:
     from typing_extensions import Literal  # type: ignore[misc]
 
 from ._utils import b_, deprecate_with_replacement, ord_, paeth_predictor
@@ -87,6 +89,8 @@ def decode(
         :param decode_parms: a dictionary of values, understanding the
             "/Predictor":<int> key only
         :return: the flate-decoded data.
+
+        :raises PdfReadError:
         """
         if "decodeParms" in kwargs:  # pragma: no cover
             deprecate_with_replacement("decodeParms", "parameters", "4.0.0")
@@ -102,7 +106,7 @@ def decode(
                             predictor = decode_parm["/Predictor"]
                 else:
                     predictor = decode_parms.get("/Predictor", 1)
-            except AttributeError:
+            except (AttributeError, TypeError):  # Type Error is NullObject
                 pass  # Usually an array with a null object was read
         # predictor 1 == no predictor
         if predictor != 1:
@@ -203,6 +207,8 @@ def decode(
         :param decode_parms:
         :return: a string conversion in base-7 ASCII, where each of its values
             v is such that 0 <= ord(v) <= 127.
+
+        :raises PdfStreamError:
         """
         if "decodeParms" in kwargs:  # pragma: no cover
             deprecate_with_replacement("decodeParms", "parameters", "4.0.0")
@@ -277,6 +283,8 @@ def decode(self) -> str:
             algorithm derived from:
             http://www.rasip.fer.hr/research/compress/algorithms/fund/lz/lzw.html
             and the PDFReference
+
+            :raises PdfReadError: If the stop code is missing
             """
             cW = self.CLEARDICT
             baos = ""
@@ -504,7 +512,8 @@ def decode(
 
 def decode_stream_data(stream: Any) -> Union[str, bytes]:  # utils.StreamObject
     filters = stream.get(SA.FILTER, ())
-
+    if isinstance(filters, IndirectObject):
+        filters = cast(ArrayObject, filters.get_object())
     if len(filters) and not isinstance(filters[0], NameObject):
         # we have a single filter instance
         filters = (filters,)
@@ -512,13 +521,13 @@ def decode_stream_data(stream: Any) -> Union[str, bytes]:  # utils.StreamObject
     # If there is not data to decode we should not try to decode the data.
     if data:
         for filter_type in filters:
-            if filter_type == FT.FLATE_DECODE or filter_type == FTA.FL:
+            if filter_type in (FT.FLATE_DECODE, FTA.FL):
                 data = FlateDecode.decode(data, stream.get(SA.DECODE_PARMS))
-            elif filter_type == FT.ASCII_HEX_DECODE or filter_type == FTA.AHx:
+            elif filter_type in (FT.ASCII_HEX_DECODE, FTA.AHx):
                 data = ASCIIHexDecode.decode(data)  # type: ignore
-            elif filter_type == FT.LZW_DECODE or filter_type == FTA.LZW:
+            elif filter_type in (FT.LZW_DECODE, FTA.LZW):
                 data = LZWDecode.decode(data, stream.get(SA.DECODE_PARMS))  # type: ignore
-            elif filter_type == FT.ASCII_85_DECODE or filter_type == FTA.A85:
+            elif filter_type in (FT.ASCII_85_DECODE, FTA.A85):
                 data = ASCII85Decode.decode(data)
             elif filter_type == FT.DCT_DECODE:
                 data = DCTDecode.decode(data)
diff --git a/PyPDF2/generic/__init__.py b/PyPDF2/generic/__init__.py
new file mode 100644
index 000000000..b0c60da88
--- /dev/null
+++ b/PyPDF2/generic/__init__.py
@@ -0,0 +1,137 @@
+# Copyright (c) 2006, Mathieu Fenniak
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+# * The name of the author may not be used to endorse or promote products
+# derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+"""Implementation of generic PDF objects (dictionary, number, string, ...)."""
+__author__ = "Mathieu Fenniak"
+__author_email__ = "biziqe@mathieu.fenniak.net"
+
+from typing import Dict, List, Union
+
+from .._utils import StreamType, deprecate_with_replacement
+from ..constants import OutlineFontFlag
+from ._annotations import AnnotationBuilder
+from ._base import (
+    BooleanObject,
+    ByteStringObject,
+    FloatObject,
+    IndirectObject,
+    NameObject,
+    NullObject,
+    NumberObject,
+    PdfObject,
+    TextStringObject,
+    encode_pdfdocencoding,
+)
+from ._data_structures import (
+    ArrayObject,
+    ContentStream,
+    DecodedStreamObject,
+    Destination,
+    DictionaryObject,
+    EncodedStreamObject,
+    Field,
+    StreamObject,
+    TreeObject,
+    read_object,
+)
+from ._outline import Bookmark, OutlineItem
+from ._rectangle import RectangleObject
+from ._utils import (
+    create_string_object,
+    decode_pdfdocencoding,
+    hex_to_rgb,
+    read_hex_string_from_stream,
+    read_string_from_stream,
+)
+
+
+def readHexStringFromStream(
+    stream: StreamType,
+) -> Union["TextStringObject", "ByteStringObject"]:  # pragma: no cover
+    deprecate_with_replacement(
+        "readHexStringFromStream", "read_hex_string_from_stream", "4.0.0"
+    )
+    return read_hex_string_from_stream(stream)
+
+
+def readStringFromStream(
+    stream: StreamType,
+    forced_encoding: Union[None, str, List[str], Dict[int, str]] = None,
+) -> Union["TextStringObject", "ByteStringObject"]:  # pragma: no cover
+    deprecate_with_replacement(
+        "readStringFromStream", "read_string_from_stream", "4.0.0"
+    )
+    return read_string_from_stream(stream, forced_encoding)
+
+
+def createStringObject(
+    string: Union[str, bytes],
+    forced_encoding: Union[None, str, List[str], Dict[int, str]] = None,
+) -> Union[TextStringObject, ByteStringObject]:  # pragma: no cover
+    deprecate_with_replacement("createStringObject", "create_string_object", "4.0.0")
+    return create_string_object(string, forced_encoding)
+
+
+__all__ = [
+    # Base types
+    "BooleanObject",
+    "FloatObject",
+    "NumberObject",
+    "NameObject",
+    "IndirectObject",
+    "NullObject",
+    "PdfObject",
+    "TextStringObject",
+    "ByteStringObject",
+    # Annotations
+    "AnnotationBuilder",
+    # Data structures
+    "ArrayObject",
+    "DictionaryObject",
+    "TreeObject",
+    "StreamObject",
+    "DecodedStreamObject",
+    "EncodedStreamObject",
+    "ContentStream",
+    "RectangleObject",
+    "Field",
+    "Destination",
+    # --- More specific stuff
+    # Outline
+    "OutlineItem",
+    "OutlineFontFlag",
+    "Bookmark",
+    # Data structures core functions
+    "read_object",
+    # Utility functions
+    "create_string_object",
+    "encode_pdfdocencoding",
+    "decode_pdfdocencoding",
+    "hex_to_rgb",
+    "read_hex_string_from_stream",
+    "read_string_from_stream",
+]
diff --git a/PyPDF2/generic/_annotations.py b/PyPDF2/generic/_annotations.py
new file mode 100644
index 000000000..ffb4f8f17
--- /dev/null
+++ b/PyPDF2/generic/_annotations.py
@@ -0,0 +1,275 @@
+from typing import Optional, Tuple, Union
+
+from ._base import (
+    BooleanObject,
+    FloatObject,
+    NameObject,
+    NullObject,
+    NumberObject,
+    TextStringObject,
+)
+from ._data_structures import ArrayObject, DictionaryObject
+from ._rectangle import RectangleObject
+from ._utils import hex_to_rgb
+
+
+class AnnotationBuilder:
+    """
+    The AnnotationBuilder creates dictionaries representing PDF annotations.
+
+    Those dictionaries can be modified before they are added to a PdfWriter
+    instance via `writer.add_annotation`.
+
+    See `adding PDF annotations <../user/adding-pdf-annotations.html>`_ for
+    it's usage combined with PdfWriter.
+    """
+
+    from ..types import FitType, ZoomArgType
+
+    @staticmethod
+    def text(
+        rect: Union[RectangleObject, Tuple[float, float, float, float]],
+        text: str,
+        open: bool = False,
+        flags: int = 0,
+    ) -> DictionaryObject:
+        """
+        Add text annotation.
+
+        :param Tuple[int, int, int, int] rect:
+            or array of four integers specifying the clickable rectangular area
+            ``[xLL, yLL, xUR, yUR]``
+        :param bool open:
+        :param int flags:
+        """
+        # TABLE 8.23 Additional entries specific to a text annotation
+        text_obj = DictionaryObject(
+            {
+                NameObject("/Type"): NameObject("/Annot"),
+                NameObject("/Subtype"): NameObject("/Text"),
+                NameObject("/Rect"): RectangleObject(rect),
+                NameObject("/Contents"): TextStringObject(text),
+                NameObject("/Open"): BooleanObject(open),
+                NameObject("/Flags"): NumberObject(flags),
+            }
+        )
+        return text_obj
+
+    @staticmethod
+    def free_text(
+        text: str,
+        rect: Union[RectangleObject, Tuple[float, float, float, float]],
+        font: str = "Helvetica",
+        bold: bool = False,
+        italic: bool = False,
+        font_size: str = "14pt",
+        font_color: str = "000000",
+        border_color: str = "000000",
+        background_color: str = "ffffff",
+    ) -> DictionaryObject:
+        """
+        Add text in a rectangle to a page.
+
+        :param str text: Text to be added
+        :param RectangleObject rect: or array of four integers
+            specifying the clickable rectangular area ``[xLL, yLL, xUR, yUR]``
+        :param str font: Name of the Font, e.g. 'Helvetica'
+        :param bool bold: Print the text in bold
+        :param bool italic: Print the text in italic
+        :param str font_size: How big the text will be, e.g. '14pt'
+        :param str font_color: Hex-string for the color
+        :param str border_color: Hex-string for the border color
+        :param str background_color: Hex-string for the background of the annotation
+        """
+        font_str = "font: "
+        if bold is True:
+            font_str = font_str + "bold "
+        if italic is True:
+            font_str = font_str + "italic "
+        font_str = font_str + font + " " + font_size
+        font_str = font_str + ";text-align:left;color:#" + font_color
+
+        bg_color_str = ""
+        for st in hex_to_rgb(border_color):
+            bg_color_str = bg_color_str + str(st) + " "
+        bg_color_str = bg_color_str + "rg"
+
+        free_text = DictionaryObject()
+        free_text.update(
+            {
+                NameObject("/Type"): NameObject("/Annot"),
+                NameObject("/Subtype"): NameObject("/FreeText"),
+                NameObject("/Rect"): RectangleObject(rect),
+                NameObject("/Contents"): TextStringObject(text),
+                # font size color
+                NameObject("/DS"): TextStringObject(font_str),
+                # border color
+                NameObject("/DA"): TextStringObject(bg_color_str),
+                # background color
+                NameObject("/C"): ArrayObject(
+                    [FloatObject(n) for n in hex_to_rgb(background_color)]
+                ),
+            }
+        )
+        return free_text
+
+    @staticmethod
+    def line(
+        p1: Tuple[float, float],
+        p2: Tuple[float, float],
+        rect: Union[RectangleObject, Tuple[float, float, float, float]],
+        text: str = "",
+        title_bar: str = "",
+    ) -> DictionaryObject:
+        """
+        Draw a line on the PDF.
+
+        :param Tuple[float, float] p1: First point
+        :param Tuple[float, float] p2: Second point
+        :param RectangleObject rect: or array of four
+                integers specifying the clickable rectangular area
+                ``[xLL, yLL, xUR, yUR]``
+        :param str text: Text to be displayed as the line annotation
+        :param str title_bar: Text to be displayed in the title bar of the
+            annotation; by convention this is the name of the author
+        """
+        line_obj = DictionaryObject(
+            {
+                NameObject("/Type"): NameObject("/Annot"),
+                NameObject("/Subtype"): NameObject("/Line"),
+                NameObject("/Rect"): RectangleObject(rect),
+                NameObject("/T"): TextStringObject(title_bar),
+                NameObject("/L"): ArrayObject(
+                    [
+                        FloatObject(p1[0]),
+                        FloatObject(p1[1]),
+                        FloatObject(p2[0]),
+                        FloatObject(p2[1]),
+                    ]
+                ),
+                NameObject("/LE"): ArrayObject(
+                    [
+                        NameObject(None),
+                        NameObject(None),
+                    ]
+                ),
+                NameObject("/IC"): ArrayObject(
+                    [
+                        FloatObject(0.5),
+                        FloatObject(0.5),
+                        FloatObject(0.5),
+                    ]
+                ),
+                NameObject("/Contents"): TextStringObject(text),
+            }
+        )
+        return line_obj
+
+    @staticmethod
+    def link(
+        rect: Union[RectangleObject, Tuple[float, float, float, float]],
+        border: Optional[ArrayObject] = None,
+        url: Optional[str] = None,
+        target_page_index: Optional[int] = None,
+        fit: FitType = "/Fit",
+        fit_args: Tuple[ZoomArgType, ...] = tuple(),
+    ) -> DictionaryObject:
+        """
+        Add a link to the document.
+
+        The link can either be an external link or an internal link.
+
+        An external link requires the URL parameter.
+        An internal link requires the target_page_index, fit, and fit args.
+
+
+        :param RectangleObject rect: or array of four
+            integers specifying the clickable rectangular area
+            ``[xLL, yLL, xUR, yUR]``
+        :param border: if provided, an array describing border-drawing
+            properties. See the PDF spec for details. No border will be
+            drawn if this argument is omitted.
+            - horizontal corner radius,
+            - vertical corner radius, and
+            - border width
+            - Optionally: Dash
+        :param str url: Link to a website (if you want to make an external link)
+        :param int target_page_index: index of the page to which the link should go
+                                (if you want to make an internal link)
+        :param str fit: Page fit or 'zoom' option (see below). Additional arguments may need
+            to be supplied. Passing ``None`` will be read as a null value for that coordinate.
+        :param Tuple[int, ...] fit_args: Parameters for the fit argument.
+
+
+        .. list-table:: Valid ``fit`` arguments (see Table 8.2 of the PDF 1.7 reference for details)
+           :widths: 50 200
+
+           * - /Fit
+             - No additional arguments
+           * - /XYZ
+             - [left] [top] [zoomFactor]
+           * - /FitH
+             - [top]
+           * - /FitV
+             - [left]
+           * - /FitR
+             - [left] [bottom] [right] [top]
+           * - /FitB
+             - No additional arguments
+           * - /FitBH
+             - [top]
+           * - /FitBV
+             - [left]
+        """
+        from ..types import BorderArrayType
+
+        is_external = url is not None
+        is_internal = target_page_index is not None
+        if not is_external and not is_internal:
+            raise ValueError(
+                "Either 'url' or 'target_page_index' have to be provided. Both were None."
+            )
+        if is_external and is_internal:
+            raise ValueError(
+                f"Either 'url' or 'target_page_index' have to be provided. url={url}, target_page_index={target_page_index}"
+            )
+
+        border_arr: BorderArrayType
+        if border is not None:
+            border_arr = [NameObject(n) for n in border[:3]]
+            if len(border) == 4:
+                dash_pattern = ArrayObject([NameObject(n) for n in border[3]])
+                border_arr.append(dash_pattern)
+        else:
+            border_arr = [NumberObject(0)] * 3
+
+        link_obj = DictionaryObject(
+            {
+                NameObject("/Type"): NameObject("/Annot"),
+                NameObject("/Subtype"): NameObject("/Link"),
+                NameObject("/Rect"): RectangleObject(rect),
+                NameObject("/Border"): ArrayObject(border_arr),
+            }
+        )
+        if is_external:
+            link_obj[NameObject("/A")] = DictionaryObject(
+                {
+                    NameObject("/S"): NameObject("/URI"),
+                    NameObject("/Type"): NameObject("/Action"),
+                    NameObject("/URI"): TextStringObject(url),
+                }
+            )
+        if is_internal:
+            fit_arg_ready = [
+                NullObject() if a is None else NumberObject(a) for a in fit_args
+            ]
+            # This needs to be updated later!
+            dest_deferred = DictionaryObject(
+                {
+                    "target_page_index": NumberObject(target_page_index),
+                    "fit": NameObject(fit),
+                    "fit_args": ArrayObject(fit_arg_ready),
+                }
+            )
+            link_obj[NameObject("/Dest")] = dest_deferred
+        return link_obj
diff --git a/PyPDF2/generic/_base.py b/PyPDF2/generic/_base.py
new file mode 100644
index 000000000..5734c3304
--- /dev/null
+++ b/PyPDF2/generic/_base.py
@@ -0,0 +1,464 @@
+# Copyright (c) 2006, Mathieu Fenniak
+# All rights reserved.
+#
+# Redistribution and use in source and binary forms, with or without
+# modification, are permitted provided that the following conditions are
+# met:
+#
+# * Redistributions of source code must retain the above copyright notice,
+# this list of conditions and the following disclaimer.
+# * Redistributions in binary form must reproduce the above copyright notice,
+# this list of conditions and the following disclaimer in the documentation
+# and/or other materials provided with the distribution.
+# * The name of the author may not be used to endorse or promote products
+# derived from this software without specific prior written permission.
+#
+# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE
+# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE
+# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR
+# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF
+# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS
+# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
+# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
+# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE
+# POSSIBILITY OF SUCH DAMAGE.
+
+import codecs
+import decimal
+import hashlib
+import re
+from typing import Any, Callable, Optional, Union
+
+from .._codecs import _pdfdoc_encoding_rev
+from .._utils import (
+    StreamType,
+    b_,
+    deprecate_with_replacement,
+    hex_str,
+    hexencode,
+    logger_warning,
+    read_non_whitespace,
+    read_until_regex,
+    str_,
+)
+from ..errors import STREAM_TRUNCATED_PREMATURELY, PdfReadError, PdfStreamError
+
+__author__ = "Mathieu Fenniak"
+__author_email__ = "biziqe@mathieu.fenniak.net"
+
+
+class PdfObject:
+    # function for calculating a hash value
+    hash_func: Callable[..., "hashlib._Hash"] = hashlib.sha1
+
+    def hash_value_data(self) -> bytes:
+        return ("%s" % self).encode()
+
+    def hash_value(self) -> bytes:
+        return (
+            "%s:%s"
+            % (
+                self.__class__.__name__,
+                self.hash_func(self.hash_value_data()).hexdigest(),
+            )
+        ).encode()
+
+    def get_object(self) -> Optional["PdfObject"]:
+        """Resolve indirect references."""
+        return self
+
+    def getObject(self) -> Optional["PdfObject"]:  # pragma: no cover
+        deprecate_with_replacement("getObject", "get_object")
+        return self.get_object()
+
+    def write_to_stream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:
+        raise NotImplementedError
+
+
+class NullObject(PdfObject):
+    def write_to_stream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:
+        stream.write(b"null")
+
+    @staticmethod
+    def read_from_stream(stream: StreamType) -> "NullObject":
+        nulltxt = stream.read(4)
+        if nulltxt != b"null":
+            raise PdfReadError("Could not read Null object")
+        return NullObject()
+
+    def writeToStream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("writeToStream", "write_to_stream")
+        self.write_to_stream(stream, encryption_key)
+
+    def __repr__(self) -> str:
+        return "NullObject"
+
+    @staticmethod
+    def readFromStream(stream: StreamType) -> "NullObject":  # pragma: no cover
+        deprecate_with_replacement("readFromStream", "read_from_stream")
+        return NullObject.read_from_stream(stream)
+
+
+class BooleanObject(PdfObject):
+    def __init__(self, value: Any) -> None:
+        self.value = value
+
+    def __eq__(self, __o: object) -> bool:
+        if isinstance(__o, BooleanObject):
+            return self.value == __o.value
+        elif isinstance(__o, bool):
+            return self.value == __o
+        else:
+            return False
+
+    def __repr__(self) -> str:
+        return "True" if self.value else "False"
+
+    def write_to_stream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:
+        if self.value:
+            stream.write(b"true")
+        else:
+            stream.write(b"false")
+
+    def writeToStream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("writeToStream", "write_to_stream")
+        self.write_to_stream(stream, encryption_key)
+
+    @staticmethod
+    def read_from_stream(stream: StreamType) -> "BooleanObject":
+        word = stream.read(4)
+        if word == b"true":
+            return BooleanObject(True)
+        elif word == b"fals":
+            stream.read(1)
+            return BooleanObject(False)
+        else:
+            raise PdfReadError("Could not read Boolean object")
+
+    @staticmethod
+    def readFromStream(stream: StreamType) -> "BooleanObject":  # pragma: no cover
+        deprecate_with_replacement("readFromStream", "read_from_stream")
+        return BooleanObject.read_from_stream(stream)
+
+
+class IndirectObject(PdfObject):
+    def __init__(self, idnum: int, generation: int, pdf: Any) -> None:  # PdfReader
+        self.idnum = idnum
+        self.generation = generation
+        self.pdf = pdf
+
+    def get_object(self) -> Optional[PdfObject]:
+        obj = self.pdf.get_object(self)
+        if obj is None:
+            return None
+        return obj.get_object()
+
+    def __repr__(self) -> str:
+        return f"IndirectObject({self.idnum!r}, {self.generation!r}, {id(self.pdf)})"
+
+    def __eq__(self, other: Any) -> bool:
+        return (
+            other is not None
+            and isinstance(other, IndirectObject)
+            and self.idnum == other.idnum
+            and self.generation == other.generation
+            and self.pdf is other.pdf
+        )
+
+    def __ne__(self, other: Any) -> bool:
+        return not self.__eq__(other)
+
+    def write_to_stream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:
+        stream.write(b_(f"{self.idnum} {self.generation} R"))
+
+    def writeToStream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("writeToStream", "write_to_stream")
+        self.write_to_stream(stream, encryption_key)
+
+    @staticmethod
+    def read_from_stream(stream: StreamType, pdf: Any) -> "IndirectObject":  # PdfReader
+        idnum = b""
+        while True:
+            tok = stream.read(1)
+            if not tok:
+                raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
+            if tok.isspace():
+                break
+            idnum += tok
+        generation = b""
+        while True:
+            tok = stream.read(1)
+            if not tok:
+                raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
+            if tok.isspace():
+                if not generation:
+                    continue
+                break
+            generation += tok
+        r = read_non_whitespace(stream)
+        if r != b"R":
+            raise PdfReadError(
+                f"Error reading indirect object reference at byte {hex_str(stream.tell())}"
+            )
+        return IndirectObject(int(idnum), int(generation), pdf)
+
+    @staticmethod
+    def readFromStream(
+        stream: StreamType, pdf: Any  # PdfReader
+    ) -> "IndirectObject":  # pragma: no cover
+        deprecate_with_replacement("readFromStream", "read_from_stream")
+        return IndirectObject.read_from_stream(stream, pdf)
+
+
+class FloatObject(decimal.Decimal, PdfObject):
+    def __new__(
+        cls, value: Union[str, Any] = "0", context: Optional[Any] = None
+    ) -> "FloatObject":
+        try:
+            return decimal.Decimal.__new__(cls, str_(value), context)
+        except Exception:
+            try:
+                return decimal.Decimal.__new__(cls, str(value))
+            except decimal.InvalidOperation:
+                # If this isn't a valid decimal (happens in malformed PDFs)
+                # fallback to 0
+                logger_warning(f"Invalid FloatObject {value}", __name__)
+                return decimal.Decimal.__new__(cls, "0")
+
+    def __repr__(self) -> str:
+        if self == self.to_integral():
+            return str(self.quantize(decimal.Decimal(1)))
+        else:
+            # Standard formatting adds useless extraneous zeros.
+            o = f"{self:.5f}"
+            # Remove the zeros.
+            while o and o[-1] == "0":
+                o = o[:-1]
+            return o
+
+    def as_numeric(self) -> float:
+        return float(repr(self).encode("utf8"))
+
+    def write_to_stream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:
+        stream.write(repr(self).encode("utf8"))
+
+    def writeToStream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("writeToStream", "write_to_stream")
+        self.write_to_stream(stream, encryption_key)
+
+
+class NumberObject(int, PdfObject):
+    NumberPattern = re.compile(b"[^+-.0-9]")
+
+    def __new__(cls, value: Any) -> "NumberObject":
+        val = int(value)
+        try:
+            return int.__new__(cls, val)
+        except OverflowError:
+            return int.__new__(cls, 0)
+
+    def as_numeric(self) -> int:
+        return int(repr(self).encode("utf8"))
+
+    def write_to_stream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:
+        stream.write(repr(self).encode("utf8"))
+
+    def writeToStream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("writeToStream", "write_to_stream")
+        self.write_to_stream(stream, encryption_key)
+
+    @staticmethod
+    def read_from_stream(stream: StreamType) -> Union["NumberObject", FloatObject]:
+        num = read_until_regex(stream, NumberObject.NumberPattern)
+        if num.find(b".") != -1:
+            return FloatObject(num)
+        return NumberObject(num)
+
+    @staticmethod
+    def readFromStream(
+        stream: StreamType,
+    ) -> Union["NumberObject", FloatObject]:  # pragma: no cover
+        deprecate_with_replacement("readFromStream", "read_from_stream")
+        return NumberObject.read_from_stream(stream)
+
+
+class ByteStringObject(bytes, PdfObject):
+    """
+    Represents a string object where the text encoding could not be determined.
+    This occurs quite often, as the PDF spec doesn't provide an alternate way to
+    represent strings -- for example, the encryption data stored in files (like
+    /O) is clearly not text, but is still stored in a "String" object.
+    """
+
+    @property
+    def original_bytes(self) -> bytes:
+        """For compatibility with TextStringObject.original_bytes."""
+        return self
+
+    def write_to_stream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:
+        bytearr = self
+        if encryption_key:
+            from .._security import RC4_encrypt
+
+            bytearr = RC4_encrypt(encryption_key, bytearr)  # type: ignore
+        stream.write(b"<")
+        stream.write(hexencode(bytearr))
+        stream.write(b">")
+
+    def writeToStream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("writeToStream", "write_to_stream")
+        self.write_to_stream(stream, encryption_key)
+
+
+class TextStringObject(str, PdfObject):
+    """
+    Represents a string object that has been decoded into a real unicode string.
+    If read from a PDF document, this string appeared to match the
+    PDFDocEncoding, or contained a UTF-16BE BOM mark to cause UTF-16 decoding to
+    occur.
+    """
+
+    autodetect_pdfdocencoding = False
+    autodetect_utf16 = False
+
+    @property
+    def original_bytes(self) -> bytes:
+        """
+        It is occasionally possible that a text string object gets created where
+        a byte string object was expected due to the autodetection mechanism --
+        if that occurs, this "original_bytes" property can be used to
+        back-calculate what the original encoded bytes were.
+        """
+        return self.get_original_bytes()
+
+    def get_original_bytes(self) -> bytes:
+        # We're a text string object, but the library is trying to get our raw
+        # bytes.  This can happen if we auto-detected this string as text, but
+        # we were wrong.  It's pretty common.  Return the original bytes that
+        # would have been used to create this object, based upon the autodetect
+        # method.
+        if self.autodetect_utf16:
+            return codecs.BOM_UTF16_BE + self.encode("utf-16be")
+        elif self.autodetect_pdfdocencoding:
+            return encode_pdfdocencoding(self)
+        else:
+            raise Exception("no information about original bytes")
+
+    def write_to_stream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:
+        # Try to write the string out as a PDFDocEncoding encoded string.  It's
+        # nicer to look at in the PDF file.  Sadly, we take a performance hit
+        # here for trying...
+        try:
+            bytearr = encode_pdfdocencoding(self)
+        except UnicodeEncodeError:
+            bytearr = codecs.BOM_UTF16_BE + self.encode("utf-16be")
+        if encryption_key:
+            from .._security import RC4_encrypt
+
+            bytearr = RC4_encrypt(encryption_key, bytearr)
+            obj = ByteStringObject(bytearr)
+            obj.write_to_stream(stream, None)
+        else:
+            stream.write(b"(")
+            for c in bytearr:
+                if not chr(c).isalnum() and c != b" ":
+                    # This:
+                    #   stream.write(b_(rf"\{c:0>3o}"))
+                    # gives
+                    #   https://github.com/davidhalter/parso/issues/207
+                    stream.write(b_("\\%03o" % c))
+                else:
+                    stream.write(b_(chr(c)))
+            stream.write(b")")
+
+    def writeToStream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("writeToStream", "write_to_stream")
+        self.write_to_stream(stream, encryption_key)
+
+
+class NameObject(str, PdfObject):
+    delimiter_pattern = re.compile(rb"\s+|[\(\)<>\[\]{}/%]")
+    surfix = b"/"
+
+    def write_to_stream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:
+        stream.write(b_(self))
+
+    def writeToStream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("writeToStream", "write_to_stream")
+        self.write_to_stream(stream, encryption_key)
+
+    @staticmethod
+    def read_from_stream(stream: StreamType, pdf: Any) -> "NameObject":  # PdfReader
+        name = stream.read(1)
+        if name != NameObject.surfix:
+            raise PdfReadError("name read error")
+        name += read_until_regex(stream, NameObject.delimiter_pattern, ignore_eof=True)
+        try:
+            try:
+                ret = name.decode("utf-8")
+            except (UnicodeEncodeError, UnicodeDecodeError):
+                ret = name.decode("gbk")
+            return NameObject(ret)
+        except (UnicodeEncodeError, UnicodeDecodeError) as e:
+            # Name objects should represent irregular characters
+            # with a '#' followed by the symbol's hex number
+            if not pdf.strict:
+                logger_warning("Illegal character in Name Object", __name__)
+                return NameObject(name)
+            else:
+                raise PdfReadError("Illegal character in Name Object") from e
+
+    @staticmethod
+    def readFromStream(
+        stream: StreamType, pdf: Any  # PdfReader
+    ) -> "NameObject":  # pragma: no cover
+        deprecate_with_replacement("readFromStream", "read_from_stream")
+        return NameObject.read_from_stream(stream, pdf)
+
+
+def encode_pdfdocencoding(unicode_string: str) -> bytes:
+    retval = b""
+    for c in unicode_string:
+        try:
+            retval += b_(chr(_pdfdoc_encoding_rev[c]))
+        except KeyError:
+            raise UnicodeEncodeError(
+                "pdfdocencoding", c, -1, -1, "does not exist in translation table"
+            )
+    return retval
diff --git a/PyPDF2/generic.py b/PyPDF2/generic/_data_structures.py
similarity index 51%
rename from PyPDF2/generic.py
rename to PyPDF2/generic/_data_structures.py
index ac6a98edf..283b33b22 100644
--- a/PyPDF2/generic.py
+++ b/PyPDF2/generic/_data_structures.py
@@ -26,58 +26,45 @@
 # POSSIBILITY OF SUCH DAMAGE.
 
 
-"""Implementation of generic PDF objects (dictionary, number, string, ...)."""
 __author__ = "Mathieu Fenniak"
 __author_email__ = "biziqe@mathieu.fenniak.net"
 
-import codecs
-import decimal
-import hashlib
 import logging
 import re
-import warnings
 from io import BytesIO
-from typing import (
-    Any,
-    Callable,
-    Dict,
-    Iterable,
-    List,
-    Optional,
-    Tuple,
-    Union,
-    cast,
-)
+from typing import Any, Dict, Iterable, List, Optional, Tuple, Union, cast
 
-from ._codecs import (  # noqa: rev_encoding
-    _pdfdoc_encoding,
-    _pdfdoc_encoding_rev,
-    rev_encoding,
-)
-from ._utils import (
+from .._utils import (
     WHITESPACES,
     StreamType,
     b_,
-    deprecate_no_replacement,
     deprecate_with_replacement,
     hex_str,
-    hexencode,
+    logger_warning,
     read_non_whitespace,
     read_until_regex,
     skip_over_comment,
-    str_,
 )
-from .constants import FieldDictionaryAttributes
-from .constants import FilterTypes as FT
-from .constants import StreamAttributes as SA
-from .constants import TypArguments as TA
-from .constants import TypFitArguments as TF
-from .errors import (
-    STREAM_TRUNCATED_PREMATURELY,
-    PdfReadError,
-    PdfReadWarning,
-    PdfStreamError,
+from ..constants import (
+    CheckboxRadioButtonAttributes,
+    FieldDictionaryAttributes,
+)
+from ..constants import FilterTypes as FT
+from ..constants import OutlineFontFlag
+from ..constants import StreamAttributes as SA
+from ..constants import TypArguments as TA
+from ..constants import TypFitArguments as TF
+from ..errors import STREAM_TRUNCATED_PREMATURELY, PdfReadError, PdfStreamError
+from ._base import (
+    BooleanObject,
+    FloatObject,
+    IndirectObject,
+    NameObject,
+    NullObject,
+    NumberObject,
+    PdfObject,
 )
+from ._utils import read_hex_string_from_stream, read_string_from_stream
 
 logger = logging.getLogger(__name__)
 ObjectPrefix = b"/<[tf(n%"
@@ -85,109 +72,8 @@
 IndirectPattern = re.compile(rb"[+-]?(\d+)\s+(\d+)\s+R[^a-zA-Z]")
 
 
-class PdfObject:
-    # function for calculating a hash value
-    hash_func: Callable[..., "hashlib._Hash"] = hashlib.sha1
-
-    def hash_value_data(self) -> bytes:
-        return ("%s" % self).encode()
-
-    def hash_value(self) -> bytes:
-        return (
-            "%s:%s"
-            % (
-                self.__class__.__name__,
-                self.hash_func(self.hash_value_data()).hexdigest(),
-            )
-        ).encode()
-
-    def get_object(self) -> Optional["PdfObject"]:
-        """Resolve indirect references."""
-        return self
-
-    def getObject(self) -> Optional["PdfObject"]:  # pragma: no cover
-        deprecate_with_replacement("getObject", "get_object")
-        return self.get_object()
-
-    def write_to_stream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:
-        raise NotImplementedError
-
-
-class NullObject(PdfObject):
-    def write_to_stream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:
-        stream.write(b"null")
-
-    @staticmethod
-    def read_from_stream(stream: StreamType) -> "NullObject":
-        nulltxt = stream.read(4)
-        if nulltxt != b"null":
-            raise PdfReadError("Could not read Null object")
-        return NullObject()
-
-    def writeToStream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("writeToStream", "write_to_stream")
-        self.write_to_stream(stream, encryption_key)
-
-    @staticmethod
-    def readFromStream(stream: StreamType) -> "NullObject":  # pragma: no cover
-        deprecate_with_replacement("readFromStream", "read_from_stream")
-        return NullObject.read_from_stream(stream)
-
-
-class BooleanObject(PdfObject):
-    def __init__(self, value: Any) -> None:
-        self.value = value
-
-    def __eq__(self, __o: object) -> bool:
-        if isinstance(__o, BooleanObject):
-            return self.value == __o.value
-        elif isinstance(__o, bool):
-            return self.value == __o
-        else:
-            return False
-
-    def __repr__(self) -> str:
-        return "True" if self.value else "False"
-
-    def write_to_stream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:
-        if self.value:
-            stream.write(b"true")
-        else:
-            stream.write(b"false")
-
-    def writeToStream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("writeToStream", "write_to_stream")
-        self.write_to_stream(stream, encryption_key)
-
-    @staticmethod
-    def read_from_stream(stream: StreamType) -> "BooleanObject":
-        word = stream.read(4)
-        if word == b"true":
-            return BooleanObject(True)
-        elif word == b"fals":
-            stream.read(1)
-            return BooleanObject(False)
-        else:
-            raise PdfReadError("Could not read Boolean object")
-
-    @staticmethod
-    def readFromStream(stream: StreamType) -> "BooleanObject":  # pragma: no cover
-        deprecate_with_replacement("readFromStream", "read_from_stream")
-        return BooleanObject.read_from_stream(stream)
-
-
 class ArrayObject(list, PdfObject):
-    def items(self) -> Iterable:
+    def items(self) -> Iterable[Any]:
         """
         Emulate DictionaryObject.items for a list
         (index, object)
@@ -242,422 +128,6 @@ def readFromStream(
         return ArrayObject.read_from_stream(stream, pdf)
 
 
-class IndirectObject(PdfObject):
-    def __init__(self, idnum: int, generation: int, pdf: Any) -> None:  # PdfReader
-        self.idnum = idnum
-        self.generation = generation
-        self.pdf = pdf
-
-    def get_object(self) -> Optional[PdfObject]:
-        obj = self.pdf.get_object(self)
-        if obj is None:
-            return None
-        return obj.get_object()
-
-    def __repr__(self) -> str:
-        return f"IndirectObject({self.idnum!r}, {self.generation!r}, {id(self.pdf)})"
-
-    def __eq__(self, other: Any) -> bool:
-        return (
-            other is not None
-            and isinstance(other, IndirectObject)
-            and self.idnum == other.idnum
-            and self.generation == other.generation
-            and self.pdf is other.pdf
-        )
-
-    def __ne__(self, other: Any) -> bool:
-        return not self.__eq__(other)
-
-    def write_to_stream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:
-        stream.write(b_(f"{self.idnum} {self.generation} R"))
-
-    def writeToStream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("writeToStream", "write_to_stream")
-        self.write_to_stream(stream, encryption_key)
-
-    @staticmethod
-    def read_from_stream(stream: StreamType, pdf: Any) -> "IndirectObject":  # PdfReader
-        idnum = b""
-        while True:
-            tok = stream.read(1)
-            if not tok:
-                raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
-            if tok.isspace():
-                break
-            idnum += tok
-        generation = b""
-        while True:
-            tok = stream.read(1)
-            if not tok:
-                raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
-            if tok.isspace():
-                if not generation:
-                    continue
-                break
-            generation += tok
-        r = read_non_whitespace(stream)
-        if r != b"R":
-            raise PdfReadError(
-                f"Error reading indirect object reference at byte {hex_str(stream.tell())}"
-            )
-        return IndirectObject(int(idnum), int(generation), pdf)
-
-    @staticmethod
-    def readFromStream(
-        stream: StreamType, pdf: Any  # PdfReader
-    ) -> "IndirectObject":  # pragma: no cover
-        deprecate_with_replacement("readFromStream", "read_from_stream")
-        return IndirectObject.read_from_stream(stream, pdf)
-
-
-class FloatObject(decimal.Decimal, PdfObject):
-    def __new__(
-        cls, value: Union[str, Any] = "0", context: Optional[Any] = None
-    ) -> "FloatObject":
-        try:
-            return decimal.Decimal.__new__(cls, str_(value), context)
-        except Exception:
-            try:
-                return decimal.Decimal.__new__(cls, str(value))
-            except decimal.InvalidOperation:
-                # If this isn't a valid decimal (happens in malformed PDFs)
-                # fallback to 0
-                logger.warning(f"Invalid FloatObject {value}")
-                return decimal.Decimal.__new__(cls, "0")
-
-    def __repr__(self) -> str:
-        if self == self.to_integral():
-            return str(self.quantize(decimal.Decimal(1)))
-        else:
-            # Standard formatting adds useless extraneous zeros.
-            o = f"{self:.5f}"
-            # Remove the zeros.
-            while o and o[-1] == "0":
-                o = o[:-1]
-            return o
-
-    def as_numeric(self) -> float:
-        return float(repr(self).encode("utf8"))
-
-    def write_to_stream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:
-        stream.write(repr(self).encode("utf8"))
-
-    def writeToStream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("writeToStream", "write_to_stream")
-        self.write_to_stream(stream, encryption_key)
-
-
-class NumberObject(int, PdfObject):
-    NumberPattern = re.compile(b"[^+-.0-9]")
-
-    def __new__(cls, value: Any) -> "NumberObject":
-        val = int(value)
-        try:
-            return int.__new__(cls, val)
-        except OverflowError:
-            return int.__new__(cls, 0)
-
-    def as_numeric(self) -> int:
-        return int(repr(self).encode("utf8"))
-
-    def write_to_stream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:
-        stream.write(repr(self).encode("utf8"))
-
-    def writeToStream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("writeToStream", "write_to_stream")
-        self.write_to_stream(stream, encryption_key)
-
-    @staticmethod
-    def read_from_stream(stream: StreamType) -> Union["NumberObject", FloatObject]:
-        num = read_until_regex(stream, NumberObject.NumberPattern)
-        if num.find(b".") != -1:
-            return FloatObject(num)
-        return NumberObject(num)
-
-    @staticmethod
-    def readFromStream(
-        stream: StreamType,
-    ) -> Union["NumberObject", FloatObject]:  # pragma: no cover
-        deprecate_with_replacement("readFromStream", "read_from_stream")
-        return NumberObject.read_from_stream(stream)
-
-
-def readHexStringFromStream(
-    stream: StreamType,
-) -> Union["TextStringObject", "ByteStringObject"]:  # pragma: no cover
-    deprecate_with_replacement(
-        "readHexStringFromStream", "read_hex_string_from_stream", "4.0.0"
-    )
-    return read_hex_string_from_stream(stream)
-
-
-def read_hex_string_from_stream(
-    stream: StreamType,
-    forced_encoding: Union[None, str, List[str], Dict[int, str]] = None,
-) -> Union["TextStringObject", "ByteStringObject"]:
-    stream.read(1)
-    txt = ""
-    x = b""
-    while True:
-        tok = read_non_whitespace(stream)
-        if not tok:
-            raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
-        if tok == b">":
-            break
-        x += tok
-        if len(x) == 2:
-            txt += chr(int(x, base=16))
-            x = b""
-    if len(x) == 1:
-        x += b"0"
-    if len(x) == 2:
-        txt += chr(int(x, base=16))
-    return create_string_object(b_(txt), forced_encoding)
-
-
-def readStringFromStream(
-    stream: StreamType,
-    forced_encoding: Union[None, str, List[str], Dict[int, str]] = None,
-) -> Union["TextStringObject", "ByteStringObject"]:  # pragma: no cover
-    deprecate_with_replacement(
-        "readStringFromStream", "read_string_from_stream", "4.0.0"
-    )
-    return read_string_from_stream(stream, forced_encoding)
-
-
-def read_string_from_stream(
-    stream: StreamType,
-    forced_encoding: Union[None, str, List[str], Dict[int, str]] = None,
-) -> Union["TextStringObject", "ByteStringObject"]:
-    tok = stream.read(1)
-    parens = 1
-    txt = b""
-    while True:
-        tok = stream.read(1)
-        if not tok:
-            raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
-        if tok == b"(":
-            parens += 1
-        elif tok == b")":
-            parens -= 1
-            if parens == 0:
-                break
-        elif tok == b"\\":
-            tok = stream.read(1)
-            escape_dict = {
-                b"n": b"\n",
-                b"r": b"\r",
-                b"t": b"\t",
-                b"b": b"\b",
-                b"f": b"\f",
-                b"c": rb"\c",
-                b"(": b"(",
-                b")": b")",
-                b"/": b"/",
-                b"\\": b"\\",
-                b" ": b" ",
-                b"%": b"%",
-                b"<": b"<",
-                b">": b">",
-                b"[": b"[",
-                b"]": b"]",
-                b"#": b"#",
-                b"_": b"_",
-                b"&": b"&",
-                b"$": b"$",
-            }
-            try:
-                tok = escape_dict[tok]
-            except KeyError:
-                if tok.isdigit():
-                    # "The number ddd may consist of one, two, or three
-                    # octal digits; high-order overflow shall be ignored.
-                    # Three octal digits shall be used, with leading zeros
-                    # as needed, if the next character of the string is also
-                    # a digit." (PDF reference 7.3.4.2, p 16)
-                    for _ in range(2):
-                        ntok = stream.read(1)
-                        if ntok.isdigit():
-                            tok += ntok
-                        else:
-                            break
-                    tok = b_(chr(int(tok, base=8)))
-                elif tok in b"\n\r":
-                    # This case is  hit when a backslash followed by a line
-                    # break occurs.  If it's a multi-char EOL, consume the
-                    # second character:
-                    tok = stream.read(1)
-                    if tok not in b"\n\r":
-                        stream.seek(-1, 1)
-                    # Then don't add anything to the actual string, since this
-                    # line break was escaped:
-                    tok = b""
-                else:
-                    msg = rf"Unexpected escaped string: {tok.decode('utf8')}"
-                    logger.warning(msg)
-        txt += tok
-    return create_string_object(txt, forced_encoding)
-
-
-class ByteStringObject(bytes, PdfObject):  # type: ignore
-    """
-    Represents a string object where the text encoding could not be determined.
-    This occurs quite often, as the PDF spec doesn't provide an alternate way to
-    represent strings -- for example, the encryption data stored in files (like
-    /O) is clearly not text, but is still stored in a "String" object.
-    """
-
-    @property
-    def original_bytes(self) -> bytes:
-        """For compatibility with TextStringObject.original_bytes."""
-        return self
-
-    def write_to_stream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:
-        bytearr = self
-        if encryption_key:
-            from ._security import RC4_encrypt
-
-            bytearr = RC4_encrypt(encryption_key, bytearr)  # type: ignore
-        stream.write(b"<")
-        stream.write(hexencode(bytearr))
-        stream.write(b">")
-
-    def writeToStream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("writeToStream", "write_to_stream")
-        self.write_to_stream(stream, encryption_key)
-
-
-class TextStringObject(str, PdfObject):
-    """
-    Represents a string object that has been decoded into a real unicode string.
-    If read from a PDF document, this string appeared to match the
-    PDFDocEncoding, or contained a UTF-16BE BOM mark to cause UTF-16 decoding to
-    occur.
-    """
-
-    autodetect_pdfdocencoding = False
-    autodetect_utf16 = False
-
-    @property
-    def original_bytes(self) -> bytes:
-        """
-        It is occasionally possible that a text string object gets created where
-        a byte string object was expected due to the autodetection mechanism --
-        if that occurs, this "original_bytes" property can be used to
-        back-calculate what the original encoded bytes were.
-        """
-        return self.get_original_bytes()
-
-    def get_original_bytes(self) -> bytes:
-        # We're a text string object, but the library is trying to get our raw
-        # bytes.  This can happen if we auto-detected this string as text, but
-        # we were wrong.  It's pretty common.  Return the original bytes that
-        # would have been used to create this object, based upon the autodetect
-        # method.
-        if self.autodetect_utf16:
-            return codecs.BOM_UTF16_BE + self.encode("utf-16be")
-        elif self.autodetect_pdfdocencoding:
-            return encode_pdfdocencoding(self)
-        else:
-            raise Exception("no information about original bytes")
-
-    def write_to_stream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:
-        # Try to write the string out as a PDFDocEncoding encoded string.  It's
-        # nicer to look at in the PDF file.  Sadly, we take a performance hit
-        # here for trying...
-        try:
-            bytearr = encode_pdfdocencoding(self)
-        except UnicodeEncodeError:
-            bytearr = codecs.BOM_UTF16_BE + self.encode("utf-16be")
-        if encryption_key:
-            from ._security import RC4_encrypt
-
-            bytearr = RC4_encrypt(encryption_key, bytearr)
-            obj = ByteStringObject(bytearr)
-            obj.write_to_stream(stream, None)
-        else:
-            stream.write(b"(")
-            for c in bytearr:
-                if not chr(c).isalnum() and c != b" ":
-                    # This:
-                    #   stream.write(b_(rf"\{c:0>3o}"))
-                    # gives
-                    #   https://github.com/davidhalter/parso/issues/207
-                    stream.write(b_("\\%03o" % c))
-                else:
-                    stream.write(b_(chr(c)))
-            stream.write(b")")
-
-    def writeToStream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("writeToStream", "write_to_stream")
-        self.write_to_stream(stream, encryption_key)
-
-
-class NameObject(str, PdfObject):
-    delimiter_pattern = re.compile(rb"\s+|[\(\)<>\[\]{}/%]")
-    surfix = b"/"
-
-    def write_to_stream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:
-        stream.write(b_(self))
-
-    def writeToStream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("writeToStream", "write_to_stream")
-        self.write_to_stream(stream, encryption_key)
-
-    @staticmethod
-    def read_from_stream(stream: StreamType, pdf: Any) -> "NameObject":  # PdfReader
-        name = stream.read(1)
-        if name != NameObject.surfix:
-            raise PdfReadError("name read error")
-        name += read_until_regex(stream, NameObject.delimiter_pattern, ignore_eof=True)
-        try:
-            try:
-                ret = name.decode("utf-8")
-            except (UnicodeEncodeError, UnicodeDecodeError):
-                ret = name.decode("gbk")
-            return NameObject(ret)
-        except (UnicodeEncodeError, UnicodeDecodeError) as e:
-            # Name objects should represent irregular characters
-            # with a '#' followed by the symbol's hex number
-            if not pdf.strict:
-                warnings.warn("Illegal character in Name Object", PdfReadWarning)
-                return NameObject(name)
-            else:
-                raise PdfReadError("Illegal character in Name Object") from e
-
-    @staticmethod
-    def readFromStream(
-        stream: StreamType, pdf: Any  # PdfReader
-    ) -> "NameObject":  # pragma: no cover
-        deprecate_with_replacement("readFromStream", "read_from_stream")
-        return NameObject.read_from_stream(stream, pdf)
-
-
 class DictionaryObject(dict, PdfObject):
     def raw_get(self, key: Any) -> Any:
         return dict.__getitem__(self, key)
@@ -690,7 +160,7 @@ def xmp_metadata(self) -> Optional[PdfObject]:
         that can be used to access XMP metadata from the document.  Can also
         return None if no metadata was found on the document root.
         """
-        from .xmp import XmpInformation
+        from ..xmp import XmpInformation
 
         metadata = self.get("/Metadata", None)
         if metadata is None:
@@ -805,10 +275,9 @@ def read_unsized_from_steam(stream: StreamType, pdf: Any) -> bytes:  # PdfReader
                     f"Multiple definitions in dictionary at byte "
                     f"{hex_str(stream.tell())} for key {key}"
                 )
-                if pdf.strict:
+                if pdf is not None and pdf.strict:
                     raise PdfReadError(msg)
-                else:
-                    warnings.warn(msg, PdfReadWarning)
+                logger_warning(msg, __name__)
 
         pos = stream.tell()
         s = read_non_whitespace(stream)
@@ -857,7 +326,7 @@ def read_unsized_from_steam(stream: StreamType, pdf: Any) -> bytes:  # PdfReader
                     stream.seek(pos, 0)
                     raise PdfReadError(
                         "Unable to find 'endstream' marker after stream at byte "
-                        f"{hex_str(stream.tell())}."
+                        f"{hex_str(stream.tell())} (nd='{ndstream!r}', end='{end!r}')."
                     )
         else:
             stream.seek(pos, 0)
@@ -890,22 +359,26 @@ def has_children(self) -> bool:
     def __iter__(self) -> Any:
         return self.children()
 
-    def children(self) -> Optional[Any]:
+    def children(self) -> Iterable[Any]:
         if not self.has_children():
             return
 
-        child = self["/First"]
+        child_ref = self[NameObject("/First")]
+        child = child_ref.get_object()
         while True:
             yield child
-            if child == self["/Last"]:
+            if child == self[NameObject("/Last")]:
                 return
-            child = child["/Next"]  # type: ignore
+            child_ref = child.get(NameObject("/Next"))  # type: ignore
+            if child_ref is None:
+                return
+            child = child_ref.get_object()
 
     def addChild(self, child: Any, pdf: Any) -> None:  # pragma: no cover
         deprecate_with_replacement("addChild", "add_child")
         self.add_child(child, pdf)
 
-    def add_child(self, child: Any, pdf: Any) -> None:  # PdfReader
+    def add_child(self, child: Any, pdf: Any) -> None:  # PdfWriter
         child_obj = child.get_object()
         child = pdf.get_reference(child_obj)
         assert isinstance(child, IndirectObject)
@@ -937,6 +410,41 @@ def removeChild(self, child: Any) -> None:  # pragma: no cover
         deprecate_with_replacement("removeChild", "remove_child")
         self.remove_child(child)
 
+    def _remove_node_from_tree(
+        self, prev: Any, prev_ref: Any, cur: Any, last: Any
+    ) -> None:
+        """Adjust the pointers of the linked list and tree node count."""
+        next_ref = cur.get(NameObject("/Next"), None)
+        if prev is None:
+            if next_ref:
+                # Removing first tree node
+                next_obj = next_ref.get_object()
+                del next_obj[NameObject("/Prev")]
+                self[NameObject("/First")] = next_ref
+                self[NameObject("/Count")] = NumberObject(
+                    self[NameObject("/Count")] - 1  # type: ignore
+                )
+
+            else:
+                # Removing only tree node
+                assert self[NameObject("/Count")] == 1
+                del self[NameObject("/Count")]
+                del self[NameObject("/First")]
+                if NameObject("/Last") in self:
+                    del self[NameObject("/Last")]
+        else:
+            if next_ref:
+                # Removing middle tree node
+                next_obj = next_ref.get_object()
+                next_obj[NameObject("/Prev")] = prev_ref
+                prev[NameObject("/Next")] = next_ref
+            else:
+                # Removing last tree node
+                assert cur == last
+                del prev[NameObject("/Next")]
+                self[NameObject("/Last")] = prev_ref
+            self[NameObject("/Count")] = NumberObject(self[NameObject("/Count")] - 1)  # type: ignore
+
     def remove_child(self, child: Any) -> None:
         child_obj = child.get_object()
 
@@ -954,39 +462,11 @@ def remove_child(self, child: Any) -> None:
         last = last_ref.get_object()
         while cur is not None:
             if cur == child_obj:
-                if prev is None:
-                    if NameObject("/Next") in cur:
-                        # Removing first tree node
-                        next_ref = cur[NameObject("/Next")]
-                        next_obj = next_ref.get_object()
-                        del next_obj[NameObject("/Prev")]
-                        self[NameObject("/First")] = next_ref
-                        self[NameObject("/Count")] -= 1  # type: ignore
-
-                    else:
-                        # Removing only tree node
-                        assert self[NameObject("/Count")] == 1
-                        del self[NameObject("/Count")]
-                        del self[NameObject("/First")]
-                        if NameObject("/Last") in self:
-                            del self[NameObject("/Last")]
-                else:
-                    if NameObject("/Next") in cur:
-                        # Removing middle tree node
-                        next_ref = cur[NameObject("/Next")]
-                        next_obj = next_ref.get_object()
-                        next_obj[NameObject("/Prev")] = prev_ref
-                        prev[NameObject("/Next")] = next_ref
-                        self[NameObject("/Count")] -= 1
-                    else:
-                        # Removing last tree node
-                        assert cur == last
-                        del prev[NameObject("/Next")]
-                        self[NameObject("/Last")] = prev_ref
-                        self[NameObject("/Count")] -= 1
+                self._remove_node_from_tree(prev, prev_ref, cur, last)
                 found = True
                 break
 
+            # Go to the next node
             prev_ref = cur_ref
             prev = cur
             if NameObject("/Next") in cur:
@@ -999,11 +479,7 @@ def remove_child(self, child: Any) -> None:
         if not found:
             raise ValueError("Removal couldn't find item in tree")
 
-        del child_obj[NameObject("/Parent")]
-        if NameObject("/Next") in child_obj:
-            del child_obj[NameObject("/Next")]
-        if NameObject("/Prev") in child_obj:
-            del child_obj[NameObject("/Prev")]
+        _reset_node_tree_relationship(child_obj)
 
     def emptyTree(self) -> None:  # pragma: no cover
         deprecate_with_replacement("emptyTree", "empty_tree", "4.0.0")
@@ -1012,11 +488,7 @@ def emptyTree(self) -> None:  # pragma: no cover
     def empty_tree(self) -> None:
         for child in self:
             child_obj = child.get_object()
-            del child_obj[NameObject("/Parent")]
-            if NameObject("/Next") in child_obj:
-                del child_obj[NameObject("/Next")]
-            if NameObject("/Prev") in child_obj:
-                del child_obj[NameObject("/Prev")]
+            _reset_node_tree_relationship(child_obj)
 
         if NameObject("/Count") in self:
             del self[NameObject("/Count")]
@@ -1026,6 +498,19 @@ def empty_tree(self) -> None:
             del self[NameObject("/Last")]
 
 
+def _reset_node_tree_relationship(child_obj: Any) -> None:
+    """
+    Call this after a node has been removed from a tree.
+
+    This resets the nodes attributes in respect to that tree.
+    """
+    del child_obj[NameObject("/Parent")]
+    if NameObject("/Next") in child_obj:
+        del child_obj[NameObject("/Next")]
+    if NameObject("/Prev") in child_obj:
+        del child_obj[NameObject("/Prev")]
+
+
 class StreamObject(DictionaryObject):
     def __init__(self) -> None:
         self.__data: Optional[str] = None
@@ -1063,7 +548,7 @@ def write_to_stream(
         stream.write(b"\nstream\n")
         data = self._data
         if encryption_key:
-            from ._security import RC4_encrypt
+            from .._security import RC4_encrypt
 
             data = RC4_encrypt(encryption_key, data)
         stream.write(data)
@@ -1095,7 +580,7 @@ def flateEncode(self) -> "EncodedStreamObject":  # pragma: no cover
         return self.flate_encode()
 
     def flate_encode(self) -> "EncodedStreamObject":
-        from .filters import FlateDecode
+        from ..filters import FlateDecode
 
         if SA.FILTER in self:
             f = self[SA.FILTER]
@@ -1145,7 +630,7 @@ def decodedSelf(self, value: DecodedStreamObject) -> None:  # pragma: no cover
         self.decoded_self = value
 
     def get_data(self) -> Union[None, str, bytes]:
-        from .filters import decode_stream_data
+        from ..filters import decode_stream_data
 
         if self.decoded_self is not None:
             # cached version of decoded object
@@ -1161,9 +646,17 @@ def get_data(self) -> Union[None, str, bytes]:
             self.decoded_self = decoded
             return decoded._data
 
-    def set_data(self, data: Any) -> None:
+    def getData(self) -> Union[None, str, bytes]:  # pragma: no cover
+        deprecate_with_replacement("getData", "get_data")
+        return self.get_data()
+
+    def set_data(self, data: Any) -> None:  # pragma: no cover
         raise PdfReadError("Creating EncodedStreamObject is not currently supported")
 
+    def setData(self, data: Any) -> None:  # pragma: no cover
+        deprecate_with_replacement("setData", "set_data")
+        return self.set_data(data)
+
 
 class ContentStream(DecodedStreamObject):
     def __init__(
@@ -1186,6 +679,8 @@ def __init__(
             data = b""
             for s in stream:
                 data += b_(s.get_object().get_data())
+                if data[-1] != b"\n":
+                    data += b"\n"
             stream_bytes = BytesIO(data)
         else:
             stream_data = stream.get_data()
@@ -1358,247 +853,6 @@ def read_object(
             return NumberObject.read_from_stream(stream)
 
 
-class RectangleObject(ArrayObject):
-    """
-    This class is used to represent *page boxes* in PyPDF2. These boxes include:
-        * :attr:`artbox <PyPDF2._page.PageObject.artbox>`
-        * :attr:`bleedbox <PyPDF2._page.PageObject.bleedbox>`
-        * :attr:`cropbox <PyPDF2._page.PageObject.cropbox>`
-        * :attr:`mediabox <PyPDF2._page.PageObject.mediabox>`
-        * :attr:`trimbox <PyPDF2._page.PageObject.trimbox>`
-    """
-
-    def __init__(self, arr: Tuple[float, float, float, float]) -> None:
-        # must have four points
-        assert len(arr) == 4
-        # automatically convert arr[x] into NumberObject(arr[x]) if necessary
-        ArrayObject.__init__(self, [self._ensure_is_number(x) for x in arr])  # type: ignore
-
-    def _ensure_is_number(self, value: Any) -> Union[FloatObject, NumberObject]:
-        if not isinstance(value, (NumberObject, FloatObject)):
-            value = FloatObject(value)
-        return value
-
-    def scale(self, sx: float, sy: float) -> "RectangleObject":
-        return RectangleObject(
-            (
-                float(self.left) * sx,
-                float(self.bottom) * sy,
-                float(self.right) * sx,
-                float(self.top) * sy,
-            )
-        )
-
-    def ensureIsNumber(
-        self, value: Any
-    ) -> Union[FloatObject, NumberObject]:  # pragma: no cover
-        deprecate_no_replacement("ensureIsNumber")
-        return self._ensure_is_number(value)
-
-    def __repr__(self) -> str:
-        return f"RectangleObject({repr(list(self))})"
-
-    @property
-    def left(self) -> FloatObject:
-        return self[0]
-
-    @property
-    def bottom(self) -> FloatObject:
-        return self[1]
-
-    @property
-    def right(self) -> FloatObject:
-        return self[2]
-
-    @property
-    def top(self) -> FloatObject:
-        return self[3]
-
-    def getLowerLeft_x(self) -> FloatObject:  # pragma: no cover
-        deprecate_with_replacement("getLowerLeft_x", "left")
-        return self.left
-
-    def getLowerLeft_y(self) -> FloatObject:  # pragma: no cover
-        deprecate_with_replacement("getLowerLeft_y", "bottom")
-        return self.bottom
-
-    def getUpperRight_x(self) -> FloatObject:  # pragma: no cover
-        deprecate_with_replacement("getUpperRight_x", "right")
-        return self.right
-
-    def getUpperRight_y(self) -> FloatObject:  # pragma: no cover
-        deprecate_with_replacement("getUpperRight_y", "top")
-        return self.top
-
-    def getUpperLeft_x(self) -> FloatObject:  # pragma: no cover
-        deprecate_with_replacement("getUpperLeft_x", "left")
-        return self.left
-
-    def getUpperLeft_y(self) -> FloatObject:  # pragma: no cover
-        deprecate_with_replacement("getUpperLeft_y", "top")
-        return self.top
-
-    def getLowerRight_x(self) -> FloatObject:  # pragma: no cover
-        deprecate_with_replacement("getLowerRight_x", "right")
-        return self.right
-
-    def getLowerRight_y(self) -> FloatObject:  # pragma: no cover
-        deprecate_with_replacement("getLowerRight_y", "bottom")
-        return self.bottom
-
-    @property
-    def lower_left(self) -> Tuple[decimal.Decimal, decimal.Decimal]:
-        """
-        Property to read and modify the lower left coordinate of this box
-        in (x,y) form.
-        """
-        return self.left, self.bottom
-
-    @lower_left.setter
-    def lower_left(self, value: List[Any]) -> None:
-        self[0], self[1] = (self._ensure_is_number(x) for x in value)
-
-    @property
-    def lower_right(self) -> Tuple[decimal.Decimal, decimal.Decimal]:
-        """
-        Property to read and modify the lower right coordinate of this box
-        in (x,y) form.
-        """
-        return self.right, self.bottom
-
-    @lower_right.setter
-    def lower_right(self, value: List[Any]) -> None:
-        self[2], self[1] = (self._ensure_is_number(x) for x in value)
-
-    @property
-    def upper_left(self) -> Tuple[decimal.Decimal, decimal.Decimal]:
-        """
-        Property to read and modify the upper left coordinate of this box
-        in (x,y) form.
-        """
-        return self.left, self.top
-
-    @upper_left.setter
-    def upper_left(self, value: List[Any]) -> None:
-        self[0], self[3] = (self._ensure_is_number(x) for x in value)
-
-    @property
-    def upper_right(self) -> Tuple[decimal.Decimal, decimal.Decimal]:
-        """
-        Property to read and modify the upper right coordinate of this box
-        in (x,y) form.
-        """
-        return self.right, self.top
-
-    @upper_right.setter
-    def upper_right(self, value: List[Any]) -> None:
-        self[2], self[3] = (self._ensure_is_number(x) for x in value)
-
-    def getLowerLeft(
-        self,
-    ) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
-        deprecate_with_replacement("getLowerLeft", "lower_left")
-        return self.lower_left
-
-    def getLowerRight(
-        self,
-    ) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
-        deprecate_with_replacement("getLowerRight", "lower_right")
-        return self.lower_right
-
-    def getUpperLeft(
-        self,
-    ) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
-        deprecate_with_replacement("getUpperLeft", "upper_left")
-        return self.upper_left
-
-    def getUpperRight(
-        self,
-    ) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
-        deprecate_with_replacement("getUpperRight", "upper_right")
-        return self.upper_right
-
-    def setLowerLeft(self, value: Tuple[float, float]) -> None:  # pragma: no cover
-        deprecate_with_replacement("setLowerLeft", "lower_left")
-        self.lower_left = value  # type: ignore
-
-    def setLowerRight(self, value: Tuple[float, float]) -> None:  # pragma: no cover
-        deprecate_with_replacement("setLowerRight", "lower_right")
-        self[2], self[1] = (self._ensure_is_number(x) for x in value)
-
-    def setUpperLeft(self, value: Tuple[float, float]) -> None:  # pragma: no cover
-        deprecate_with_replacement("setUpperLeft", "upper_left")
-        self[0], self[3] = (self._ensure_is_number(x) for x in value)
-
-    def setUpperRight(self, value: Tuple[float, float]) -> None:  # pragma: no cover
-        deprecate_with_replacement("setUpperRight", "upper_right")
-        self[2], self[3] = (self._ensure_is_number(x) for x in value)
-
-    @property
-    def width(self) -> decimal.Decimal:
-        return self.right - self.left
-
-    def getWidth(self) -> decimal.Decimal:  # pragma: no cover
-        deprecate_with_replacement("getWidth", "width")
-        return self.width
-
-    @property
-    def height(self) -> decimal.Decimal:
-        return self.top - self.bottom
-
-    def getHeight(self) -> decimal.Decimal:  # pragma: no cover
-        deprecate_with_replacement("getHeight", "height")
-        return self.height
-
-    @property
-    def lowerLeft(self) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
-        deprecate_with_replacement("lowerLeft", "lower_left")
-        return self.lower_left
-
-    @lowerLeft.setter
-    def lowerLeft(
-        self, value: Tuple[decimal.Decimal, decimal.Decimal]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("lowerLeft", "lower_left")
-        self.lower_left = value
-
-    @property
-    def lowerRight(self) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
-        deprecate_with_replacement("lowerRight", "lower_right")
-        return self.lower_right
-
-    @lowerRight.setter
-    def lowerRight(
-        self, value: Tuple[decimal.Decimal, decimal.Decimal]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("lowerRight", "lower_right")
-        self.lower_right = value
-
-    @property
-    def upperLeft(self) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
-        deprecate_with_replacement("upperLeft", "upper_left")
-        return self.upper_left
-
-    @upperLeft.setter
-    def upperLeft(
-        self, value: Tuple[decimal.Decimal, decimal.Decimal]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("upperLeft", "upper_left")
-        self.upper_left = value
-
-    @property
-    def upperRight(self) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
-        deprecate_with_replacement("upperRight", "upper_right")
-        return self.upper_right
-
-    @upperRight.setter
-    def upperRight(
-        self, value: Tuple[decimal.Decimal, decimal.Decimal]
-    ) -> None:  # pragma: no cover
-        deprecate_with_replacement("upperRight", "upper_right")
-        self.upper_right = value
-
-
 class Field(TreeObject):
     """
     A class representing a field dictionary.
@@ -1609,8 +863,11 @@ class Field(TreeObject):
 
     def __init__(self, data: Dict[str, Any]) -> None:
         DictionaryObject.__init__(self)
-
-        for attr in FieldDictionaryAttributes.attributes():
+        field_attributes = (
+            FieldDictionaryAttributes.attributes()
+            + CheckboxRadioButtonAttributes.attributes()
+        )
+        for attr in field_attributes:
             try:
                 self[NameObject(attr)] = data[attr]
             except KeyError:
@@ -1792,9 +1049,15 @@ def __init__(
                 self[NameObject(TA.TOP)],
             ) = args
         elif typ in [TF.FIT_H, TF.FIT_BH]:
-            (self[NameObject(TA.TOP)],) = args
+            try:  # Prefered to be more robust not only to null parameters
+                (self[NameObject(TA.TOP)],) = args
+            except Exception:
+                (self[NameObject(TA.TOP)],) = (NullObject(),)
         elif typ in [TF.FIT_V, TF.FIT_BV]:
-            (self[NameObject(TA.LEFT)],) = args
+            try:  # Prefered to be more robust not only to null parameters
+                (self[NameObject(TA.LEFT)],) = args
+            except Exception:
+                (self[NameObject(TA.LEFT)],) = (NullObject(),)
         elif typ in [TF.FIT, TF.FIT_B]:
             pass
         else:
@@ -1879,139 +1142,24 @@ def bottom(self) -> Optional[FloatObject]:
         """Read-only property accessing the bottom vertical coordinate."""
         return self.get("/Bottom", None)
 
-
-class Bookmark(Destination):
-    def write_to_stream(
-        self, stream: StreamType, encryption_key: Union[None, str, bytes]
-    ) -> None:
-        stream.write(b"<<\n")
-        for key in [
-            NameObject(x)
-            for x in ["/Title", "/Parent", "/First", "/Last", "/Next", "/Prev"]
-            if x in self
-        ]:
-            key.write_to_stream(stream, encryption_key)
-            stream.write(b" ")
-            value = self.raw_get(key)
-            value.write_to_stream(stream, encryption_key)
-            stream.write(b"\n")
-        key = NameObject("/Dest")
-        key.write_to_stream(stream, encryption_key)
-        stream.write(b" ")
-        value = self.dest_array
-        value.write_to_stream(stream, encryption_key)
-        stream.write(b"\n")
-        stream.write(b">>")
-
-
-def createStringObject(
-    string: Union[str, bytes],
-    forced_encoding: Union[None, str, List[str], Dict[int, str]] = None,
-) -> Union[TextStringObject, ByteStringObject]:  # pragma: no cover
-    deprecate_with_replacement("createStringObject", "create_string_object", "4.0.0")
-    return create_string_object(string, forced_encoding)
-
-
-def create_string_object(
-    string: Union[str, bytes],
-    forced_encoding: Union[None, str, List[str], Dict[int, str]] = None,
-) -> Union[TextStringObject, ByteStringObject]:
-    """
-    Create a ByteStringObject or a TextStringObject from a string to represent the string.
-
-    :param string: A string
-
-    :raises TypeError: If string is not of type str or bytes.
-    """
-    if isinstance(string, str):
-        return TextStringObject(string)
-    elif isinstance(string, bytes):
-        if isinstance(forced_encoding, (list, dict)):
-            out = ""
-            for x in string:
-                try:
-                    out += forced_encoding[x]
-                except Exception:
-                    out += bytes((x,)).decode("charmap")
-            return TextStringObject(out)
-        elif isinstance(forced_encoding, str):
-            if forced_encoding == "bytes":
-                return ByteStringObject(string)
-            return TextStringObject(string.decode(forced_encoding))
-        else:
-            try:
-                if string.startswith(codecs.BOM_UTF16_BE):
-                    retval = TextStringObject(string.decode("utf-16"))
-                    retval.autodetect_utf16 = True
-                    return retval
-                else:
-                    # This is probably a big performance hit here, but we need to
-                    # convert string objects into the text/unicode-aware version if
-                    # possible... and the only way to check if that's possible is
-                    # to try.  Some strings are strings, some are just byte arrays.
-                    retval = TextStringObject(decode_pdfdocencoding(string))
-                    retval.autodetect_pdfdocencoding = True
-                    return retval
-            except UnicodeDecodeError:
-                return ByteStringObject(string)
-    else:
-        raise TypeError("create_string_object should have str or unicode arg")
-
-
-def _create_bookmark(
-    action_ref: IndirectObject,
-    title: str,
-    color: Optional[Tuple[float, float, float]],
-    italic: bool,
-    bold: bool,
-) -> TreeObject:
-    bookmark = TreeObject()
-
-    bookmark.update(
-        {
-            NameObject("/A"): action_ref,
-            NameObject("/Title"): create_string_object(title),
-        }
-    )
-
-    if color is not None:
-        bookmark.update(
-            {NameObject("/C"): ArrayObject([FloatObject(c) for c in color])}
+    @property
+    def color(self) -> Optional[ArrayObject]:
+        """Read-only property accessing the color in (R, G, B) with values 0.0-1.0"""
+        return self.get(
+            "/C", ArrayObject([FloatObject(0), FloatObject(0), FloatObject(0)])
         )
 
-    format_flag = 0
-    if italic:
-        format_flag += 1
-    if bold:
-        format_flag += 2
-    if format_flag:
-        bookmark.update({NameObject("/F"): NumberObject(format_flag)})
-    return bookmark
-
-
-def encode_pdfdocencoding(unicode_string: str) -> bytes:
-    retval = b""
-    for c in unicode_string:
-        try:
-            retval += b_(chr(_pdfdoc_encoding_rev[c]))
-        except KeyError:
-            raise UnicodeEncodeError(
-                "pdfdocencoding", c, -1, -1, "does not exist in translation table"
-            )
-    return retval
-
-
-def decode_pdfdocencoding(byte_array: bytes) -> str:
-    retval = ""
-    for b in byte_array:
-        c = _pdfdoc_encoding[b]
-        if c == "\u0000":
-            raise UnicodeDecodeError(
-                "pdfdocencoding",
-                bytearray(b),
-                -1,
-                -1,
-                "does not exist in translation table",
-            )
-        retval += c
-    return retval
+    @property
+    def font_format(self) -> Optional[OutlineFontFlag]:
+        """Read-only property accessing the font type. 1=italic, 2=bold, 3=both"""
+        return self.get("/F", 0)
+
+    @property
+    def outline_count(self) -> Optional[int]:
+        """
+        Read-only property accessing the outline count.
+        positive = expanded
+        negative = collapsed
+        absolute value = number of visible descendents at all levels
+        """
+        return self.get("/Count", None)
diff --git a/PyPDF2/generic/_outline.py b/PyPDF2/generic/_outline.py
new file mode 100644
index 000000000..0c8b17cdf
--- /dev/null
+++ b/PyPDF2/generic/_outline.py
@@ -0,0 +1,35 @@
+from typing import Any, Union
+
+from .._utils import StreamType, deprecate_with_replacement
+from ._base import NameObject
+from ._data_structures import Destination
+
+
+class OutlineItem(Destination):
+    def write_to_stream(
+        self, stream: StreamType, encryption_key: Union[None, str, bytes]
+    ) -> None:
+        stream.write(b"<<\n")
+        for key in [
+            NameObject(x)
+            for x in ["/Title", "/Parent", "/First", "/Last", "/Next", "/Prev"]
+            if x in self
+        ]:
+            key.write_to_stream(stream, encryption_key)
+            stream.write(b" ")
+            value = self.raw_get(key)
+            value.write_to_stream(stream, encryption_key)
+            stream.write(b"\n")
+        key = NameObject("/Dest")
+        key.write_to_stream(stream, encryption_key)
+        stream.write(b" ")
+        value = self.dest_array
+        value.write_to_stream(stream, encryption_key)
+        stream.write(b"\n")
+        stream.write(b">>")
+
+
+class Bookmark(OutlineItem):  # pragma: no cover
+    def __init__(self, *args: Any, **kwargs: Any) -> None:
+        deprecate_with_replacement("Bookmark", "OutlineItem")
+        super().__init__(*args, **kwargs)
diff --git a/PyPDF2/generic/_rectangle.py b/PyPDF2/generic/_rectangle.py
new file mode 100644
index 000000000..e8e19d5ab
--- /dev/null
+++ b/PyPDF2/generic/_rectangle.py
@@ -0,0 +1,249 @@
+import decimal
+from typing import Any, List, Tuple, Union
+
+from .._utils import deprecate_no_replacement, deprecate_with_replacement
+from ._base import FloatObject, NumberObject
+from ._data_structures import ArrayObject
+
+
+class RectangleObject(ArrayObject):
+    """
+    This class is used to represent *page boxes* in PyPDF2. These boxes include:
+        * :attr:`artbox <PyPDF2._page.PageObject.artbox>`
+        * :attr:`bleedbox <PyPDF2._page.PageObject.bleedbox>`
+        * :attr:`cropbox <PyPDF2._page.PageObject.cropbox>`
+        * :attr:`mediabox <PyPDF2._page.PageObject.mediabox>`
+        * :attr:`trimbox <PyPDF2._page.PageObject.trimbox>`
+    """
+
+    def __init__(
+        self, arr: Union["RectangleObject", Tuple[float, float, float, float]]
+    ) -> None:
+        # must have four points
+        assert len(arr) == 4
+        # automatically convert arr[x] into NumberObject(arr[x]) if necessary
+        ArrayObject.__init__(self, [self._ensure_is_number(x) for x in arr])  # type: ignore
+
+    def _ensure_is_number(self, value: Any) -> Union[FloatObject, NumberObject]:
+        if not isinstance(value, (NumberObject, FloatObject)):
+            value = FloatObject(value)
+        return value
+
+    def scale(self, sx: float, sy: float) -> "RectangleObject":
+        return RectangleObject(
+            (
+                float(self.left) * sx,
+                float(self.bottom) * sy,
+                float(self.right) * sx,
+                float(self.top) * sy,
+            )
+        )
+
+    def ensureIsNumber(
+        self, value: Any
+    ) -> Union[FloatObject, NumberObject]:  # pragma: no cover
+        deprecate_no_replacement("ensureIsNumber")
+        return self._ensure_is_number(value)
+
+    def __repr__(self) -> str:
+        return f"RectangleObject({repr(list(self))})"
+
+    @property
+    def left(self) -> FloatObject:
+        return self[0]
+
+    @property
+    def bottom(self) -> FloatObject:
+        return self[1]
+
+    @property
+    def right(self) -> FloatObject:
+        return self[2]
+
+    @property
+    def top(self) -> FloatObject:
+        return self[3]
+
+    def getLowerLeft_x(self) -> FloatObject:  # pragma: no cover
+        deprecate_with_replacement("getLowerLeft_x", "left")
+        return self.left
+
+    def getLowerLeft_y(self) -> FloatObject:  # pragma: no cover
+        deprecate_with_replacement("getLowerLeft_y", "bottom")
+        return self.bottom
+
+    def getUpperRight_x(self) -> FloatObject:  # pragma: no cover
+        deprecate_with_replacement("getUpperRight_x", "right")
+        return self.right
+
+    def getUpperRight_y(self) -> FloatObject:  # pragma: no cover
+        deprecate_with_replacement("getUpperRight_y", "top")
+        return self.top
+
+    def getUpperLeft_x(self) -> FloatObject:  # pragma: no cover
+        deprecate_with_replacement("getUpperLeft_x", "left")
+        return self.left
+
+    def getUpperLeft_y(self) -> FloatObject:  # pragma: no cover
+        deprecate_with_replacement("getUpperLeft_y", "top")
+        return self.top
+
+    def getLowerRight_x(self) -> FloatObject:  # pragma: no cover
+        deprecate_with_replacement("getLowerRight_x", "right")
+        return self.right
+
+    def getLowerRight_y(self) -> FloatObject:  # pragma: no cover
+        deprecate_with_replacement("getLowerRight_y", "bottom")
+        return self.bottom
+
+    @property
+    def lower_left(self) -> Tuple[decimal.Decimal, decimal.Decimal]:
+        """
+        Property to read and modify the lower left coordinate of this box
+        in (x,y) form.
+        """
+        return self.left, self.bottom
+
+    @lower_left.setter
+    def lower_left(self, value: List[Any]) -> None:
+        self[0], self[1] = (self._ensure_is_number(x) for x in value)
+
+    @property
+    def lower_right(self) -> Tuple[decimal.Decimal, decimal.Decimal]:
+        """
+        Property to read and modify the lower right coordinate of this box
+        in (x,y) form.
+        """
+        return self.right, self.bottom
+
+    @lower_right.setter
+    def lower_right(self, value: List[Any]) -> None:
+        self[2], self[1] = (self._ensure_is_number(x) for x in value)
+
+    @property
+    def upper_left(self) -> Tuple[decimal.Decimal, decimal.Decimal]:
+        """
+        Property to read and modify the upper left coordinate of this box
+        in (x,y) form.
+        """
+        return self.left, self.top
+
+    @upper_left.setter
+    def upper_left(self, value: List[Any]) -> None:
+        self[0], self[3] = (self._ensure_is_number(x) for x in value)
+
+    @property
+    def upper_right(self) -> Tuple[decimal.Decimal, decimal.Decimal]:
+        """
+        Property to read and modify the upper right coordinate of this box
+        in (x,y) form.
+        """
+        return self.right, self.top
+
+    @upper_right.setter
+    def upper_right(self, value: List[Any]) -> None:
+        self[2], self[3] = (self._ensure_is_number(x) for x in value)
+
+    def getLowerLeft(
+        self,
+    ) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
+        deprecate_with_replacement("getLowerLeft", "lower_left")
+        return self.lower_left
+
+    def getLowerRight(
+        self,
+    ) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
+        deprecate_with_replacement("getLowerRight", "lower_right")
+        return self.lower_right
+
+    def getUpperLeft(
+        self,
+    ) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
+        deprecate_with_replacement("getUpperLeft", "upper_left")
+        return self.upper_left
+
+    def getUpperRight(
+        self,
+    ) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
+        deprecate_with_replacement("getUpperRight", "upper_right")
+        return self.upper_right
+
+    def setLowerLeft(self, value: Tuple[float, float]) -> None:  # pragma: no cover
+        deprecate_with_replacement("setLowerLeft", "lower_left")
+        self.lower_left = value  # type: ignore
+
+    def setLowerRight(self, value: Tuple[float, float]) -> None:  # pragma: no cover
+        deprecate_with_replacement("setLowerRight", "lower_right")
+        self[2], self[1] = (self._ensure_is_number(x) for x in value)
+
+    def setUpperLeft(self, value: Tuple[float, float]) -> None:  # pragma: no cover
+        deprecate_with_replacement("setUpperLeft", "upper_left")
+        self[0], self[3] = (self._ensure_is_number(x) for x in value)
+
+    def setUpperRight(self, value: Tuple[float, float]) -> None:  # pragma: no cover
+        deprecate_with_replacement("setUpperRight", "upper_right")
+        self[2], self[3] = (self._ensure_is_number(x) for x in value)
+
+    @property
+    def width(self) -> decimal.Decimal:
+        return self.right - self.left
+
+    def getWidth(self) -> decimal.Decimal:  # pragma: no cover
+        deprecate_with_replacement("getWidth", "width")
+        return self.width
+
+    @property
+    def height(self) -> decimal.Decimal:
+        return self.top - self.bottom
+
+    def getHeight(self) -> decimal.Decimal:  # pragma: no cover
+        deprecate_with_replacement("getHeight", "height")
+        return self.height
+
+    @property
+    def lowerLeft(self) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
+        deprecate_with_replacement("lowerLeft", "lower_left")
+        return self.lower_left
+
+    @lowerLeft.setter
+    def lowerLeft(
+        self, value: Tuple[decimal.Decimal, decimal.Decimal]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("lowerLeft", "lower_left")
+        self.lower_left = value
+
+    @property
+    def lowerRight(self) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
+        deprecate_with_replacement("lowerRight", "lower_right")
+        return self.lower_right
+
+    @lowerRight.setter
+    def lowerRight(
+        self, value: Tuple[decimal.Decimal, decimal.Decimal]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("lowerRight", "lower_right")
+        self.lower_right = value
+
+    @property
+    def upperLeft(self) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
+        deprecate_with_replacement("upperLeft", "upper_left")
+        return self.upper_left
+
+    @upperLeft.setter
+    def upperLeft(
+        self, value: Tuple[decimal.Decimal, decimal.Decimal]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("upperLeft", "upper_left")
+        self.upper_left = value
+
+    @property
+    def upperRight(self) -> Tuple[decimal.Decimal, decimal.Decimal]:  # pragma: no cover
+        deprecate_with_replacement("upperRight", "upper_right")
+        return self.upper_right
+
+    @upperRight.setter
+    def upperRight(
+        self, value: Tuple[decimal.Decimal, decimal.Decimal]
+    ) -> None:  # pragma: no cover
+        deprecate_with_replacement("upperRight", "upper_right")
+        self.upper_right = value
diff --git a/PyPDF2/generic/_utils.py b/PyPDF2/generic/_utils.py
new file mode 100644
index 000000000..1d4b492ec
--- /dev/null
+++ b/PyPDF2/generic/_utils.py
@@ -0,0 +1,172 @@
+import codecs
+from typing import Dict, List, Tuple, Union
+
+from .._codecs import _pdfdoc_encoding
+from .._utils import StreamType, b_, logger_warning, read_non_whitespace
+from ..errors import STREAM_TRUNCATED_PREMATURELY, PdfStreamError
+from ._base import ByteStringObject, TextStringObject
+
+
+def hex_to_rgb(value: str) -> Tuple[float, float, float]:
+    return tuple(int(value.lstrip("#")[i : i + 2], 16) / 255.0 for i in (0, 2, 4))  # type: ignore
+
+
+def read_hex_string_from_stream(
+    stream: StreamType,
+    forced_encoding: Union[None, str, List[str], Dict[int, str]] = None,
+) -> Union["TextStringObject", "ByteStringObject"]:
+    stream.read(1)
+    txt = ""
+    x = b""
+    while True:
+        tok = read_non_whitespace(stream)
+        if not tok:
+            raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
+        if tok == b">":
+            break
+        x += tok
+        if len(x) == 2:
+            txt += chr(int(x, base=16))
+            x = b""
+    if len(x) == 1:
+        x += b"0"
+    if len(x) == 2:
+        txt += chr(int(x, base=16))
+    return create_string_object(b_(txt), forced_encoding)
+
+
+def read_string_from_stream(
+    stream: StreamType,
+    forced_encoding: Union[None, str, List[str], Dict[int, str]] = None,
+) -> Union["TextStringObject", "ByteStringObject"]:
+    tok = stream.read(1)
+    parens = 1
+    txt = b""
+    while True:
+        tok = stream.read(1)
+        if not tok:
+            raise PdfStreamError(STREAM_TRUNCATED_PREMATURELY)
+        if tok == b"(":
+            parens += 1
+        elif tok == b")":
+            parens -= 1
+            if parens == 0:
+                break
+        elif tok == b"\\":
+            tok = stream.read(1)
+            escape_dict = {
+                b"n": b"\n",
+                b"r": b"\r",
+                b"t": b"\t",
+                b"b": b"\b",
+                b"f": b"\f",
+                b"c": rb"\c",
+                b"(": b"(",
+                b")": b")",
+                b"/": b"/",
+                b"\\": b"\\",
+                b" ": b" ",
+                b"%": b"%",
+                b"<": b"<",
+                b">": b">",
+                b"[": b"[",
+                b"]": b"]",
+                b"#": b"#",
+                b"_": b"_",
+                b"&": b"&",
+                b"$": b"$",
+            }
+            try:
+                tok = escape_dict[tok]
+            except KeyError:
+                if tok.isdigit():
+                    # "The number ddd may consist of one, two, or three
+                    # octal digits; high-order overflow shall be ignored.
+                    # Three octal digits shall be used, with leading zeros
+                    # as needed, if the next character of the string is also
+                    # a digit." (PDF reference 7.3.4.2, p 16)
+                    for _ in range(2):
+                        ntok = stream.read(1)
+                        if ntok.isdigit():
+                            tok += ntok
+                        else:
+                            stream.seek(-1, 1)  # ntok has to be analysed
+                            break
+                    tok = b_(chr(int(tok, base=8)))
+                elif tok in b"\n\r":
+                    # This case is  hit when a backslash followed by a line
+                    # break occurs.  If it's a multi-char EOL, consume the
+                    # second character:
+                    tok = stream.read(1)
+                    if tok not in b"\n\r":
+                        stream.seek(-1, 1)
+                    # Then don't add anything to the actual string, since this
+                    # line break was escaped:
+                    tok = b""
+                else:
+                    msg = rf"Unexpected escaped string: {tok.decode('utf8')}"
+                    logger_warning(msg, __name__)
+        txt += tok
+    return create_string_object(txt, forced_encoding)
+
+
+def create_string_object(
+    string: Union[str, bytes],
+    forced_encoding: Union[None, str, List[str], Dict[int, str]] = None,
+) -> Union[TextStringObject, ByteStringObject]:
+    """
+    Create a ByteStringObject or a TextStringObject from a string to represent the string.
+
+    :param Union[str, bytes] string: A string
+
+    :raises TypeError: If string is not of type str or bytes.
+    """
+    if isinstance(string, str):
+        return TextStringObject(string)
+    elif isinstance(string, bytes):
+        if isinstance(forced_encoding, (list, dict)):
+            out = ""
+            for x in string:
+                try:
+                    out += forced_encoding[x]
+                except Exception:
+                    out += bytes((x,)).decode("charmap")
+            return TextStringObject(out)
+        elif isinstance(forced_encoding, str):
+            if forced_encoding == "bytes":
+                return ByteStringObject(string)
+            return TextStringObject(string.decode(forced_encoding))
+        else:
+            try:
+                if string.startswith(codecs.BOM_UTF16_BE):
+                    retval = TextStringObject(string.decode("utf-16"))
+                    retval.autodetect_utf16 = True
+                    return retval
+                else:
+                    # This is probably a big performance hit here, but we need to
+                    # convert string objects into the text/unicode-aware version if
+                    # possible... and the only way to check if that's possible is
+                    # to try.  Some strings are strings, some are just byte arrays.
+                    retval = TextStringObject(decode_pdfdocencoding(string))
+                    retval.autodetect_pdfdocencoding = True
+                    return retval
+            except UnicodeDecodeError:
+                return ByteStringObject(string)
+    else:
+        raise TypeError("create_string_object should have str or unicode arg")
+
+
+def decode_pdfdocencoding(byte_array: bytes) -> str:
+    retval = ""
+    for b in byte_array:
+        c = _pdfdoc_encoding[b]
+        if c == "\u0000":
+            raise UnicodeDecodeError(
+                "pdfdocencoding",
+                bytearray(b),
+                -1,
+                -1,
+                "does not exist in translation table",
+            )
+        retval += c
+    return retval
diff --git a/PyPDF2/types.py b/PyPDF2/types.py
index a55465745..9683c1edd 100644
--- a/PyPDF2/types.py
+++ b/PyPDF2/types.py
@@ -12,19 +12,16 @@
     # Python 3.10+: https://www.python.org/dev/peps/pep-0484/
     from typing import TypeAlias  # type: ignore[attr-defined]
 except ImportError:
-    from typing_extensions import TypeAlias  # type: ignore[misc]
+    from typing_extensions import TypeAlias
 
-from .generic import (
-    ArrayObject,
-    Bookmark,
-    Destination,
-    NameObject,
-    NullObject,
-    NumberObject,
-)
+from .generic._base import NameObject, NullObject, NumberObject
+from .generic._data_structures import ArrayObject, Destination
+from .generic._outline import OutlineItem
 
 BorderArrayType: TypeAlias = List[Union[NameObject, NumberObject, ArrayObject]]
-BookmarkTypes: TypeAlias = Union[Bookmark, Destination]
+OutlineItemType: TypeAlias = Union[OutlineItem, Destination]
+# BookmarkTypes is deprecated. Use OutlineItemType instead
+BookmarkTypes: TypeAlias = OutlineItemType  # Remove with PyPDF2==3.0.0
 FitType: TypeAlias = Literal[
     "/Fit", "/XYZ", "/FitH", "/FitV", "/FitR", "/FitB", "/FitBH", "/FitBV"
 ]
@@ -36,8 +33,9 @@
 #    OutlinesType = List[Union[Destination, "OutlinesType"]]
 # See https://github.com/python/mypy/issues/731
 # Hence use this for the moment:
-OutlinesType = List[Union[Destination, List[Union[Destination, List[Destination]]]]]
-
+OutlineType = List[Union[Destination, List[Union[Destination, List[Destination]]]]]
+# OutlinesType is deprecated. Use OutlineType instead
+OutlinesType: TypeAlias = OutlineType  # Remove with PyPDF2==3.0.0
 
 LayoutType: TypeAlias = Literal[
     "/NoLayout",
diff --git a/PyPDF2/xmp.py b/PyPDF2/xmp.py
index 5cb79c750..06c92132d 100644
--- a/PyPDF2/xmp.py
+++ b/PyPDF2/xmp.py
@@ -16,6 +16,7 @@
     Optional,
     TypeVar,
     Union,
+    cast,
 )
 from xml.dom.minidom import Document
 from xml.dom.minidom import Element as XmlElement
@@ -209,7 +210,7 @@ class XmpInformation(PdfObject):
     An object that represents Adobe XMP metadata.
     Usually accessed by :py:attr:`xmp_metadata()<PyPDF2.PdfReader.xmp_metadata>`
 
-    :raises: PdfReadError if XML is invalid
+    :raises PdfReadError: if XML is invalid
     """
 
     def __init__(self, stream: ContentStream) -> None:
@@ -482,7 +483,7 @@ def xmpmm_documentId(self, value: str) -> None:  # pragma: no cover
     @property
     def xmpmm_instanceId(self) -> str:  # pragma: no cover
         deprecate_with_replacement("xmpmm_instanceId", "xmpmm_instance_id")
-        return self.xmpmm_instance_id
+        return cast(str, self.xmpmm_instance_id)
 
     @xmpmm_instanceId.setter
     def xmpmm_instanceId(self, value: str) -> None:  # pragma: no cover
diff --git a/docs/conf.py b/docs/conf.py
index ec0538ce4..41e5fba22 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -17,6 +17,7 @@
 sys.path.insert(0, os.path.abspath("../"))
 
 shutil.copyfile("../CHANGELOG.md", "meta/CHANGELOG.md")
+shutil.copyfile("../CONTRIBUTORS.md", "meta/CONTRIBUTORS.md")
 
 # -- Project information -----------------------------------------------------
 
diff --git a/docs/dev/intro.md b/docs/dev/intro.md
index ced5725fb..702f4b503 100644
--- a/docs/dev/intro.md
+++ b/docs/dev/intro.md
@@ -31,6 +31,12 @@ most cases we typically want to test for. The `sample-files` might cover a lot
 more edge cases, the behavior we get when file sizes get bigger, different
 PDF producers.
 
+In order to get the sample-files folder, you need to execute:
+
+```
+git submodule update --init
+```
+
 ## Tools: git and pre-commit
 
 Git is a command line application for version control. If you don't know it,
@@ -67,6 +73,8 @@ The `PREFIX` can be:
 * `ENH`: A new feature! Describe in the body what it can be used for.
 * `DEP`: A deprecation - either marking something as "this is going to be removed"
    or actually removing it.
+* `PI`: A performance improvement. This could also be a reduction in the
+        file size of PDF files generated by PyPDF2.
 * `ROB`: A robustness change. Dealing better with broken PDF files.
 * `DOC`: A documentation change.
 * `TST`: Adding / adjusting tests.
diff --git a/docs/index.rst b/docs/index.rst
index 8f8ec7760..1d63c9102 100644
--- a/docs/index.rst
+++ b/docs/index.rst
@@ -50,6 +50,7 @@ You can contribute to `PyPDF2 on Github <https://github.com/py-pdf/PyPDF2>`_.
    modules/RectangleObject
    modules/Field
    modules/PageRange
+   modules/AnnotationBuilder
 
 .. toctree::
    :caption: Developer Guide
@@ -67,6 +68,7 @@ You can contribute to `PyPDF2 on Github <https://github.com/py-pdf/PyPDF2>`_.
    meta/CHANGELOG
    meta/project-governance
    meta/history
+   meta/CONTRIBUTORS
    meta/comparisons
    meta/faq
 
diff --git a/docs/meta/project-governance.md b/docs/meta/project-governance.md
index dfb17aa9d..2b421fced 100644
--- a/docs/meta/project-governance.md
+++ b/docs/meta/project-governance.md
@@ -71,7 +71,7 @@ as their mother tongue. We try our best to understand others -
 The community can expect the following:
 
 * The **benevolent dictator** tries their best to make decisions from which the overall
-  community profits. The benevolent dictator is aware that his/her decisons can shape the
+  community profits. The benevolent dictator is aware that his/her decisions can shape the
   overall community. Once the benevolent dictator notices that she/he doesn't have the time
   to advance PyPDF2, he/she looks for a new benevolent dictator. As it is expected
   that the benevolent dictator will step down at some point of their choice
diff --git a/docs/modules/AnnotationBuilder.rst b/docs/modules/AnnotationBuilder.rst
new file mode 100644
index 000000000..8ad7e54a5
--- /dev/null
+++ b/docs/modules/AnnotationBuilder.rst
@@ -0,0 +1,7 @@
+The AnnotationBuilder Class
+---------------------------
+
+.. autoclass:: PyPDF2.generic.AnnotationBuilder
+    :members:
+    :no-undoc-members:
+    :show-inheritance:
diff --git a/docs/user/add-watermark.md b/docs/user/add-watermark.md
index a0eabccca..4e852ae07 100644
--- a/docs/user/add-watermark.md
+++ b/docs/user/add-watermark.md
@@ -10,28 +10,35 @@ content stays the same.
 ## Stamp (Overlay)
 
 ```python
-from PyPDF2 import PdfWriter, PdfReader
-
-
-def stamp(content_page, image_page):
-    """Put the image over the content"""
-    # Note that this modifies the content_page in-place!
-    content_page.merge_page(image_page)
-    return content_page
-
+from pathlib import Path
+from typing import Union, Literal, List
 
-# Read the pages
-reader_content = PdfReader("content.pdf")
-reader_image = PdfReader("image.pdf")
+from PyPDF2 import PdfWriter, PdfReader
 
-# Modify it
-modified = stamp(reader_content.pages[0], reader_image.pages[0])
 
-# Create the new document
-writer = PdfWriter()
-writer.add_page(modified)
-with open("out-stamp.pdf", "wb") as fp:
-    writer.write(fp)
+def stamp(
+    content_pdf: Path,
+    stamp_pdf: Path,
+    pdf_result: Path,
+    page_indices: Union[Literal["ALL"], List[int]] = "ALL",
+):
+    reader = PdfReader(stamp_pdf)
+    image_page = reader.pages[0]
+
+    writer = PdfWriter()
+
+    reader = PdfReader(content_pdf)
+    if page_indices == "ALL":
+        page_indices = list(range(0, len(reader.pages)))
+    for index in page_indices:
+        content_page = reader.pages[index]
+        mediabox = content_page.mediabox
+        content_page.merge_page(image_page)
+        content_page.mediabox = mediabox
+        writer.add_page(content_page)
+
+    with open(pdf_result, "wb") as fp:
+        writer.write(fp)
 ```
 
 ![stamp.png](stamp.png)
@@ -39,28 +46,40 @@ with open("out-stamp.pdf", "wb") as fp:
 ## Watermark (Underlay)
 
 ```python
+from pathlib import Path
+from typing import Union, Literal, List
+
 from PyPDF2 import PdfWriter, PdfReader
 
 
-def watermark(content_page, image_page):
-    """Put the image under the content"""
-    # Note that this modifies the image_page in-place!
-    image_page.merge_page(content_page)
-    return image_page
+def watermark(
+    content_pdf: Path,
+    stamp_pdf: Path,
+    pdf_result: Path,
+    page_indices: Union[Literal["ALL"], List[int]] = "ALL",
+):
+    reader = PdfReader(content_pdf)
+    if page_indices == "ALL":
+        page_indices = list(range(0, len(reader.pages)))
+
+    reader_stamp = PdfReader(stamp_pdf)
+    image_page = reader_stamp.pages[0]
 
+    writer = PdfWriter()
+    for index in page_indices:
+        content_page = reader.pages[index]
+        mediabox = content_page.mediabox
 
-# Read the pages
-reader_content = PdfReader("content.pdf")
-reader_image = PdfReader("image.pdf")
+        # You need to load it again, as the last time it was overwritten
+        reader_stamp = PdfReader(stamp_pdf)
+        image_page = reader_stamp.pages[0]
 
-# Modify it
-modified = stamp(reader_content.pages[0], reader_image.pages[0])
+        image_page.merge_page(content_page)
+        image_page.mediabox = mediabox
+        writer.add_page(image_page)
 
-# Create the new document
-writer = PdfWriter()
-writer.add_page(modified)
-with open("out-watermark.pdf", "wb") as fp:
-    writer.write(fp)
+    with open(pdf_result, "wb") as fp:
+        writer.write(fp)
 ```
 
 ![watermark.png](watermark.png)
diff --git a/docs/user/adding-pdf-annotations.md b/docs/user/adding-pdf-annotations.md
index d174e1114..4cce69545 100644
--- a/docs/user/adding-pdf-annotations.md
+++ b/docs/user/adding-pdf-annotations.md
@@ -14,3 +14,121 @@ writer.add_attachment("smile.png", data)
 with open("output.pdf", "wb") as output_stream:
     writer.write(output_stream)
 ```
+
+
+## Free Text
+
+If you want to add text in a box like this
+
+![](free-text-annotation.png)
+
+you can use the {py:class}`AnnotationBuilder <PyPDF2.generic.AnnotationBuilder>`:
+
+```python
+from PyPDF2 import PdfReader, PdfWriter
+from PyPDF2.generic import AnnotationBuilder
+
+# Fill the writer with the pages you want
+pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+reader = PdfReader(pdf_path)
+page = reader.pages[0]
+writer = PdfWriter()
+writer.add_page(page)
+
+# Create the annotation and add it
+annotation = AnnotationBuilder.free_text(
+    "Hello World\nThis is the second line!",
+    rect=(50, 550, 200, 650),
+    font="Arial",
+    bold=True,
+    italic=True,
+    font_size="20pt",
+    font_color="00ff00",
+    border_color="0000ff",
+    bg_color="cdcdcd",
+)
+writer.add_annotation(page_number=0, annotation=annotation)
+
+# Write the annotated file to disk
+with open("annotated-pdf.pdf", "wb") as fp:
+    writer.write(fp)
+```
+
+## Text
+
+A text annotation looks like this:
+
+![](text-annotation.png)
+
+## Line
+
+If you want to add a line like this:
+
+![](annotation-line.png)
+
+you can use the {py:class}`AnnotationBuilder <PyPDF2.generic.AnnotationBuilder>`:
+
+```python
+pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+reader = PdfReader(pdf_path)
+page = reader.pages[0]
+writer = PdfWriter()
+writer.add_page(page)
+
+# Add the line
+annotation = AnnotationBuilder.line(
+    text="Hello World\nLine2",
+    rect=(50, 550, 200, 650),
+    p1=(50, 550),
+    p2=(200, 650),
+)
+writer.add_annotation(page_number=0, annotation=annotation)
+
+# Write the annotated file to disk
+with open("annotated-pdf.pdf", "wb") as fp:
+    writer.write(fp)
+```
+
+## Link
+
+If you want to add a link, you can use
+the {py:class}`AnnotationBuilder <PyPDF2.generic.AnnotationBuilder>`:
+
+```python
+pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+reader = PdfReader(pdf_path)
+page = reader.pages[0]
+writer = PdfWriter()
+writer.add_page(page)
+
+# Add the line
+annotation = AnnotationBuilder.link(
+    rect=(50, 550, 200, 650),
+    url="https://martin-thoma.com/",
+)
+writer.add_annotation(page_number=0, annotation=annotation)
+
+# Write the annotated file to disk
+with open("annotated-pdf.pdf", "wb") as fp:
+    writer.write(fp)
+```
+
+You can also add internal links:
+
+```python
+pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+reader = PdfReader(pdf_path)
+page = reader.pages[0]
+writer = PdfWriter()
+writer.add_page(page)
+
+# Add the line
+annotation = AnnotationBuilder.link(
+    rect=(50, 550, 200, 650), target_page_index=3, fit="/FitH", fit_args=(123,)
+)
+writer.add_annotation(page_number=0, annotation=annotation)
+
+# Write the annotated file to disk
+with open("annotated-pdf.pdf", "wb") as fp:
+    writer.write(fp)
+```
diff --git a/docs/user/annotation-line.png b/docs/user/annotation-line.png
new file mode 100644
index 000000000..6717e1147
Binary files /dev/null and b/docs/user/annotation-line.png differ
diff --git a/docs/user/cropping-and-transforming.md b/docs/user/cropping-and-transforming.md
index 872907547..c83038cf0 100644
--- a/docs/user/cropping-and-transforming.md
+++ b/docs/user/cropping-and-transforming.md
@@ -128,3 +128,66 @@ op = Transformation().rotate(45).translate(tx=50)
 ```
 
 ![](merge-translated.png)
+
+
+## Scaling
+
+PyPDF2 offers two ways to scale: The page itself and the contents on a page.
+Typically, you want to combine both.
+
+![](scaling.png)
+
+### Scaling a Page (the Canvas)
+
+```python
+from PyPDF2 import PdfReader, PdfWriter
+
+# Read the input
+reader = PdfReader("resources/side-by-side-subfig.pdf")
+page = reader.pages[0]
+
+# Scale
+page.scale(0.5)
+
+# Write the result to a file
+writer = PdfWriter()
+writer.add_page(page)
+writer.write("out.pdf")
+```
+
+If you wish to have more control, you can adjust the various page boxes
+directly:
+
+```python
+from PyPDF2.generic import RectangleObject
+
+mb = page.mediabox
+
+page.mediabox = RectangleObject((mb.left, mb.bottom, mb.right, mb.top))
+page.cropbox = RectangleObject((mb.left, mb.bottom, mb.right, mb.top))
+page.trimbox = RectangleObject((mb.left, mb.bottom, mb.right, mb.top))
+page.bleedbox = RectangleObject((mb.left, mb.bottom, mb.right, mb.top))
+page.artbox = RectangleObject((mb.left, mb.bottom, mb.right, mb.top))
+```
+
+### Scaling the content
+
+The content is scaled towords the origin of the coordinate system. Typically,
+that is the lower-left corner.
+
+```python
+from PyPDF2 import PdfReader, PdfWriter, Transformation
+
+# Read the input
+reader = PdfReader("resources/side-by-side-subfig.pdf")
+page = reader.pages[0]
+
+# Scale
+op = Transformation().scale(sx=0.7, sy=0.7)
+page.add_transformation(op)
+
+# Write the result to a file
+writer = PdfWriter()
+writer.add_page(page)
+writer.write("out-pg-transform.pdf")
+```
diff --git a/docs/user/error-hierarchy.png b/docs/user/error-hierarchy.png
new file mode 100644
index 000000000..2b0b250e3
Binary files /dev/null and b/docs/user/error-hierarchy.png differ
diff --git a/docs/user/extract-text.md b/docs/user/extract-text.md
index 8415ee31c..7f3596d7e 100644
--- a/docs/user/extract-text.md
+++ b/docs/user/extract-text.md
@@ -10,6 +10,18 @@ page = reader.pages[0]
 print(page.extract_text())
 ```
 
+you can also choose to limit the text orientation you want to extract, e.g:
+
+```python
+# extract only text oriented up
+print(page.extract_text(0))
+
+# extract text oriented up and turned left
+print(page.extract_text((0, 90)))
+```
+
+Refer to [extract\_text](../modules/PageObject.html#PyPDF2._page.PageObject.extract_text) for more details.
+
 ## Why Text Extraction is hard
 
 Extracting text from a PDF can be pretty tricky. In several cases there is no
@@ -85,11 +97,11 @@ PyPDF2 might make mistakes parsing that.
 Hence I would distinguish three types of PDF documents:
 
 * **Digitally-born PDF files**: The file was created digitally on the computer.
-  It can contain images, texts, links, bookmarks, JavaScript, ...
+  It can contain images, texts, links, outline items (a.k.a., bookmarks), JavaScript, ...
   If you Zoom in a lot, the text still looks sharp.
 * **Scanned PDF files**: Any number of pages was scanned. The images were then
   stored in a PDF file. Hence the file is just a container for those images.
-  You cannot copy the text, you don't have links, bookmarks, JavaScript.
+  You cannot copy the text, you don't have links, outline items, JavaScript.
 * **OCRed PDF files**: The scanner ran OCR software and put the recognized text
   in the background of the image. Hence you can copy the text, but it still looks
   like a scan. If you zoom in enough, you can recognize pixels.
@@ -112,3 +124,17 @@ comes to characters which are easy to confuse such as `oO0ö`.
 
 PyPDF2 also has an edge when it comes to characters which are rare, e.g.
 🤰. OCR software will not be able to recognize smileys correctly.
+
+
+
+## Attempts to prevent text extraction
+
+If people who share PDF documents want to prevent text extraction, there are
+multiple ways to do so:
+
+1. Store the contents of the PDF as an image
+2. [Use a scrambled font](https://stackoverflow.com/a/43466923/562769)
+
+However, text extraction cannot be completely prevented if people should still
+be able to read the document. In the worst case people can make a screenshot,
+print it, scan it, and run OCR over it.
diff --git a/docs/user/free-text-annotation.png b/docs/user/free-text-annotation.png
new file mode 100644
index 000000000..1c2f69ed3
Binary files /dev/null and b/docs/user/free-text-annotation.png differ
diff --git a/docs/user/pdf-version-support.md b/docs/user/pdf-version-support.md
index a588997ab..4ac0adf13 100644
--- a/docs/user/pdf-version-support.md
+++ b/docs/user/pdf-version-support.md
@@ -21,12 +21,18 @@ all features of PDF 2.0.
 | Feature                                 | PDF-Version | PyPDF2 Support |
 | --------------------------------------- | ----------- | -------------- |
 | Transparent Graphics                    | 1.4         | ?              |
-| CMaps                                   | 1.4         | ❌ [#201](https://github.com/py-pdf/PyPDF2/pull/201), [#464](https://github.com/py-pdf/PyPDF2/pull/464), [#805](https://github.com/py-pdf/PyPDF2/pull/805)   |
+| CMaps                                   | 1.4         | ✅             |
 | Object Streams                          | 1.5         | ?              |
 | Cross-reference Streams                 | 1.5         | ?              |
 | Optional Content Groups (OCGs) - Layers | 1.5         | ?              |
 | Content Stream Compression              | 1.5         | ?              |
-| AES Encryption                          | 1.6         | ❌ [#749](https://github.com/py-pdf/PyPDF2/pull/749)  |
+| AES Encryption                          | 1.6         | ✅             |
 
 See [History of PDF](https://en.wikipedia.org/wiki/History_of_PDF) for more
 features.
+
+Some PDF features are not supported by PyPDF2, but other libraries can be used
+for them:
+
+* [pyHanko](https://pyhanko.readthedocs.io/en/latest/index.html): Cryptographically sign a PDF ([#302](https://github.com/py-pdf/PyPDF2/issues/302))
+* [camelot-py](https://pypi.org/project/camelot-py/): Table Extraction ([#231](https://github.com/py-pdf/PyPDF2/issues/231))
diff --git a/docs/user/scaling.png b/docs/user/scaling.png
new file mode 100644
index 000000000..8270c1e44
Binary files /dev/null and b/docs/user/scaling.png differ
diff --git a/docs/user/suppress-warnings.md b/docs/user/suppress-warnings.md
index 59662c3cf..70b66de6f 100644
--- a/docs/user/suppress-warnings.md
+++ b/docs/user/suppress-warnings.md
@@ -1,12 +1,20 @@
-# Suppress Warnings and Log messages
+# Exceptions, Warnings, and Log messages
 
 PyPDF2 makes use of 3 mechanisms to show that something went wrong:
 
-* **Exceptions**: Error-cases the client should explicitly handle. In the
-   `strict=True` mode, most log messages will become exceptions. This can be
-   useful in applications where you can force to user to fix the broken PDF.
-* **Warnings**: Avoidable issues, such as using deprecated classes / functions / parameters
-* **Log messages**: Nothing the client can do, but they should know it happened.
+* **Log messages** are informative messages that can be used for post-mortem
+  analysis. Most of the time, users can ignore them. They come in different
+  *levels*, such as info / warning / error indicating the severity.
+  Examples are non-standard compliant PDF files which PyPDF2 can deal with.
+* **Warnings** are avoidable issues, such as using deprecated classes /
+  functions / parameters. Another example is missing capabilities of PyPDF2.
+  In those cases, PyPDF2 users should adjust their code. Warnings
+  are issued by the `warnings` module - those are different from the log-level
+  "warning".
+* **Exceptions** are error-cases that PyPDF2 users should explicitly handle.
+  In the `strict=True` mode, most log messages with the warning level will
+  become exceptions. This can be useful in applications where you can force to
+  user to fix the broken PDF.
 
 
 ## Exceptions
diff --git a/docs/user/text-annotation.png b/docs/user/text-annotation.png
new file mode 100644
index 000000000..51997a115
Binary files /dev/null and b/docs/user/text-annotation.png differ
diff --git a/requirements/ci.in b/requirements/ci.in
index f94a627d7..0527a1f05 100644
--- a/requirements/ci.in
+++ b/requirements/ci.in
@@ -2,6 +2,7 @@ coverage
 flake8
 flake8_implicit_str_concat
 flake8-bugbear
+flake8-print
 mypy
 pillow
 pytest
diff --git a/requirements/ci.txt b/requirements/ci.txt
index 7e58996a6..bf8372cb2 100644
--- a/requirements/ci.txt
+++ b/requirements/ci.txt
@@ -11,14 +11,17 @@ attrs==20.3.0
     #   pytest
 coverage==6.2
     # via -r requirements/ci.in
-flake8==4.0.1
+flake8==5.0.4
     # via
     #   -r requirements/ci.in
     #   flake8-bugbear
-flake8-bugbear==22.4.25
+    #   flake8-print
+flake8-bugbear==22.7.1
     # via -r requirements/ci.in
 flake8-implicit-str-concat==0.2.0
     # via -r requirements/ci.in
+flake8-print==4.0.1
+    # via -r requirements/ci.in
 importlib-metadata==4.2.0
     # via
     #   flake8
@@ -26,11 +29,11 @@ importlib-metadata==4.2.0
     #   pytest
 iniconfig==1.1.1
     # via pytest
-mccabe==0.6.1
+mccabe==0.7.0
     # via flake8
 more-itertools==8.13.0
     # via flake8-implicit-str-concat
-mypy==0.961
+mypy==0.971
     # via -r requirements/ci.in
 mypy-extensions==0.4.3
     # via mypy
@@ -44,11 +47,13 @@ py==1.11.0
     # via pytest
 py-cpuinfo==8.0.0
     # via pytest-benchmark
-pycodestyle==2.8.0
-    # via flake8
-pycryptodome==3.14.1
+pycodestyle==2.9.1
+    # via
+    #   flake8
+    #   flake8-print
+pycryptodome==3.15.0
     # via -r requirements/ci.in
-pyflakes==2.4.0
+pyflakes==2.5.0
     # via flake8
 pyparsing==3.0.9
     # via packaging
@@ -58,6 +63,8 @@ pytest==7.0.1
     #   pytest-benchmark
 pytest-benchmark==3.4.1
     # via -r requirements/ci.in
+six==1.16.0
+    # via flake8-print
 tomli==1.2.3
     # via
     #   mypy
@@ -66,7 +73,7 @@ typed-ast==1.5.4
     # via mypy
 typeguard==2.13.3
     # via -r requirements/ci.in
-types-pillow==9.0.20
+types-pillow==9.2.1
     # via -r requirements/ci.in
 typing-extensions==4.1.1
     # via
diff --git a/requirements/dev.txt b/requirements/dev.txt
index 619879c41..1c20dd40f 100644
--- a/requirements/dev.txt
+++ b/requirements/dev.txt
@@ -6,13 +6,13 @@
 #
 attrs==21.4.0
     # via pytest
-black==22.3.0
+black==22.6.0
     # via -r requirements/dev.in
 bleach==4.1.0
     # via readme-renderer
 certifi==2022.6.15
     # via requests
-cffi==1.15.0
+cffi==1.15.1
     # via cryptography
 cfgv==3.3.1
     # via pre-commit
@@ -26,11 +26,11 @@ colorama==0.4.5
     # via twine
 coverage[toml]==6.2
     # via pytest-cov
-cryptography==37.0.2
+cryptography==37.0.4
     # via secretstorage
 dataclasses==0.8
     # via black
-distlib==0.3.4
+distlib==0.3.5
     # via virtualenv
 docutils==0.18.1
     # via readme-renderer
@@ -135,11 +135,11 @@ typing-extensions==4.1.1
     # via
     #   black
     #   importlib-metadata
-urllib3==1.26.9
+urllib3==1.26.10
     # via
     #   requests
     #   twine
-virtualenv==20.14.1
+virtualenv==20.15.1
     # via pre-commit
 webencodings==0.5.1
     # via bleach
diff --git a/requirements/docs.txt b/requirements/docs.txt
index afe10d6a3..6516276b0 100644
--- a/requirements/docs.txt
+++ b/requirements/docs.txt
@@ -8,9 +8,9 @@ alabaster==0.7.12
     # via sphinx
 attrs==21.4.0
     # via markdown-it-py
-babel==2.10.1
+babel==2.10.3
     # via sphinx
-certifi==2022.5.18.1
+certifi==2022.6.15
     # via requests
 charset-normalizer==2.0.12
     # via requests
@@ -21,7 +21,7 @@ docutils==0.17.1
     #   sphinx-rtd-theme
 idna==3.3
     # via requests
-imagesize==1.3.0
+imagesize==1.4.1
     # via sphinx
 importlib-metadata==4.8.3
     # via sphinx
@@ -78,7 +78,7 @@ typing-extensions==4.1.1
     # via
     #   importlib-metadata
     #   markdown-it-py
-urllib3==1.26.9
+urllib3==1.26.10
     # via requests
 zipp==3.6.0
     # via importlib-metadata
diff --git a/resources/encryption/r2-owner-password.pdf b/resources/encryption/r2-owner-password.pdf
new file mode 100644
index 000000000..121c61ed7
Binary files /dev/null and b/resources/encryption/r2-owner-password.pdf differ
diff --git a/resources/encryption/r4-owner-password.pdf b/resources/encryption/r4-owner-password.pdf
new file mode 100644
index 000000000..837c97832
Binary files /dev/null and b/resources/encryption/r4-owner-password.pdf differ
diff --git a/resources/libreoffice-form.pdf b/resources/libreoffice-form.pdf
new file mode 100644
index 000000000..7641b4d54
Binary files /dev/null and b/resources/libreoffice-form.pdf differ
diff --git a/resources/outline-without-title.pdf b/resources/outline-without-title.pdf
new file mode 100644
index 000000000..2734f5f82
Binary files /dev/null and b/resources/outline-without-title.pdf differ
diff --git a/resources/outlines-with-invalid-destinations.pdf b/resources/outlines-with-invalid-destinations.pdf
new file mode 100644
index 000000000..a5102f669
Binary files /dev/null and b/resources/outlines-with-invalid-destinations.pdf differ
diff --git a/sample-files b/sample-files
index 6da0fbb53..b6f4ff3de 160000
--- a/sample-files
+++ b/sample-files
@@ -1 +1 @@
-Subproject commit 6da0fbb53f11bd5b8a4acf06e4d26e5e2bf5bf57
+Subproject commit b6f4ff3de00745783d79f25cb8803901d1f20d28
diff --git a/setup.cfg b/setup.cfg
index 62d77a818..2c0eebe8f 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -16,7 +16,7 @@ url = https://pypdf2.readthedocs.io/en/latest/
 project_urls =
     Source = https://github.com/py-pdf/PyPDF2
     Bug Reports = https://github.com/py-pdf/PyPDF2/issues
-    Changelog = https://raw.githubusercontent.com/py-pdf/PyPDF2/main/CHANGELOG
+    Changelog = https://pypdf2.readthedocs.io/en/latest/meta/CHANGELOG.html
 classifiers =
     Development Status :: 5 - Production/Stable
     Intended Audience :: Developers
@@ -35,9 +35,10 @@ classifiers =
 packages =
     PyPDF2
     PyPDF2._codecs
+    PyPDF2.generic
 python_requires = >=3.6
 install_requires =
-    typing_extensions; python_version < '3.10'
+    typing_extensions >= 3.10.0.0; python_version < '3.10'
 
 [options.extras_require]
 crypto = PyCryptodome
@@ -46,3 +47,6 @@ crypto = PyCryptodome
 backup = False
 runner = ./mutmut-test.sh
 tests_dir = tests/
+
+[tool:check-wheel-contents]
+package = ./PyPDF2
diff --git a/tests/__init__.py b/tests/__init__.py
index 56438877e..3b4539cc7 100644
--- a/tests/__init__.py
+++ b/tests/__init__.py
@@ -1,6 +1,9 @@
 import os
 import ssl
 import urllib.request
+from typing import List
+
+from PyPDF2.generic import DictionaryObject, IndirectObject
 
 
 def get_pdf_from_url(url: str, name: str) -> bytes:
@@ -30,3 +33,37 @@ def get_pdf_from_url(url: str, name: str) -> bytes:
     with open(cache_path, "rb") as fp:
         data = fp.read()
     return data
+
+
+def _strip_position(line: str) -> str:
+    """
+    Remove the location information.
+
+    The message
+        WARNING  PyPDF2._reader:_utils.py:364 Xref table not zero-indexed.
+
+    becomes
+        Xref table not zero-indexed.
+    """
+    line = ".py".join(line.split(".py:")[1:])
+    line = " ".join(line.split(" ")[1:])
+    return line
+
+
+def normalize_warnings(caplog_text: str) -> List[str]:
+    return [_strip_position(line) for line in caplog_text.strip().split("\n")]
+
+
+class ReaderDummy:
+    def __init__(self, strict=False):
+        self.strict = strict
+
+    def get_object(self, indirect_ref):
+        class DummyObj:
+            def get_object(self):
+                return self
+
+        return DictionaryObject()
+
+    def get_reference(self, obj):
+        return IndirectObject(idnum=1, generation=1, pdf=self)
diff --git a/tests/bench.py b/tests/bench.py
index d8f526ed9..4ae9bb2d1 100644
--- a/tests/bench.py
+++ b/tests/bench.py
@@ -1,19 +1,17 @@
-import os
-
-import pytest
+from pathlib import Path
 
 import PyPDF2
 from PyPDF2 import PdfReader, Transformation
 from PyPDF2.generic import Destination
 
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
-SAMPLE_ROOT = os.path.join(PROJECT_ROOT, "sample-files")
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
+SAMPLE_ROOT = PROJECT_ROOT / "sample-files"
 
 
 def page_ops(pdf_path, password):
-    pdf_path = os.path.join(RESOURCE_ROOT, pdf_path)
+    pdf_path = RESOURCE_ROOT / pdf_path
 
     reader = PdfReader(pdf_path)
 
@@ -52,48 +50,48 @@ def test_page_operations(benchmark):
 
 
 def merge():
-    pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
-    outline = os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf")
-    pdf_forms = os.path.join(RESOURCE_ROOT, "pdflatex-forms.pdf")
-    pdf_pw = os.path.join(RESOURCE_ROOT, "libreoffice-writer-password.pdf")
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
+    outline = RESOURCE_ROOT / "pdflatex-outline.pdf"
+    pdf_forms = RESOURCE_ROOT / "pdflatex-forms.pdf"
+    pdf_pw = RESOURCE_ROOT / "libreoffice-writer-password.pdf"
 
-    file_merger = PyPDF2.PdfMerger()
+    merger = PyPDF2.PdfMerger()
 
     # string path:
-    file_merger.append(pdf_path)
-    file_merger.append(outline)
-    file_merger.append(pdf_path, pages=PyPDF2.pagerange.PageRange(slice(0, 0)))
-    file_merger.append(pdf_forms)
+    merger.append(pdf_path)
+    merger.append(outline)
+    merger.append(pdf_path, pages=PyPDF2.pagerange.PageRange(slice(0, 0)))
+    merger.append(pdf_forms)
 
     # Merging an encrypted file
     reader = PyPDF2.PdfReader(pdf_pw)
     reader.decrypt("openpassword")
-    file_merger.append(reader)
+    merger.append(reader)
 
     # PdfReader object:
-    file_merger.append(PyPDF2.PdfReader(pdf_path, "rb"), bookmark=True)
+    merger.append(PyPDF2.PdfReader(pdf_path, "rb"), outline_item=True)
 
     # File handle
     with open(pdf_path, "rb") as fh:
-        file_merger.append(fh)
+        merger.append(fh)
 
-    bookmark = file_merger.add_bookmark("A bookmark", 0)
-    file_merger.add_bookmark("deeper", 0, parent=bookmark)
-    file_merger.add_metadata({"author": "Martin Thoma"})
-    file_merger.add_named_destination("title", 0)
-    file_merger.set_page_layout("/SinglePage")
-    file_merger.set_page_mode("/UseThumbs")
+    outline_item = merger.add_outline_item("An outline item", 0)
+    merger.add_outline_item("deeper", 0, parent=outline_item)
+    merger.add_metadata({"author": "Martin Thoma"})
+    merger.add_named_destination("title", 0)
+    merger.set_page_layout("/SinglePage")
+    merger.set_page_mode("/UseThumbs")
 
-    tmp_path = "dont_commit_merged.pdf"
-    file_merger.write(tmp_path)
-    file_merger.close()
+    write_path = "dont_commit_merged.pdf"
+    merger.write(write_path)
+    merger.close()
 
-    # Check if bookmarks are correct
-    reader = PyPDF2.PdfReader(tmp_path)
+    # Check if outline is correct
+    reader = PyPDF2.PdfReader(write_path)
     assert [
-        el.title for el in reader._get_outlines() if isinstance(el, Destination)
+        el.title for el in reader._get_outline() if isinstance(el, Destination)
     ] == [
-        "A bookmark",
+        "An outline item",
         "Foo",
         "Bar",
         "Baz",
@@ -106,9 +104,6 @@ def merge():
         "True",
     ]
 
-    # Clean up
-    os.remove(tmp_path)
-
 
 def test_merge(benchmark):
     """
@@ -127,7 +122,6 @@ def text_extraction(pdf_path):
     return text
 
 
-@pytest.mark.filterwarnings("ignore::PyPDF2.errors.PdfReadWarning")
 def test_text_extraction(benchmark):
-    file_path = os.path.join(SAMPLE_ROOT, "009-pdflatex-geotopo/GeoTopo.pdf")
+    file_path = SAMPLE_ROOT / "009-pdflatex-geotopo/GeoTopo.pdf"
     benchmark(text_extraction, file_path)
diff --git a/tests/test_basic_features.py b/tests/test_basic_features.py
deleted file mode 100644
index 5a6d23bfd..000000000
--- a/tests/test_basic_features.py
+++ /dev/null
@@ -1,53 +0,0 @@
-import os
-
-from PyPDF2 import PdfReader, PdfWriter
-
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
-
-
-def test_basic_features():
-    pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
-    reader = PdfReader(pdf_path)
-    writer = PdfWriter()
-
-    assert len(reader.pages) == 1
-
-    # add page 1 from input1 to output document, unchanged
-    writer.add_page(reader.pages[0])
-
-    # add page 2 from input1, but rotated clockwise 90 degrees
-    writer.add_page(reader.pages[0].rotate(90))
-
-    # add page 3 from input1, but first add a watermark from another PDF:
-    page3 = reader.pages[0]
-    watermark_pdf = pdf_path
-    watermark = PdfReader(watermark_pdf)
-    page3.merge_page(watermark.pages[0])
-    writer.add_page(page3)
-
-    # add page 4 from input1, but crop it to half size:
-    page4 = reader.pages[0]
-    page4.mediabox.upper_right = (
-        page4.mediabox.right / 2,
-        page4.mediabox.top / 2,
-    )
-    writer.add_page(page4)
-
-    # add some Javascript to launch the print window on opening this PDF.
-    # the password dialog may prevent the print dialog from being shown,
-    # comment the the encription lines, if that's the case, to try this out
-    writer.add_js("this.print({bUI:true,bSilent:false,bShrinkToFit:true});")
-
-    # encrypt your new PDF and add a password
-    password = "secret"
-    writer.encrypt(password)
-
-    # finally, write "output" to PyPDF2-output.pdf
-    tmp_path = "PyPDF2-output.pdf"
-    with open(tmp_path, "wb") as output_stream:
-        writer.write(output_stream)
-
-    # cleanup
-    os.remove(tmp_path)
diff --git a/tests/test_encryption.py b/tests/test_encryption.py
index 43ebdf20e..4534dfd34 100644
--- a/tests/test_encryption.py
+++ b/tests/test_encryption.py
@@ -1,9 +1,11 @@
-import os
+from pathlib import Path
 
 import pytest
 
 import PyPDF2
-from PyPDF2.errors import DependencyError
+from PyPDF2 import PasswordType, PdfReader
+from PyPDF2._encryption import AlgV5, CryptRC4
+from PyPDF2.errors import DependencyError, PdfReadError
 
 try:
     from Crypto.Cipher import AES  # noqa: F401
@@ -12,9 +14,9 @@
 except ImportError:
     HAS_PYCRYPTODOME = False
 
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
 
 
 @pytest.mark.parametrize(
@@ -22,34 +24,38 @@
     [
         # unencrypted pdf
         ("unencrypted.pdf", False),
-        # created by `qpdf --encrypt "" "" 40 -- unencrypted.pdf r2-empty-password.pdf`
+        # created by `qpdf --encrypt "" "" 40 -- unencrypted.pdf r2-empty-password.pdf`:
         ("r2-empty-password.pdf", False),
-        # created by `qpdf --encrypt "" "" 128 -- unencrypted.pdf r3-empty-password.pdf`
+        # created by `qpdf --encrypt "" "" 128 -- unencrypted.pdf r3-empty-password.pdf`:
         ("r3-empty-password.pdf", False),
-        # created by `qpdf --encrypt "asdfzxcv" "" 40 -- unencrypted.pdf r2-user-password.pdf`
+        # created by `qpdf --encrypt "asdfzxcv" "" 40 -- unencrypted.pdf r2-user-password.pdf`:
         ("r2-user-password.pdf", False),
-        # created by `qpdf --encrypt "asdfzxcv" "" 128 -- unencrypted.pdf r3-user-password.pdf`
+        # created by `qpdf --encrypt "" "asdfzxcv" 40 -- unencrypted.pdf r2-user-password.pdf`:
+        ("r2-owner-password.pdf", False),
+        # created by `qpdf --encrypt "asdfzxcv" "" 128 -- unencrypted.pdf r3-user-password.pdf`:
         ("r3-user-password.pdf", False),
-        # created by `qpdf --encrypt "asdfzxcv" "" 128 --force-V4 -- unencrypted.pdf r4-user-password.pdf`
+        # created by `qpdf --encrypt "asdfzxcv" "" 128 --force-V4 -- unencrypted.pdf r4-user-password.pdf`:
         ("r4-user-password.pdf", False),
-        # created by `qpdf --encrypt "asdfzxcv" "" 128 --use-aes=y -- unencrypted.pdf r4-aes-user-password.pdf`
+        # created by `qpdf --encrypt "" "asdfzxcv" 128 --force-V4 -- unencrypted.pdf r4-owner-password.pdf`:
+        ("r4-owner-password.pdf", False),
+        # created by `qpdf --encrypt "asdfzxcv" "" 128 --use-aes=y -- unencrypted.pdf r4-aes-user-password.pdf`:
         ("r4-aes-user-password.pdf", True),
-        # # created by `qpdf --encrypt "" "" 256 --force-R5 -- unencrypted.pdf r5-empty-password.pdf`
+        # # created by `qpdf --encrypt "" "" 256 --force-R5 -- unencrypted.pdf r5-empty-password.pdf`:
         ("r5-empty-password.pdf", True),
-        # # created by `qpdf --encrypt "asdfzxcv" "" 256 --force-R5 -- unencrypted.pdf r5-user-password.pdf`
+        # # created by `qpdf --encrypt "asdfzxcv" "" 256 --force-R5 -- unencrypted.pdf r5-user-password.pdf`:
         ("r5-user-password.pdf", True),
-        # # created by `qpdf --encrypt "" "asdfzxcv" 256 --force-R5 -- unencrypted.pdf r5-owner-password.pdf`
+        # # created by `qpdf --encrypt "" "asdfzxcv" 256 --force-R5 -- unencrypted.pdf r5-owner-password.pdf`:
         ("r5-owner-password.pdf", True),
-        # created by `qpdf --encrypt "" "" 256 -- unencrypted.pdf r6-empty-password.pdf`
+        # created by `qpdf --encrypt "" "" 256 -- unencrypted.pdf r6-empty-password.pdf`:
         ("r6-empty-password.pdf", True),
-        # created by `qpdf --encrypt "asdfzxcv" "" 256 -- unencrypted.pdf r6-user-password.pdf`
+        # created by `qpdf --encrypt "asdfzxcv" "" 256 -- unencrypted.pdf r6-user-password.pdf`:
         ("r6-user-password.pdf", True),
-        # created by `qpdf --encrypt "" "asdfzxcv" 256 -- unencrypted.pdf r6-owner-password.pdf`
+        # created by `qpdf --encrypt "" "asdfzxcv" 256 -- unencrypted.pdf r6-owner-password.pdf`:
         ("r6-owner-password.pdf", True),
     ],
 )
 def test_encryption(name, requres_pycryptodome):
-    inputfile = os.path.join(RESOURCE_ROOT, "encryption", name)
+    inputfile = RESOURCE_ROOT / "encryption" / name
     if requres_pycryptodome and not HAS_PYCRYPTODOME:
         with pytest.raises(DependencyError) as exc:
             ipdf = PyPDF2.PdfReader(inputfile)
@@ -59,7 +65,7 @@ def test_encryption(name, requres_pycryptodome):
         return
     else:
         ipdf = PyPDF2.PdfReader(inputfile)
-        if inputfile.endswith("unencrypted.pdf"):
+        if str(inputfile).endswith("unencrypted.pdf"):
             assert not ipdf.is_encrypted
         else:
             assert ipdf.is_encrypted
@@ -87,9 +93,7 @@ def test_encryption(name, requres_pycryptodome):
 )
 @pytest.mark.skipif(not HAS_PYCRYPTODOME, reason="No pycryptodome")
 def test_both_password(name, user_passwd, owner_passwd):
-    from PyPDF2 import PasswordType
-
-    inputfile = os.path.join(RESOURCE_ROOT, "encryption", name)
+    inputfile = RESOURCE_ROOT / "encryption" / name
     ipdf = PyPDF2.PdfReader(inputfile)
     assert ipdf.is_encrypted
     assert ipdf.decrypt(user_passwd) == PasswordType.USER_PASSWORD
@@ -101,6 +105,7 @@ def test_both_password(name, user_passwd, owner_passwd):
     ("pdffile", "password"),
     [
         ("crazyones-encrypted-256.pdf", "password"),
+        ("crazyones-encrypted-256.pdf", b"password"),
     ],
 )
 @pytest.mark.skipif(not HAS_PYCRYPTODOME, reason="No pycryptodome")
@@ -111,7 +116,7 @@ def test_get_page_of_encrypted_file_new_algorithm(pdffile, password):
     This is a regression test for issue 327:
     IndexError for get_page() of decrypted file
     """
-    path = os.path.join(RESOURCE_ROOT, pdffile)
+    path = RESOURCE_ROOT / pdffile
     PyPDF2.PdfReader(path, password=password).pages[0]
 
 
@@ -130,12 +135,53 @@ def test_get_page_of_encrypted_file_new_algorithm(pdffile, password):
 )
 @pytest.mark.skipif(not HAS_PYCRYPTODOME, reason="No pycryptodome")
 def test_encryption_merge(names):
-    pdf_merger = PyPDF2.PdfMerger()
-    files = [os.path.join(RESOURCE_ROOT, "encryption", x) for x in names]
+    merger = PyPDF2.PdfMerger()
+    files = [RESOURCE_ROOT / "encryption" / x for x in names]
     pdfs = [PyPDF2.PdfReader(x) for x in files]
     for pdf in pdfs:
         if pdf.is_encrypted:
             pdf.decrypt("asdfzxcv")
-        pdf_merger.append(pdf)
+        merger.append(pdf)
     # no need to write to file
-    pdf_merger.close()
+    merger.close()
+
+
+@pytest.mark.parametrize(
+    "cryptcls",
+    [
+        CryptRC4,
+    ],
+)
+def test_encrypt_decrypt_class(cryptcls):
+    message = b"Hello World"
+    key = bytes(0 for _ in range(128))  # b"secret key"
+    crypt = cryptcls(key)
+    assert crypt.decrypt(crypt.encrypt(message)) == message
+
+
+def test_decrypt_not_decrypted_pdf():
+    path = RESOURCE_ROOT / "crazyones.pdf"
+    with pytest.raises(PdfReadError) as exc:
+        PdfReader(path, password="nonexistant")
+    assert exc.value.args[0] == "Not encrypted file"
+
+
+def test_generate_values():
+    """
+    This test only checks if there is an exception.
+
+    It does not verify that the content is correct.
+    """
+    if not HAS_PYCRYPTODOME:
+        return
+    key = b"0123456789123451"
+    values = AlgV5.generate_values(
+        user_pwd=b"foo", owner_pwd=b"bar", key=key, p=0, metadata_encrypted=True
+    )
+    assert values == {
+        "/U": values["/U"],
+        "/UE": values["/UE"],
+        "/O": values["/O"],
+        "/OE": values["/OE"],
+        "/Perms": values["/Perms"],
+    }
diff --git a/tests/test_filters.py b/tests/test_filters.py
index 3f69b642b..36203d5e6 100644
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -1,11 +1,12 @@
 import string
 from io import BytesIO
 from itertools import product as cartesian_product
+from unittest.mock import patch
 
 import pytest
 
 from PyPDF2 import PdfReader
-from PyPDF2.errors import PdfReadError, PdfReadWarning, PdfStreamError
+from PyPDF2.errors import PdfReadError, PdfStreamError
 from PyPDF2.filters import (
     ASCII85Decode,
     ASCIIHexDecode,
@@ -198,14 +199,16 @@ def test_CCITTFaxDecode():
     )
 
 
-def test_decompress_zlib_error():
+@patch("PyPDF2._reader.logger_warning")
+def test_decompress_zlib_error(mock_logger_warning):
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/952/952445.pdf"
     name = "tika-952445.pdf"
-    with pytest.warns(PdfReadWarning, match=r"incorrect startxref pointer\(3\)"):
-        reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
     for page in reader.pages:
         page.extract_text()
-    # assert exc.value.args[0] == "Could not find xref table at specified location"
+    mock_logger_warning.assert_called_with(
+        "incorrect startxref pointer(3)", "PyPDF2._reader"
+    )
 
 
 def test_lzw_decode_neg1():
@@ -216,3 +219,10 @@ def test_lzw_decode_neg1():
         for page in reader.pages:
             page.extract_text()
     assert exc.value.args[0] == "Missed the stop code in LZWDecode!"
+
+
+def test_issue_399():
+    url = "https://corpora.tika.apache.org/base/docs/govdocs1/976/976970.pdf"
+    name = "tika-976970.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    reader.pages[1].extract_text()
diff --git a/tests/test_generic.py b/tests/test_generic.py
index c7b79b308..5cb1ae5d1 100644
--- a/tests/test_generic.py
+++ b/tests/test_generic.py
@@ -1,14 +1,17 @@
 import os
 from io import BytesIO
+from pathlib import Path
+from unittest.mock import patch
 
 import pytest
 
 from PyPDF2 import PdfMerger, PdfReader, PdfWriter
+from PyPDF2.constants import CheckboxRadioButtonAttributes
 from PyPDF2.constants import TypFitArguments as TF
-from PyPDF2.errors import PdfReadError, PdfReadWarning, PdfStreamError
+from PyPDF2.errors import PdfReadError, PdfStreamError
 from PyPDF2.generic import (
+    AnnotationBuilder,
     ArrayObject,
-    Bookmark,
     BooleanObject,
     ByteStringObject,
     Destination,
@@ -18,6 +21,7 @@
     NameObject,
     NullObject,
     NumberObject,
+    OutlineItem,
     RectangleObject,
     TextStringObject,
     TreeObject,
@@ -28,11 +32,11 @@
     read_string_from_stream,
 )
 
-from . import get_pdf_from_url
+from . import ReaderDummy, get_pdf_from_url
 
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
 
 
 def test_float_object_exception():
@@ -153,7 +157,12 @@ def test_readStringFromStream_multichar_eol2():
 
 def test_readStringFromStream_excape_digit():
     stream = BytesIO(b"x\\1a )")
-    assert read_string_from_stream(stream) == "\x01 "
+    assert read_string_from_stream(stream) == "\x01a "
+
+
+def test_readStringFromStream_excape_digit2():
+    stream = BytesIO(b"(hello \\1\\2\\3\\4)")
+    assert read_string_from_stream(stream) == "hello \x01\x02\x03\x04"
 
 
 def test_NameObject():
@@ -187,20 +196,24 @@ def test_destination_fit_r():
 def test_destination_fit_v():
     Destination(NameObject("title"), NullObject(), NameObject(TF.FIT_V), FloatObject(0))
 
+    # Trigger Exception
+    Destination(NameObject("title"), NullObject(), NameObject(TF.FIT_V), None)
+
 
 def test_destination_exception():
-    with pytest.raises(PdfReadError):
+    with pytest.raises(PdfReadError) as exc:
         Destination(
             NameObject("title"), NullObject(), NameObject("foo"), FloatObject(0)
         )
+    assert exc.value.args[0] == "Unknown Destination Type: 'foo'"
 
 
-def test_bookmark_write_to_stream():
+def test_outline_item_write_to_stream():
     stream = BytesIO()
-    bm = Bookmark(
+    oi = OutlineItem(
         NameObject("title"), NullObject(), NameObject(TF.FIT_V), FloatObject(0)
     )
-    bm.write_to_stream(stream, None)
+    oi.write_to_stream(stream, None)
     stream.seek(0, 0)
     assert stream.read() == b"<<\n/Title title\n/Dest [ null /FitV 0 ]\n>>"
 
@@ -347,7 +360,6 @@ class Tst:  # to replace pdf
         if length in (6, 10):
             assert b"BT /F1" in do._StreamObject__data
         raise PdfReadError("__ALLGOOD__")
-    print(exc.value)
     assert should_fail ^ (exc.value.args[0] == "__ALLGOOD__")
 
 
@@ -391,14 +403,116 @@ def test_remove_child_not_in_tree():
     assert exc.value.args[0] == "Removed child does not appear to be a tree item"
 
 
+def test_remove_child_not_in_that_tree():
+    class ChildDummy:
+        def __init__(self, parent):
+            self.parent = parent
+
+        def get_object(self):
+            tree = DictionaryObject()
+            tree[NameObject("/Parent")] = self.parent
+            return tree
+
+    tree = TreeObject()
+    child = ChildDummy(TreeObject())
+    tree.add_child(child, ReaderDummy())
+    with pytest.raises(ValueError) as exc:
+        tree.remove_child(child)
+    assert exc.value.args[0] == "Removed child is not a member of this tree"
+
+
+def test_remove_child_not_found_in_tree():
+    class ChildDummy:
+        def __init__(self, parent):
+            self.parent = parent
+
+        def get_object(self):
+            tree = DictionaryObject()
+            tree[NameObject("/Parent")] = self.parent
+            return tree
+
+    tree = TreeObject()
+    child = ChildDummy(tree)
+    tree.add_child(child, ReaderDummy())
+    with pytest.raises(ValueError) as exc:
+        tree.remove_child(child)
+    assert exc.value.args[0] == "Removal couldn't find item in tree"
+
+
+def test_remove_child_found_in_tree():
+    writer = PdfWriter()
+
+    # Add Tree
+    tree = TreeObject()
+    writer._add_object(tree)
+
+    # Add first child
+    # It's important to set a value, otherwise the writer.get_reference will
+    # return the same object when a second child is added.
+    child1 = TreeObject()
+    child1[NameObject("/Foo")] = TextStringObject("bar")
+    child1_ref = writer._add_object(child1)
+    tree.add_child(child1_ref, writer)
+    assert tree[NameObject("/Count")] == 1
+    assert len([el for el in tree.children()]) == 1
+
+    # Add second child
+    child2 = TreeObject()
+    child2[NameObject("/Foo")] = TextStringObject("baz")
+    child2_ref = writer._add_object(child2)
+    tree.add_child(child2_ref, writer)
+    assert tree[NameObject("/Count")] == 2
+    assert len([el for el in tree.children()]) == 2
+
+    # Remove last child
+    tree.remove_child(child2)
+    assert tree[NameObject("/Count")] == 1
+    assert len([el for el in tree.children()]) == 1
+
+    # Add new child
+    child3 = TreeObject()
+    child3[NameObject("/Foo")] = TextStringObject("3")
+    child3_ref = writer._add_object(child3)
+    tree.add_child(child3_ref, writer)
+    assert tree[NameObject("/Count")] == 2
+    assert len([el for el in tree.children()]) == 2
+
+    # Remove first child
+    child1 = tree[NameObject("/First")]
+    tree.remove_child(child1)
+    assert tree[NameObject("/Count")] == 1
+    assert len([el for el in tree.children()]) == 1
+
+    child4 = TreeObject()
+    child4[NameObject("/Foo")] = TextStringObject("4")
+    child4_ref = writer._add_object(child4)
+    tree.add_child(child4_ref, writer)
+    assert tree[NameObject("/Count")] == 2
+    assert len([el for el in tree.children()]) == 2
+
+    child5 = TreeObject()
+    child5[NameObject("/Foo")] = TextStringObject("5")
+    child5_ref = writer._add_object(child5)
+    tree.add_child(child5_ref, writer)
+    assert tree[NameObject("/Count")] == 3
+    assert len([el for el in tree.children()]) == 3
+
+    # Remove middle child
+    tree.remove_child(child4)
+    assert tree[NameObject("/Count")] == 2
+    assert len([el for el in tree.children()]) == 2
+
+    tree.empty_tree()
+
+
 def test_remove_child_in_tree():
-    pdf = os.path.join(RESOURCE_ROOT, "form.pdf")
+    pdf = RESOURCE_ROOT / "form.pdf"
 
     tree = TreeObject()
     reader = PdfReader(pdf)
     writer = PdfWriter()
     writer.add_page(reader.pages[0])
-    writer.add_bookmark("foo", pagenum=0)
+    writer.add_outline_item("foo", pagenum=0)
     obj = writer._objects[-1]
     tree.add_child(obj, writer)
     tree.remove_child(obj)
@@ -406,14 +520,17 @@ def test_remove_child_in_tree():
     tree.empty_tree()
 
 
-def test_dict_read_from_stream():
+def test_dict_read_from_stream(caplog):
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/984/984877.pdf"
     name = "tika-984877.pdf"
 
     reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
     for page in reader.pages:
-        with pytest.warns(PdfReadWarning):
-            page.extract_text()
+        page.extract_text()
+    assert (
+        "Multiple definitions in dictionary at byte 0x1084 for key /Length"
+        in caplog.text
+    )
 
 
 def test_parse_content_stream_peek_percentage():
@@ -462,32 +579,232 @@ def test_name_object_read_from_stream_unicode_error():  # L588
         page.extract_text()
 
 
-def test_bool_repr():
+def test_bool_repr(tmp_path):
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/932/932449.pdf"
     name = "tika-932449.pdf"
 
     reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
-    with open("tmp-fields-report.txt", "w") as fp:
+    write_path = tmp_path / "tmp-fields-report.txt"
+    with open(write_path, "w") as fp:
         fields = reader.get_fields(fileobj=fp)
     assert fields
-
-    # cleanup
-    os.remove("tmp-fields-report.txt")
+    assert list(fields.keys()) == ["USGPOSignature"]
+    with open(write_path) as fp:
+        data = fp.read()
+    assert data.startswith(
+        "Field Name: USGPOSignature\nField Type: Signature\nField Flags: 1\n"
+        "Value: {'/Type': '/Sig', '/Filter': '/Adobe.PPKLite', "
+        "'/SubFilter':"
+    )
 
 
-def test_issue_997():
+@patch("PyPDF2._reader.logger_warning")
+def test_issue_997(mock_logger_warning):
     url = "https://github.com/py-pdf/PyPDF2/files/8908874/Exhibit_A-2_930_Enterprise_Zone_Tax_Credits_final.pdf"
     name = "gh-issue-997.pdf"
 
     merger = PdfMerger()
     merged_filename = "tmp-out.pdf"
-    with pytest.warns(PdfReadWarning, match="not defined"):
+    merger.append(BytesIO(get_pdf_from_url(url, name=name)))  # here the error raises
+    with open(merged_filename, "wb") as f:
+        merger.write(f)
+    merger.close()
+
+    mock_logger_warning.assert_called_with(
+        "Overwriting cache for 0 4", "PyPDF2._reader"
+    )
+
+    # Strict
+    merger = PdfMerger(strict=True)
+    merged_filename = "tmp-out.pdf"
+    with pytest.raises(PdfReadError) as exc:
         merger.append(
             BytesIO(get_pdf_from_url(url, name=name))
         )  # here the error raises
+    assert exc.value.args[0] == "Could not find object."
     with open(merged_filename, "wb") as f:
         merger.write(f)
     merger.close()
 
     # cleanup
     os.remove(merged_filename)
+
+
+def test_annotation_builder_free_text():
+    # Arrange
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
+    reader = PdfReader(pdf_path)
+    page = reader.pages[0]
+    writer = PdfWriter()
+    writer.add_page(page)
+
+    # Act
+    free_text_annotation = AnnotationBuilder.free_text(
+        "Hello World - bold and italic\nThis is the second line!",
+        rect=(50, 550, 200, 650),
+        font="Arial",
+        bold=True,
+        italic=True,
+        font_size="20pt",
+        font_color="00ff00",
+        border_color="0000ff",
+        background_color="cdcdcd",
+    )
+    writer.add_annotation(0, free_text_annotation)
+
+    free_text_annotation = AnnotationBuilder.free_text(
+        "Another free text annotation (not bold, not italic)",
+        rect=(500, 550, 200, 650),
+        font="Arial",
+        bold=False,
+        italic=False,
+        font_size="20pt",
+        font_color="00ff00",
+        border_color="0000ff",
+        background_color="cdcdcd",
+    )
+    writer.add_annotation(0, free_text_annotation)
+
+    # Assert: You need to inspect the file manually
+    target = "annotated-pdf.pdf"
+    with open(target, "wb") as fp:
+        writer.write(fp)
+
+    os.remove(target)  # comment this out for manual inspection
+
+
+def test_annotation_builder_line():
+    # Arrange
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
+    reader = PdfReader(pdf_path)
+    page = reader.pages[0]
+    writer = PdfWriter()
+    writer.add_page(page)
+
+    # Act
+    line_annotation = AnnotationBuilder.line(
+        text="Hello World\nLine2",
+        rect=(50, 550, 200, 650),
+        p1=(50, 550),
+        p2=(200, 650),
+    )
+    writer.add_annotation(0, line_annotation)
+
+    # Assert: You need to inspect the file manually
+    target = "annotated-pdf.pd"
+    with open(target, "wb") as fp:
+        writer.write(fp)
+
+    os.remove(target)  # comment this out for manual inspection
+
+
+def test_annotation_builder_link():
+    # Arrange
+    pdf_path = RESOURCE_ROOT / "outline-without-title.pdf"
+    reader = PdfReader(pdf_path)
+    page = reader.pages[0]
+    writer = PdfWriter()
+    writer.add_page(page)
+
+    # Act
+    # Part 1: Too many args
+    with pytest.raises(ValueError) as exc:
+        AnnotationBuilder.link(
+            rect=(50, 550, 200, 650),
+            url="https://martin-thoma.com/",
+            target_page_index=3,
+        )
+    assert (
+        exc.value.args[0]
+        == "Either 'url' or 'target_page_index' have to be provided. url=https://martin-thoma.com/, target_page_index=3"
+    )
+
+    # Part 2: Too few args
+    with pytest.raises(ValueError) as exc:
+        AnnotationBuilder.link(
+            rect=(50, 550, 200, 650),
+        )
+    assert (
+        exc.value.args[0]
+        == "Either 'url' or 'target_page_index' have to be provided. Both were None."
+    )
+
+    # Part 3: External Link
+    link_annotation = AnnotationBuilder.link(
+        rect=(50, 50, 100, 100),
+        url="https://martin-thoma.com/",
+        border=[1, 0, 6, [3, 2]],
+    )
+    writer.add_annotation(0, link_annotation)
+
+    # Part 4: Internal Link
+    link_annotation = AnnotationBuilder.link(
+        rect=(100, 100, 300, 200),
+        target_page_index=1,
+        border=[50, 10, 4],
+    )
+    writer.add_annotation(0, link_annotation)
+
+    for page in reader.pages[1:]:
+        writer.add_page(page)
+
+    # Assert: You need to inspect the file manually
+    target = "annotated-pdf-link.pdf"
+    with open(target, "wb") as fp:
+        writer.write(fp)
+
+    os.remove(target)  # comment this out for manual inspection
+
+
+def test_annotation_builder_text():
+    # Arrange
+    pdf_path = RESOURCE_ROOT / "outline-without-title.pdf"
+    reader = PdfReader(pdf_path)
+    page = reader.pages[0]
+    writer = PdfWriter()
+    writer.add_page(page)
+
+    # Act
+    text_annotation = AnnotationBuilder.text(
+        text="Hello World\nThis is the second line!",
+        rect=(50, 550, 500, 650),
+        open=True,
+    )
+    writer.add_annotation(0, text_annotation)
+
+    # Assert: You need to inspect the file manually
+    target = "annotated-pdf-popup.pdf"
+    with open(target, "wb") as fp:
+        writer.write(fp)
+
+    os.remove(target)  # comment this out for manual inspection
+
+
+def test_CheckboxRadioButtonAttributes_opt():
+    assert "/Opt" in CheckboxRadioButtonAttributes.attributes_dict()
+
+
+def test_name_object_invalid_decode():
+    stream = BytesIO(b"/\x80\x02\x03")
+
+    # strict:
+    with pytest.raises(PdfReadError) as exc:
+        NameObject.read_from_stream(stream, ReaderDummy(strict=True))
+    assert exc.value.args[0] == "Illegal character in Name Object"
+
+    # non-strict:
+    stream.seek(0)
+    NameObject.read_from_stream(stream, ReaderDummy(strict=False))
+
+
+def test_indirect_object_invalid_read():
+    stream = BytesIO(b"0 1 s")
+    with pytest.raises(PdfReadError) as exc:
+        IndirectObject.read_from_stream(stream, ReaderDummy())
+    assert exc.value.args[0] == "Error reading indirect object reference at byte 0x5"
+
+
+def test_create_string_object_force():
+    assert create_string_object(b"Hello World", []) == "Hello World"
+    assert create_string_object(b"Hello World", {72: "A"}) == "Aello World"
+    assert create_string_object(b"Hello World", "utf8") == "Hello World"
diff --git a/tests/test_javascript.py b/tests/test_javascript.py
index 83e08ff21..a63f0e47d 100644
--- a/tests/test_javascript.py
+++ b/tests/test_javascript.py
@@ -1,18 +1,18 @@
-import os
+from pathlib import Path
 
 import pytest
 
 from PyPDF2 import PdfReader, PdfWriter
 
 # Configure path environment
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
 
 
 @pytest.fixture()
 def pdf_file_writer():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "crazyones.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf")
     writer = PdfWriter()
     writer.append_pages_from_reader(reader)
     return writer
diff --git a/tests/test_merger.py b/tests/test_merger.py
index 2a9d9e4e6..2cce04122 100644
--- a/tests/test_merger.py
+++ b/tests/test_merger.py
@@ -1,6 +1,7 @@
 import os
 import sys
 from io import BytesIO
+from pathlib import Path
 
 import pytest
 
@@ -10,27 +11,33 @@
 
 from . import get_pdf_from_url
 
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
 
-sys.path.append(PROJECT_ROOT)
+sys.path.append(str(PROJECT_ROOT))
 
 
-def test_merge():
-    pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
-    outline = os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf")
-    pdf_forms = os.path.join(RESOURCE_ROOT, "pdflatex-forms.pdf")
-    pdf_pw = os.path.join(RESOURCE_ROOT, "libreoffice-writer-password.pdf")
-
-    merger = PyPDF2.PdfMerger()
+def merger_operate(merger):
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
+    outline = RESOURCE_ROOT / "pdflatex-outline.pdf"
+    pdf_forms = RESOURCE_ROOT / "pdflatex-forms.pdf"
+    pdf_pw = RESOURCE_ROOT / "libreoffice-writer-password.pdf"
 
     # string path:
     merger.append(pdf_path)
     merger.append(outline)
     merger.append(pdf_path, pages=PyPDF2.pagerange.PageRange(slice(0, 0)))
     merger.append(pdf_forms)
-    merger.merge(0, pdf_path, import_bookmarks=False)
+    merger.merge(0, pdf_path, import_outline=False)
+    with pytest.raises(NotImplementedError) as exc:
+        with open(pdf_path, "rb") as fp:
+            data = fp.read()
+        merger.append(data)
+    assert exc.value.args[0].startswith(
+        "PdfMerger.merge requires an object that PdfReader can parse. "
+        "Typically, that is a Path"
+    )
 
     # Merging an encrypted file
     reader = PyPDF2.PdfReader(pdf_pw)
@@ -38,56 +45,68 @@ def test_merge():
     merger.append(reader)
 
     # PdfReader object:
-    merger.append(PyPDF2.PdfReader(pdf_path), bookmark="foo")
+    merger.append(PyPDF2.PdfReader(pdf_path), outline_item="foo")
 
     # File handle
     with open(pdf_path, "rb") as fh:
         merger.append(fh)
 
-    bookmark = merger.add_bookmark("A bookmark", 0)
-    bm2 = merger.add_bookmark("deeper", 0, parent=bookmark, italic=True, bold=True)
-    merger.add_bookmark("Let's see", 2, bm2, (255, 255, 0), True, True, "/FitBV", 12)
-    merger.add_bookmark(
-        "The XYZ fit", 0, bookmark, (255, 0, 15), True, True, "/XYZ", 10, 20, 3
+    outline_item = merger.add_outline_item("An outline item", 0)
+    oi2 = merger.add_outline_item(
+        "deeper", 0, parent=outline_item, italic=True, bold=True
+    )
+    merger.add_outline_item(
+        "Let's see", 2, oi2, (255, 255, 0), True, True, "/FitBV", 12
+    )
+    merger.add_outline_item(
+        "The XYZ fit", 0, outline_item, (255, 0, 15), True, True, "/XYZ", 10, 20, 3
+    )
+    merger.add_outline_item(
+        "The FitH fit", 0, outline_item, (255, 0, 15), True, True, "/FitH", 10
     )
-    merger.add_bookmark(
-        "The FitH fit", 0, bookmark, (255, 0, 15), True, True, "/FitH", 10
+    merger.add_outline_item(
+        "The FitV fit", 0, outline_item, (255, 0, 15), True, True, "/FitV", 10
     )
-    merger.add_bookmark(
-        "The FitV fit", 0, bookmark, (255, 0, 15), True, True, "/FitV", 10
+    merger.add_outline_item(
+        "The FitR fit",
+        0,
+        outline_item,
+        (255, 0, 15),
+        True,
+        True,
+        "/FitR",
+        10,
+        20,
+        30,
+        40,
     )
-    merger.add_bookmark(
-        "The FitR fit", 0, bookmark, (255, 0, 15), True, True, "/FitR", 10, 20, 30, 40
+    merger.add_outline_item(
+        "The FitB fit", 0, outline_item, (255, 0, 15), True, True, "/FitB"
     )
-    merger.add_bookmark("The FitB fit", 0, bookmark, (255, 0, 15), True, True, "/FitB")
-    merger.add_bookmark(
-        "The FitBH fit", 0, bookmark, (255, 0, 15), True, True, "/FitBH", 10
+    merger.add_outline_item(
+        "The FitBH fit", 0, outline_item, (255, 0, 15), True, True, "/FitBH", 10
     )
-    merger.add_bookmark(
-        "The FitBV fit", 0, bookmark, (255, 0, 15), True, True, "/FitBV", 10
+    merger.add_outline_item(
+        "The FitBV fit", 0, outline_item, (255, 0, 15), True, True, "/FitBV", 10
     )
 
-    found_bm = merger.find_bookmark("nothing here")
-    assert found_bm is None
+    found_oi = merger.find_outline_item("nothing here")
+    assert found_oi is None
 
-    found_bm = merger.find_bookmark("foo")
-    assert found_bm == [9]
+    found_oi = merger.find_outline_item("foo")
+    assert found_oi == [9]
 
     merger.add_metadata({"author": "Martin Thoma"})
     merger.add_named_destination("title", 0)
     merger.set_page_layout("/SinglePage")
     merger.set_page_mode("/UseThumbs")
 
-    tmp_path = "dont_commit_merged.pdf"
-    merger.write(tmp_path)
-    merger.close()
 
-    # Check if bookmarks are correct
+def check_outline(tmp_path):
+    # Check if outline is correct
     reader = PyPDF2.PdfReader(tmp_path)
-    assert [
-        el.title for el in reader._get_outlines() if isinstance(el, Destination)
-    ] == [
-        "A bookmark",
+    assert [el.title for el in reader.outline if isinstance(el, Destination)] == [
+        "An outline item",
         "Foo",
         "Bar",
         "Baz",
@@ -102,13 +121,49 @@ def test_merge():
 
     # TODO: There seem to be no destinations for those links?
 
-    # Clean up
-    os.remove(tmp_path)
+
+tmp_filename = "dont_commit_merged.pdf"
+
+
+def test_merger_operations_by_traditional_usage(tmp_path):
+    # Arrange
+    merger = PdfMerger()
+    merger_operate(merger)
+    path = tmp_path / tmp_filename
+
+    # Act
+    merger.write(path)
+    merger.close()
+
+    # Assert
+    check_outline(path)
+
+
+def test_merger_operations_by_semi_traditional_usage(tmp_path):
+    path = tmp_path / tmp_filename
+
+    with PdfMerger() as merger:
+        merger_operate(merger)
+        merger.write(path)  # Act
+
+    # Assert
+    assert os.path.isfile(path)
+    check_outline(path)
+
+
+def test_merger_operation_by_new_usage(tmp_path):
+    path = tmp_path / tmp_filename
+    with PdfMerger(fileobj=path) as merger:
+        merger_operate(merger)
+
+    # Assert
+    assert os.path.isfile(path)
+    check_outline(path)
 
 
 def test_merge_page_exception():
     merger = PyPDF2.PdfMerger()
-    pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
     with pytest.raises(TypeError) as exc:
         merger.merge(0, pdf_path, pages="a:b")
     assert exc.value.args[0] == '"pages" must be a tuple of (start, stop[, step])'
@@ -117,14 +172,14 @@ def test_merge_page_exception():
 
 def test_merge_page_tuple():
     merger = PyPDF2.PdfMerger()
-    pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
     merger.merge(0, pdf_path, pages=(0, 1))
     merger.close()
 
 
 def test_merge_write_closed_fh():
     merger = PyPDF2.PdfMerger()
-    pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
     merger.append(pdf_path)
 
     err_closed = "close() was called and thus the writer cannot be used anymore"
@@ -147,11 +202,11 @@ def test_merge_write_closed_fh():
     assert exc.value.args[0] == err_closed
 
     with pytest.raises(RuntimeError) as exc:
-        merger._write_bookmarks()
+        merger._write_outline()
     assert exc.value.args[0] == err_closed
 
     with pytest.raises(RuntimeError) as exc:
-        merger.add_bookmark("A bookmark", 0)
+        merger.add_outline_item("An outline item", 0)
     assert exc.value.args[0] == err_closed
 
     with pytest.raises(RuntimeError) as exc:
@@ -166,6 +221,7 @@ def test_trim_outline_list():
     merger = PdfMerger()
     merger.append(reader)
     merger.write("tmp-merger-do-not-commit.pdf")
+    merger.close()
 
     # cleanup
     os.remove("tmp-merger-do-not-commit.pdf")
@@ -178,6 +234,7 @@ def test_zoom():
     merger = PdfMerger()
     merger.append(reader)
     merger.write("tmp-merger-do-not-commit.pdf")
+    merger.close()
 
     # cleanup
     os.remove("tmp-merger-do-not-commit.pdf")
@@ -190,18 +247,20 @@ def test_zoom_xyz_no_left():
     merger = PdfMerger()
     merger.append(reader)
     merger.write("tmp-merger-do-not-commit.pdf")
+    merger.close()
 
     # cleanup
     os.remove("tmp-merger-do-not-commit.pdf")
 
 
-def test_bookmark():
+def test_outline_item():
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/997/997511.pdf"
     name = "tika-997511.pdf"
     reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
     merger = PdfMerger()
     merger.append(reader)
     merger.write("tmp-merger-do-not-commit.pdf")
+    merger.close()
 
     # cleanup
     os.remove("tmp-merger-do-not-commit.pdf")
@@ -214,6 +273,7 @@ def test_trim_outline():
     merger = PdfMerger()
     merger.append(reader)
     merger.write("tmp-merger-do-not-commit.pdf")
+    merger.close()
 
     # cleanup
     os.remove("tmp-merger-do-not-commit.pdf")
@@ -226,6 +286,7 @@ def test1():
     merger = PdfMerger()
     merger.append(reader)
     merger.write("tmp-merger-do-not-commit.pdf")
+    merger.close()
 
     # cleanup
     os.remove("tmp-merger-do-not-commit.pdf")
@@ -239,6 +300,7 @@ def test_sweep_recursion1():
     merger = PdfMerger()
     merger.append(reader)
     merger.write("tmp-merger-do-not-commit.pdf")
+    merger.close()
 
     reader2 = PdfReader("tmp-merger-do-not-commit.pdf")
     reader2.pages
@@ -266,6 +328,7 @@ def test_sweep_recursion2(url, name):
     merger = PdfMerger()
     merger.append(reader)
     merger.write("tmp-merger-do-not-commit.pdf")
+    merger.close()
 
     reader2 = PdfReader("tmp-merger-do-not-commit.pdf")
     reader2.pages
@@ -274,17 +337,46 @@ def test_sweep_recursion2(url, name):
     os.remove("tmp-merger-do-not-commit.pdf")
 
 
-def test_sweep_indirect_list_newobj_is_None():
+def test_sweep_indirect_list_newobj_is_None(caplog):
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/906/906769.pdf"
     name = "tika-906769.pdf"
     reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
     merger = PdfMerger()
     merger.append(reader)
-    with pytest.warns(UserWarning, match="Object 21 0 not defined."):
-        merger.write("tmp-merger-do-not-commit.pdf")
+    merger.write("tmp-merger-do-not-commit.pdf")
+    merger.close()
+    assert "Object 21 0 not defined." in caplog.text
 
     reader2 = PdfReader("tmp-merger-do-not-commit.pdf")
     reader2.pages
 
     # cleanup
     os.remove("tmp-merger-do-not-commit.pdf")
+
+
+def test_iss1145():
+    # issue with FitH destination with null param
+    url = "https://github.com/py-pdf/PyPDF2/files/9164743/file-0.pdf"
+    name = "iss1145.pdf"
+    merger = PdfMerger()
+    merger.append(PdfReader(BytesIO(get_pdf_from_url(url, name=name))))
+    merger.close()
+
+
+def test_deprecate_bookmark_decorator_warning():
+    reader = PdfReader(RESOURCE_ROOT / "outlines-with-invalid-destinations.pdf")
+    merger = PdfMerger()
+    with pytest.warns(
+        UserWarning,
+        match="import_bookmarks is deprecated as an argument. Use import_outline instead",
+    ):
+        merger.merge(0, reader, import_bookmarks=True)
+
+
+@pytest.mark.filterwarnings("ignore::UserWarning")
+def test_deprecate_bookmark_decorator_output():
+    reader = PdfReader(RESOURCE_ROOT / "outlines-with-invalid-destinations.pdf")
+    merger = PdfMerger()
+    merger.merge(0, reader, import_bookmarks=True)
+    first_oi_title = 'Valid Destination: Action /GoTo Named Destination "section.1"'
+    assert merger.outline[0].title == first_oi_title
diff --git a/tests/test_page.py b/tests/test_page.py
index 65366459e..40906bd3e 100644
--- a/tests/test_page.py
+++ b/tests/test_page.py
@@ -2,25 +2,34 @@
 import os
 from copy import deepcopy
 from io import BytesIO
+from pathlib import Path
 
 import pytest
 
-from PyPDF2 import PdfReader, Transformation
+from PyPDF2 import PdfReader, PdfWriter, Transformation
 from PyPDF2._page import PageObject
 from PyPDF2.constants import PageAttributes as PG
 from PyPDF2.errors import PdfReadWarning
-from PyPDF2.generic import DictionaryObject, NameObject, RectangleObject
+from PyPDF2.generic import (
+    ArrayObject,
+    DictionaryObject,
+    FloatObject,
+    IndirectObject,
+    NameObject,
+    RectangleObject,
+    TextStringObject,
+)
 
-from . import get_pdf_from_url
+from . import get_pdf_from_url, normalize_warnings
 
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
-EXTERNAL_ROOT = os.path.join(PROJECT_ROOT, "sample-files")
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
+EXTERNAL_ROOT = PROJECT_ROOT / "sample-files"
 
 
 def get_all_sample_files():
-    with open(os.path.join(EXTERNAL_ROOT, "files.json")) as fp:
+    with open(EXTERNAL_ROOT / "files.json") as fp:
         data = fp.read()
     meta = json.loads(data)
     return meta
@@ -35,8 +44,9 @@ def get_all_sample_files():
     [m for m in all_files_meta["data"] if not m["encrypted"]],
     ids=[m["path"] for m in all_files_meta["data"] if not m["encrypted"]],
 )
+@pytest.mark.filterwarnings("ignore::PyPDF2.errors.PdfReadWarning")
 def test_read(meta):
-    pdf_path = os.path.join(EXTERNAL_ROOT, meta["path"])
+    pdf_path = EXTERNAL_ROOT / meta["path"]
     reader = PdfReader(pdf_path)
     reader.pages[0]
     assert len(reader.pages) == meta["pages"]
@@ -67,7 +77,7 @@ def test_page_operations(pdf_path, password):
     if pdf_path.startswith("http"):
         pdf_path = BytesIO(get_pdf_from_url(pdf_path, pdf_path.split("/")[-1]))
     else:
-        pdf_path = os.path.join(RESOURCE_ROOT, pdf_path)
+        pdf_path = RESOURCE_ROOT / pdf_path
     reader = PdfReader(pdf_path)
 
     if password:
@@ -89,11 +99,11 @@ def test_page_operations(pdf_path, password):
 
 
 def test_transformation_equivalence():
-    pdf_path = os.path.join(RESOURCE_ROOT, "labeled-edges-center-image.pdf")
+    pdf_path = RESOURCE_ROOT / "labeled-edges-center-image.pdf"
     reader_base = PdfReader(pdf_path)
     page_base = reader_base.pages[0]
 
-    pdf_path = os.path.join(RESOURCE_ROOT, "box.pdf")
+    pdf_path = RESOURCE_ROOT / "box.pdf"
     reader_add = PdfReader(pdf_path)
     page_box = reader_add.pages[0]
 
@@ -131,7 +141,7 @@ def compare_dict_objects(d1, d2):
 
 
 def test_page_transformations():
-    pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
     reader = PdfReader(pdf_path)
 
     page: PageObject = reader.pages[0]
@@ -157,11 +167,11 @@ def test_page_transformations():
 @pytest.mark.parametrize(
     ("pdf_path", "password"),
     [
-        (os.path.join(RESOURCE_ROOT, "crazyones.pdf"), None),
-        (os.path.join(RESOURCE_ROOT, "attachment.pdf"), None),
-        (os.path.join(RESOURCE_ROOT, "side-by-side-subfig.pdf"), None),
+        (RESOURCE_ROOT / "crazyones.pdf", None),
+        (RESOURCE_ROOT / "attachment.pdf", None),
+        (RESOURCE_ROOT / "side-by-side-subfig.pdf", None),
         (
-            os.path.join(RESOURCE_ROOT, "libreoffice-writer-password.pdf"),
+            RESOURCE_ROOT / "libreoffice-writer-password.pdf",
             "openpassword",
         ),
     ],
@@ -175,7 +185,7 @@ def test_compress_content_streams(pdf_path, password):
 
 
 def test_page_properties():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "crazyones.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf")
     page = reader.pages[0]
     assert page.mediabox == RectangleObject((0, 0, 612, 792))
     assert page.cropbox == RectangleObject((0, 0, 612, 792))
@@ -188,7 +198,7 @@ def test_page_properties():
 
 
 def test_page_rotation_non90():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "crazyones.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf")
     page = reader.pages[0]
     with pytest.raises(ValueError) as exc:
         page.rotate(91)
@@ -211,7 +221,7 @@ def test_add_transformation_on_page_without_contents():
 
 
 def test_multi_language():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "multilang.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "multilang.pdf")
     txt = reader.pages[0].extract_text()
     assert "Hello World" in txt, "English not correctly extracted"
     # Arabic is for the moment left on side
@@ -228,6 +238,22 @@ def test_extract_text_single_quote_op():
         page.extract_text()
 
 
+def test_no_ressources_on_text_extract():
+    url = "https://github.com/py-pdf/PyPDF2/files/9428434/TelemetryTX_EM.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name="tika-964029.pdf")))
+    for page in reader.pages:
+        page.extract_text()
+
+
+def test_iss_1142():
+    # check fix for problem of context save/restore (q/Q)
+    url = "https://github.com/py-pdf/PyPDF2/files/9150656/ST.2019.PDF"
+    name = "st2019.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    txt = reader.pages[3].extract_text()
+    assert txt.find("有限公司郑州分公司") > 0
+
+
 @pytest.mark.parametrize(
     ("url", "name"),
     [
@@ -241,6 +267,16 @@ def test_extract_text_single_quote_op():
             "https://corpora.tika.apache.org/base/docs/govdocs1/932/932446.pdf",
             "tika-932446.pdf",
         ),
+        # iss 1134:
+        (
+            "https://github.com/py-pdf/PyPDF2/files/9150656/ST.2019.PDF",
+            "iss_1134.pdf",
+        ),
+        # iss 1:
+        (
+            "https://github.com/py-pdf/PyPDF2/files/9432350/Work.Flow.From.Check.to.QA.pdf",
+            "WFCA.pdf",
+        ),
     ],
 )
 def test_extract_text_page_pdf(url, name):
@@ -249,15 +285,14 @@ def test_extract_text_page_pdf(url, name):
         page.extract_text()
 
 
-def test_extract_text_page_pdf_impossible_decode_xform():
+def test_extract_text_page_pdf_impossible_decode_xform(caplog):
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/972/972962.pdf"
     name = "tika-972962.pdf"
     reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
-    with pytest.warns(
-        PdfReadWarning, match="impossible to decode XFormObject /Meta203"
-    ):
-        for page in reader.pages:
-            page.extract_text()
+    for page in reader.pages:
+        page.extract_text()
+    warn_msgs = normalize_warnings(caplog.text)
+    assert warn_msgs == [""]  # text extraction recognise no text
 
 
 def test_extract_text_operator_t_star():  # L1266, L1267
@@ -272,7 +307,7 @@ def test_extract_text_operator_t_star():  # L1266, L1267
     ("pdf_path", "password", "embedded", "unembedded"),
     [
         (
-            os.path.join(RESOURCE_ROOT, "crazyones.pdf"),
+            RESOURCE_ROOT / "crazyones.pdf",
             None,
             {
                 "/HHXGQB+SFTI1440",
@@ -282,7 +317,7 @@ def test_extract_text_operator_t_star():  # L1266, L1267
             set(),
         ),
         (
-            os.path.join(RESOURCE_ROOT, "attachment.pdf"),
+            RESOURCE_ROOT / "attachment.pdf",
             None,
             {
                 "/HHXGQB+SFTI1440",
@@ -292,20 +327,20 @@ def test_extract_text_operator_t_star():  # L1266, L1267
             set(),
         ),
         (
-            os.path.join(RESOURCE_ROOT, "libreoffice-writer-password.pdf"),
+            RESOURCE_ROOT / "libreoffice-writer-password.pdf",
             "openpassword",
             {"/BAAAAA+DejaVuSans"},
             set(),
         ),
         (
-            os.path.join(RESOURCE_ROOT, "imagemagick-images.pdf"),
+            RESOURCE_ROOT / "imagemagick-images.pdf",
             None,
             set(),
             {"/Helvetica"},
         ),
-        (os.path.join(RESOURCE_ROOT, "imagemagick-lzw.pdf"), None, set(), set()),
+        (RESOURCE_ROOT / "imagemagick-lzw.pdf", None, set(), set()),
         (
-            os.path.join(RESOURCE_ROOT, "reportlab-inline-image.pdf"),
+            RESOURCE_ROOT / "reportlab-inline-image.pdf",
             None,
             set(),
             {"/Helvetica"},
@@ -321,3 +356,160 @@ def test_get_fonts(pdf_path, password, embedded, unembedded):
         a = a.union(a_tmp)
         b = b.union(b_tmp)
     assert (a, b) == (embedded, unembedded)
+
+
+def test_annotation_getter():
+    pdf_path = RESOURCE_ROOT / "commented.pdf"
+    reader = PdfReader(pdf_path)
+    annotations = reader.pages[0].annotations
+    assert annotations is not None
+    assert isinstance(annotations[0], IndirectObject)
+
+    annot_dict = dict(annotations[0].get_object())
+    assert "/P" in annot_dict
+    assert isinstance(annot_dict["/P"], IndirectObject)
+    del annot_dict["/P"]
+
+    annot_dict["/Popup"] = annot_dict["/Popup"].get_object()
+    del annot_dict["/Popup"]["/P"]
+    del annot_dict["/Popup"]["/Parent"]
+    assert annot_dict == {
+        "/Type": "/Annot",
+        "/Subtype": "/Text",
+        "/Rect": ArrayObject(
+            [
+                270.75,
+                596.25,
+                294.75,
+                620.25,
+            ]
+        ),
+        "/Contents": "Note in second paragraph",
+        "/C": ArrayObject([1, 1, 0]),
+        "/M": "D:20220406191858+02'00",
+        "/Popup": DictionaryObject(
+            {
+                "/M": "D:20220406191847+02'00",
+                "/Rect": ArrayObject([294.75, 446.25, 494.75, 596.25]),
+                "/Subtype": "/Popup",
+                "/Type": "/Annot",
+            }
+        ),
+        "/T": "moose",
+    }
+
+
+def test_annotation_setter():
+    # Arange
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
+    reader = PdfReader(pdf_path)
+    page = reader.pages[0]
+    writer = PdfWriter()
+    writer.add_page(page)
+
+    # Act
+    page_number = 0
+    page_link = writer.get_object(writer._pages)["/Kids"][page_number]
+    annot_dict = {
+        NameObject("/P"): page_link,
+        NameObject("/Type"): NameObject("/Annot"),
+        NameObject("/Subtype"): NameObject("/Text"),
+        NameObject("/Rect"): ArrayObject(
+            [
+                FloatObject(270.75),
+                FloatObject(596.25),
+                FloatObject(294.75),
+                FloatObject(620.25),
+            ]
+        ),
+        NameObject("/Contents"): TextStringObject("Note in second paragraph"),
+        NameObject("/C"): ArrayObject([FloatObject(1), FloatObject(1), FloatObject(0)]),
+        NameObject("/M"): TextStringObject("D:20220406191858+02'00"),
+        NameObject("/Popup"): DictionaryObject(
+            {
+                NameObject("/M"): TextStringObject("D:20220406191847+02'00"),
+                NameObject("/Rect"): ArrayObject(
+                    [
+                        FloatObject(294.75),
+                        FloatObject(446.25),
+                        FloatObject(494.75),
+                        FloatObject(596.25),
+                    ]
+                ),
+                NameObject("/Subtype"): NameObject("/Popup"),
+                NameObject("/Type"): TextStringObject("/Annot"),
+            }
+        ),
+        NameObject("/T"): TextStringObject("moose"),
+    }
+    arr = ArrayObject()
+    page.annotations = arr
+
+    # Delete Annotations
+    page.annotations = None
+
+    d = DictionaryObject(annot_dict)
+    ind_obj = writer._add_object(d)
+    arr.append(ind_obj)
+
+    # Assert manually
+    target = "annot-out.pdf"
+    with open(target, "wb") as fp:
+        writer.write(fp)
+
+    # Cleanup
+    os.remove(target)  # remove for testing
+
+
+@pytest.mark.xfail(reason="#1091")
+def test_text_extraction_issue_1091():
+    url = "https://corpora.tika.apache.org/base/docs/govdocs1/966/966635.pdf"
+    name = "tika-966635.pdf"
+    stream = BytesIO(get_pdf_from_url(url, name=name))
+    with pytest.warns(PdfReadWarning):
+        reader = PdfReader(stream)
+    for page in reader.pages:
+        page.extract_text()
+
+
+def test_empyt_password_1088():
+    url = "https://corpora.tika.apache.org/base/docs/govdocs1/941/941536.pdf"
+    name = "tika-941536.pdf"
+    stream = BytesIO(get_pdf_from_url(url, name=name))
+    reader = PdfReader(stream)
+    len(reader.pages)
+
+
+@pytest.mark.xfail(reason="#1088 / #1126")
+def test_arab_text_extraction():
+    reader = PdfReader(EXTERNAL_ROOT / "015-arabic/habibi.pdf")
+    assert reader.pages[0].extract_text() == "habibi حَبيبي"
+
+
+def test_read_link_annotation():
+    reader = PdfReader(EXTERNAL_ROOT / "016-libre-office-link/libre-office-link.pdf")
+    assert len(reader.pages[0].annotations) == 1
+    annot = dict(reader.pages[0].annotations[0].get_object())
+    expected = {
+        "/Type": "/Annot",
+        "/Subtype": "/Link",
+        "/A": DictionaryObject(
+            {
+                "/S": "/URI",
+                "/Type": "/Action",
+                "/URI": "https://martin-thoma.com/",
+            }
+        ),
+        "/Border": ArrayObject([0, 0, 0]),
+        "/Rect": [
+            92.043,
+            771.389,
+            217.757,
+            785.189,
+        ],
+    }
+
+    assert set(expected.keys()) == set(annot.keys())
+    del expected["/Rect"]
+    del annot["/Rect"]
+    assert annot == expected
diff --git a/tests/test_reader.py b/tests/test_reader.py
index a605fc708..12f956d0a 100644
--- a/tests/test_reader.py
+++ b/tests/test_reader.py
@@ -6,19 +6,22 @@
 
 import pytest
 
-from PyPDF2 import PdfMerger, PdfReader
+from PyPDF2 import PdfReader
 from PyPDF2._reader import convert_to_int, convertToInt
 from PyPDF2.constants import ImageAttributes as IA
 from PyPDF2.constants import PageAttributes as PG
 from PyPDF2.constants import Ressources as RES
 from PyPDF2.errors import (
-    STREAM_TRUNCATED_PREMATURELY,
+    EmptyFileError,
+    FileNotDecryptedError,
     PdfReadError,
     PdfReadWarning,
+    WrongPasswordError,
 )
 from PyPDF2.filters import _xobj_to_image
+from PyPDF2.generic import Destination
 
-from . import get_pdf_from_url
+from . import get_pdf_from_url, normalize_warnings
 
 try:
     from Crypto.Cipher import AES  # noqa: F401
@@ -27,9 +30,10 @@
 except ImportError:
     HAS_PYCRYPTODOME = False
 
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
+EXTERNAL_ROOT = PROJECT_ROOT / "sample-files"
 
 
 @pytest.mark.parametrize(
@@ -37,7 +41,7 @@
     [("selenium-PyPDF2-issue-177.pdf", 1), ("pdflatex-outline.pdf", 4)],
 )
 def test_get_num_pages(src, num_pages):
-    src = os.path.join(RESOURCE_ROOT, src)
+    src = RESOURCE_ROOT / src
     reader = PdfReader(src)
     assert len(reader.pages) == num_pages
 
@@ -46,7 +50,7 @@ def test_get_num_pages(src, num_pages):
     ("pdf_path", "expected"),
     [
         (
-            os.path.join(RESOURCE_ROOT, "crazyones.pdf"),
+            RESOURCE_ROOT / "crazyones.pdf",
             {
                 "/CreationDate": "D:20150604133406-06'00'",
                 "/Creator": " XeTeX output 2015.06.04:1334",
@@ -54,7 +58,7 @@ def test_get_num_pages(src, num_pages):
             },
         ),
         (
-            os.path.join(RESOURCE_ROOT, "metadata.pdf"),
+            RESOURCE_ROOT / "metadata.pdf",
             {
                 "/CreationDate": "D:20220415093243+02'00'",
                 "/ModDate": "D:20220415093243+02'00'",
@@ -99,8 +103,8 @@ def test_read_metadata(pdf_path, expected):
 @pytest.mark.parametrize(
     "src",
     [
-        (os.path.join(RESOURCE_ROOT, "crazyones.pdf")),
-        (os.path.join(RESOURCE_ROOT, "commented.pdf")),
+        RESOURCE_ROOT / "crazyones.pdf",
+        RESOURCE_ROOT / "commented.pdf",
     ],
 )
 def test_get_annotations(src):
@@ -117,8 +121,8 @@ def test_get_annotations(src):
 @pytest.mark.parametrize(
     "src",
     [
-        (os.path.join(RESOURCE_ROOT, "attachment.pdf")),
-        (os.path.join(RESOURCE_ROOT, "crazyones.pdf")),
+        RESOURCE_ROOT / "attachment.pdf",
+        RESOURCE_ROOT / "crazyones.pdf",
     ],
 )
 def test_get_attachments(src):
@@ -138,14 +142,14 @@ def test_get_attachments(src):
 @pytest.mark.parametrize(
     ("src", "outline_elements"),
     [
-        (os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf"), 9),
-        (os.path.join(RESOURCE_ROOT, "crazyones.pdf"), 0),
+        (RESOURCE_ROOT / "pdflatex-outline.pdf", 9),
+        (RESOURCE_ROOT / "crazyones.pdf", 0),
     ],
 )
-def test_get_outlines(src, outline_elements):
+def test_get_outline(src, outline_elements):
     reader = PdfReader(src)
-    outlines = reader._get_outlines()
-    assert len(outlines) == outline_elements
+    outline = reader.outline
+    assert len(outline) == outline_elements
 
 
 @pytest.mark.parametrize(
@@ -160,7 +164,7 @@ def test_get_outlines(src, outline_elements):
     ],
 )
 def test_get_images(src, nb_images):
-    src = os.path.join(RESOURCE_ROOT, src)
+    src = RESOURCE_ROOT / src
     reader = PdfReader(src)
 
     with pytest.raises(TypeError):
@@ -191,24 +195,58 @@ def test_get_images(src, nb_images):
 
 
 @pytest.mark.parametrize(
-    ("strict", "with_prev_0", "startx_correction", "should_fail"),
+    ("strict", "with_prev_0", "startx_correction", "should_fail", "warning_msgs"),
     [
-        (True, False, -1, False),  # all nominal => no fail
-        (True, True, -1, True),  # Prev=0 => fail expected
-        (False, False, -1, False),
-        (False, True, -1, False),  # Prev =0 => no strict so tolerant
-        (True, False, 0, True),  # error on startxref, in strict => fail expected
-        (True, True, 0, True),
+        (
+            True,
+            False,
+            -1,
+            False,
+            [
+                "startxref on same line as offset",
+                "Xref table not zero-indexed. "
+                "ID numbers for objects will be corrected.",
+            ],
+        ),  # all nominal => no fail
+        (True, True, -1, True, ""),  # Prev=0 => fail expected
+        (
+            False,
+            False,
+            -1,
+            False,
+            ["startxref on same line as offset"],
+        ),
+        (
+            False,
+            True,
+            -1,
+            False,
+            [
+                "startxref on same line as offset",
+                "/Prev=0 in the trailer - assuming there is no previous xref table",
+            ],
+        ),  # Prev =0 => no strict so tolerant
+        (True, False, 0, True, ""),  # error on startxref, in strict => fail expected
+        (True, True, 0, True, ""),
         (
             False,
             False,
             0,
             False,
+            ["startxref on same line as offset", "incorrect startxref pointer(1)"],
         ),  # error on startxref, but no strict => xref rebuilt,no fail
-        (False, True, 0, False),
+        (
+            False,
+            True,
+            0,
+            False,
+            ["startxref on same line as offset", "incorrect startxref pointer(1)"],
+        ),
     ],
 )
-def test_get_images_raw(strict, with_prev_0, startx_correction, should_fail):
+def test_get_images_raw(
+    caplog, strict, with_prev_0, startx_correction, should_fail, warning_msgs
+):
     pdf_data = (
         b"%%PDF-1.7\n"
         b"1 0 obj << /Count 1 /Kids [4 0 R] /Type /Pages >> endobj\n"
@@ -236,7 +274,8 @@ def test_get_images_raw(strict, with_prev_0, startx_correction, should_fail):
         pdf_data.find(b"4 0 obj"),
         pdf_data.find(b"5 0 obj"),
         b"/Prev 0 " if with_prev_0 else b"",
-        # startx_correction should be -1 due to double % at the beginning indiducing an error on startxref computation
+        # startx_correction should be -1 due to double % at the beginning
+        # inducing an error on startxref computation
         pdf_data.find(b"xref") + startx_correction,
     )
     pdf_stream = io.BytesIO(pdf_data)
@@ -250,17 +289,18 @@ def test_get_images_raw(strict, with_prev_0, startx_correction, should_fail):
                 == "/Prev=0 in the trailer (try opening with strict=False)"
             )
     else:
-        with pytest.warns(PdfReadWarning):
-            PdfReader(pdf_stream, strict=strict)
+        PdfReader(pdf_stream, strict=strict)
+        assert normalize_warnings(caplog.text) == warning_msgs
 
 
-def test_issue297():
-    path = os.path.join(RESOURCE_ROOT, "issue-297.pdf")
-    with pytest.raises(PdfReadError) as exc, pytest.warns(PdfReadWarning):
+def test_issue297(caplog):
+    path = RESOURCE_ROOT / "issue-297.pdf"
+    with pytest.raises(PdfReadError) as exc:
         reader = PdfReader(path, strict=True)
+    assert caplog.text == ""
     assert "Broken xref table" in exc.value.args[0]
-    with pytest.warns(PdfReadWarning):
-        reader = PdfReader(path, strict=False)
+    reader = PdfReader(path, strict=False)
+    assert normalize_warnings(caplog.text) == ["incorrect startxref pointer(1)"]
     reader.pages[0]
 
 
@@ -268,6 +308,7 @@ def test_issue297():
     ("pdffile", "password", "should_fail"),
     [
         ("encrypted-file.pdf", "test", False),
+        ("encrypted-file.pdf", b"test", False),
         ("encrypted-file.pdf", "qwerty", True),
         ("encrypted-file.pdf", b"qwerty", True),
     ],
@@ -279,7 +320,7 @@ def test_get_page_of_encrypted_file(pdffile, password, should_fail):
     This is a regression test for issue 327:
     IndexError for get_page() of decrypted file
     """
-    path = os.path.join(RESOURCE_ROOT, pdffile)
+    path = RESOURCE_ROOT / pdffile
     if should_fail:
         with pytest.raises(PdfReadError):
             PdfReader(path, password=password)
@@ -314,7 +355,7 @@ def test_get_page_of_encrypted_file(pdffile, password, should_fail):
 )
 def test_get_form(src, expected, expected_get_fields):
     """Check if we can read out form data."""
-    src = os.path.join(RESOURCE_ROOT, src)
+    src = RESOURCE_ROOT / src
     reader = PdfReader(src)
     fields = reader.get_form_text_fields()
     assert fields == expected
@@ -350,7 +391,7 @@ def test_get_form(src, expected, expected_get_fields):
     ],
 )
 def test_get_page_number(src, page_nb):
-    src = os.path.join(RESOURCE_ROOT, src)
+    src = RESOURCE_ROOT / src
     reader = PdfReader(src)
     page = reader.pages[page_nb]
     assert reader.get_page_number(page) == page_nb
@@ -361,7 +402,7 @@ def test_get_page_number(src, page_nb):
     [("form.pdf", None), ("AutoCad_Simple.pdf", "/SinglePage")],
 )
 def test_get_page_layout(src, expected):
-    src = os.path.join(RESOURCE_ROOT, src)
+    src = RESOURCE_ROOT / src
     reader = PdfReader(src)
     assert reader.page_layout == expected
 
@@ -374,13 +415,13 @@ def test_get_page_layout(src, expected):
     ],
 )
 def test_get_page_mode(src, expected):
-    src = os.path.join(RESOURCE_ROOT, src)
+    src = RESOURCE_ROOT / src
     reader = PdfReader(src)
     assert reader.page_mode == expected
 
 
 def test_read_empty():
-    with pytest.raises(PdfReadError) as exc:
+    with pytest.raises(EmptyFileError) as exc:
         PdfReader(io.BytesIO())
     assert exc.value.args[0] == "Cannot read an empty file"
 
@@ -394,7 +435,9 @@ def test_read_malformed_header():
 def test_read_malformed_body():
     with pytest.raises(PdfReadError) as exc:
         PdfReader(io.BytesIO(b"%PDF-"), strict=True)
-    assert exc.value.args[0] == STREAM_TRUNCATED_PREMATURELY
+    assert (
+        exc.value.args[0] == "EOF marker not found"
+    )  # used to be:STREAM_TRUNCATED_PREMATURELY
 
 
 def test_read_prev_0_trailer():
@@ -469,7 +512,7 @@ def test_read_missing_startxref():
     assert exc.value.args[0] == "startxref not found"
 
 
-def test_read_unknown_zero_pages():
+def test_read_unknown_zero_pages(caplog):
     pdf_data = (
         b"%%PDF-1.7\n"
         b"1 0 obj << /Count 1 /Kids [4 0 R] /Type /Pages >> endobj\n"
@@ -500,34 +543,42 @@ def test_read_unknown_zero_pages():
         pdf_data.find(b"xref") - 1,
     )
     pdf_stream = io.BytesIO(pdf_data)
-    with pytest.warns(PdfReadWarning):
-        reader = PdfReader(pdf_stream, strict=True)
+    reader = PdfReader(pdf_stream, strict=True)
+    warnings = [
+        "startxref on same line as offset",
+        "Xref table not zero-indexed. ID numbers for objects will be corrected.",
+    ]
+    assert normalize_warnings(caplog.text) == warnings
     with pytest.raises(PdfReadError) as exc, pytest.warns(PdfReadWarning):
         len(reader.pages)
 
     assert exc.value.args[0] == "Could not find object."
-    with pytest.warns(PdfReadWarning):
-        reader = PdfReader(pdf_stream, strict=False)
+    reader = PdfReader(pdf_stream, strict=False)
+    warnings += [
+        "Object 5 1 not defined.",
+        "startxref on same line as offset",
+    ]
+    assert normalize_warnings(caplog.text) == warnings
     with pytest.raises(AttributeError) as exc, pytest.warns(PdfReadWarning):
         len(reader.pages)
     assert exc.value.args[0] == "'NoneType' object has no attribute 'get_object'"
 
 
 def test_read_encrypted_without_decryption():
-    src = os.path.join(RESOURCE_ROOT, "libreoffice-writer-password.pdf")
+    src = RESOURCE_ROOT / "libreoffice-writer-password.pdf"
     reader = PdfReader(src)
-    with pytest.raises(PdfReadError) as exc:
+    with pytest.raises(FileNotDecryptedError) as exc:
         len(reader.pages)
     assert exc.value.args[0] == "File has not been decrypted"
 
 
 def test_get_destination_page_number():
-    src = os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf")
+    src = RESOURCE_ROOT / "pdflatex-outline.pdf"
     reader = PdfReader(src)
-    outlines = reader._get_outlines()
-    for outline in outlines:
-        if not isinstance(outline, list):
-            reader.get_destination_page_number(outline)
+    outline = reader.outline
+    for outline_item in outline:
+        if not isinstance(outline_item, list):
+            reader.get_destination_page_number(outline_item)
 
 
 def test_do_not_get_stuck_on_large_files_without_start_xref():
@@ -550,17 +601,15 @@ def test_decrypt_when_no_id():
     https://github.com/mstamy2/PyPDF2/issues/608
     """
 
-    with open(
-        os.path.join(RESOURCE_ROOT, "encrypted_doc_no_id.pdf"), "rb"
-    ) as inputfile:
+    with open(RESOURCE_ROOT / "encrypted_doc_no_id.pdf", "rb") as inputfile:
         ipdf = PdfReader(inputfile)
         ipdf.decrypt("")
         assert ipdf.metadata == {"/Producer": "European Patent Office"}
 
 
 def test_reader_properties():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "crazyones.pdf"))
-    assert reader.outlines == []
+    reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf")
+    assert reader.outline == []
     assert len(reader.pages) == 1
     assert reader.page_layout is None
     assert reader.page_mode is None
@@ -569,24 +618,27 @@ def test_reader_properties():
 
 @pytest.mark.parametrize(
     "strict",
-    [(True), (False)],
+    [True, False],
 )
-def test_issue604(strict):
+def test_issue604(caplog, strict):
     """Test with invalid destinations"""  # todo
-    with open(os.path.join(RESOURCE_ROOT, "issue-604.pdf"), "rb") as f:
+    with open(RESOURCE_ROOT / "issue-604.pdf", "rb") as f:
         pdf = None
-        bookmarks = None
+        outline = None
         if strict:
             pdf = PdfReader(f, strict=strict)
             with pytest.raises(PdfReadError) as exc, pytest.warns(PdfReadWarning):
-                bookmarks = pdf._get_outlines()
+                outline = pdf.outline
             if "Unknown Destination" not in exc.value.args[0]:
                 raise Exception("Expected exception not raised")
-            return  # bookmarks not correct
+            return  # outline is not correct
         else:
             pdf = PdfReader(f, strict=strict)
-            with pytest.warns(PdfReadWarning):
-                bookmarks = pdf._get_outlines()
+            outline = pdf.outline
+            msg = [
+                "Unknown destination: ms_Thyroid_2_2020_071520_watermarked.pdf [0, 1]"
+            ]
+            assert normalize_warnings(caplog.text) == msg
 
         def get_dest_pages(x):
             if isinstance(x, list):
@@ -596,14 +648,14 @@ def get_dest_pages(x):
                 return pdf.get_destination_page_number(x) + 1
 
         out = []
-        for (
-            b
-        ) in bookmarks:  # b can be destination or a list:preferred to just print them
-            out.append(get_dest_pages(b))
+
+        # oi can be destination or a list:preferred to just print them
+        for oi in outline:
+            out.append(get_dest_pages(oi))
 
 
 def test_decode_permissions():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "crazyones.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf")
     base = {
         "accessability": False,
         "annotations": False,
@@ -625,7 +677,7 @@ def test_decode_permissions():
 
 
 def test_pages_attribute():
-    pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
     reader = PdfReader(pdf_path)
 
     # Test if getting as slice throws an error
@@ -679,7 +731,7 @@ def test_iss925():
 
 @pytest.mark.xfail(reason="#591")
 def test_extract_text_hello_world():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "hello-world.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "hello-world.pdf")
     text = reader.pages[0].extract_text().split("\n")
     assert text == [
         "English:",
@@ -703,13 +755,12 @@ def test_read_path():
     assert len(reader.pages) == 1
 
 
-def test_read_not_binary_mode():
-    with open(os.path.join(RESOURCE_ROOT, "crazyones.pdf")) as f:
+def test_read_not_binary_mode(caplog):
+    with open(RESOURCE_ROOT / "crazyones.pdf") as f:
         msg = "PdfReader stream/file object is not in binary mode. It may not be read correctly."
-        with pytest.warns(PdfReadWarning, match=msg), pytest.raises(
-            io.UnsupportedOperation
-        ):
+        with pytest.raises(io.UnsupportedOperation):
             PdfReader(f)
+    assert normalize_warnings(caplog.text) == [msg]
 
 
 @pytest.mark.skipif(not HAS_PYCRYPTODOME, reason="No pycryptodome")
@@ -722,24 +773,24 @@ def test_read_form_416():
     assert len(fields) > 0
 
 
-def test_extract_text_xref_issue_2():
+def test_extract_text_xref_issue_2(caplog):
     # pdf/0264cf510015b2a4b395a15cb23c001e.pdf
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/981/981961.pdf"
-    msg = r"incorrect startxref pointer\(2\)"
-    with pytest.warns(PdfReadWarning, match=msg):
-        reader = PdfReader(BytesIO(get_pdf_from_url(url, name="tika-981961.pdf")))
+    msg = "incorrect startxref pointer(2)"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name="tika-981961.pdf")))
     for page in reader.pages:
         page.extract_text()
+    assert normalize_warnings(caplog.text) == [msg]
 
 
-def test_extract_text_xref_issue_3():
+def test_extract_text_xref_issue_3(caplog):
     # pdf/0264cf510015b2a4b395a15cb23c001e.pdf
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/977/977774.pdf"
-    msg = r"incorrect startxref pointer\(3\)"
-    with pytest.warns(PdfReadWarning, match=msg):
-        reader = PdfReader(BytesIO(get_pdf_from_url(url, name="tika-977774.pdf")))
+    msg = "incorrect startxref pointer(3)"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name="tika-977774.pdf")))
     for page in reader.pages:
         page.extract_text()
+    assert normalize_warnings(caplog.text) == [msg]
 
 
 def test_extract_text_pdf15():
@@ -768,12 +819,12 @@ def test_get_fields():
     assert dict(fields["c1-1"]) == ({"/FT": "/Btn", "/T": "c1-1"})
 
 
+# covers also issue 1089
+@pytest.mark.filterwarnings("ignore::PyPDF2.errors.PdfReadWarning")
 def test_get_fields_read_else_block():
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/934/934771.pdf"
     name = "tika-934771.pdf"
-    with pytest.raises(PdfReadError) as exc:
-        PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
-    assert exc.value.args[0] == "Could not find xref table at specified location"
+    PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
 
 
 def test_get_fields_read_else_block2():
@@ -784,12 +835,11 @@ def test_get_fields_read_else_block2():
     assert fields is None
 
 
+@pytest.mark.filterwarnings("ignore::PyPDF2.errors.PdfReadWarning")
 def test_get_fields_read_else_block3():
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/957/957721.pdf"
     name = "tika-957721.pdf"
-    with pytest.raises(PdfReadError) as exc:
-        PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
-    assert exc.value.args[0] == "Could not find xref table at specified location"
+    PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
 
 
 def test_metadata_is_none():
@@ -811,21 +861,11 @@ def test_get_fields_read_write_report():
     os.remove("tmp-fields-report.txt")
 
 
-def test_unexpected_destination():
-    url = "https://corpora.tika.apache.org/base/docs/govdocs1/913/913678.pdf"
-    name = "tika-913678.pdf"
-    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
-    merger = PdfMerger()
-    with pytest.raises(PdfReadError) as exc:
-        merger.append(reader)
-    assert exc.value.args[0] == "Unexpected destination '/1'"
-
-
 @pytest.mark.parametrize(
     "src",
     [
-        (os.path.join(RESOURCE_ROOT, "crazyones.pdf")),
-        (os.path.join(RESOURCE_ROOT, "commented.pdf")),
+        RESOURCE_ROOT / "crazyones.pdf",
+        RESOURCE_ROOT / "commented.pdf",
     ],
 )
 def test_xfa(src):
@@ -850,11 +890,207 @@ def test_xfa_non_empty():
 @pytest.mark.parametrize(
     "src,pdf_header",
     [
-        (os.path.join(RESOURCE_ROOT, "attachment.pdf"), "%PDF-1.5"),
-        (os.path.join(RESOURCE_ROOT, "crazyones.pdf"), "%PDF-1.5"),
+        (RESOURCE_ROOT / "attachment.pdf", "%PDF-1.5"),
+        (RESOURCE_ROOT / "crazyones.pdf", "%PDF-1.5"),
     ],
 )
 def test_header(src, pdf_header):
     reader = PdfReader(src)
 
     assert reader.pdf_header == pdf_header
+
+
+def test_outline_color():
+    url = "https://corpora.tika.apache.org/base/docs/govdocs1/924/924546.pdf"
+    name = "tika-924546.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    assert reader.outline[0].color == [0, 0, 1]
+
+
+def test_outline_font_format():
+    url = "https://corpora.tika.apache.org/base/docs/govdocs1/924/924546.pdf"
+    name = "tika-924546.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    assert reader.outline[0].font_format == 2
+
+
+def get_outline_property(outline, attribute_name: str):
+    results = []
+    if isinstance(outline, list):
+        for outline_item in outline:
+            if isinstance(outline_item, Destination):
+                results.append(getattr(outline_item, attribute_name))
+            else:
+                results.append(get_outline_property(outline_item, attribute_name))
+    else:
+        raise ValueError(f"got {type(outline)}")
+    return results
+
+
+def test_outline_title_issue_1121():
+    reader = PdfReader(EXTERNAL_ROOT / "014-outlines/mistitled_outlines_example.pdf")
+
+    assert get_outline_property(reader.outline, "title") == [
+        "First",
+        [
+            "Second",
+            "Third",
+            "Fourth",
+            [
+                "Fifth",
+                "Sixth",
+            ],
+            "Seventh",
+            [
+                "Eighth",
+                "Ninth",
+            ],
+        ],
+        "Tenth",
+        [
+            "Eleventh",
+            "Twelfth",
+            "Thirteenth",
+            "Fourteenth",
+        ],
+        "Fifteenth",
+        [
+            "Sixteenth",
+            "Seventeenth",
+        ],
+        "Eighteenth",
+        "Nineteenth",
+        [
+            "Twentieth",
+            "Twenty-first",
+            "Twenty-second",
+            "Twenty-third",
+            "Twenty-fourth",
+            "Twenty-fifth",
+            "Twenty-sixth",
+            "Twenty-seventh",
+        ],
+    ]
+
+
+def test_outline_count():
+    reader = PdfReader(EXTERNAL_ROOT / "014-outlines/mistitled_outlines_example.pdf")
+
+    assert get_outline_property(reader.outline, "outline_count") == [
+        5,
+        [
+            None,
+            None,
+            2,
+            [
+                None,
+                None,
+            ],
+            -2,
+            [
+                None,
+                None,
+            ],
+        ],
+        4,
+        [
+            None,
+            None,
+            None,
+            None,
+        ],
+        -2,
+        [
+            None,
+            None,
+        ],
+        None,
+        8,
+        [
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+            None,
+        ],
+    ]
+
+
+def test_outline_missing_title():
+    # Strict
+    reader = PdfReader(RESOURCE_ROOT / "outline-without-title.pdf", strict=True)
+    with pytest.raises(PdfReadError) as exc:
+        reader.outline
+    assert exc.value.args[0].startswith("Outline Entry Missing /Title attribute:")
+
+    # Non-strict
+    with pytest.raises(ValueError) as exc:
+        reader = PdfReader(RESOURCE_ROOT / "outline-without-title.pdf", strict=False)
+        reader.outline
+    assert exc.value.args[0] == "value must be PdfObject"
+
+
+def test_named_destination():
+    # 1st case : the named_dest are stored directly as a dictionnary, PDF1.1 style
+    url = "https://github.com/py-pdf/PyPDF2/files/9197028/lorem_ipsum.pdf"
+    name = "lorem_ipsum.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    assert len(reader.named_destinations) > 0
+    # 2nd case : Dest below names and with Kids...
+    url = "https://opensource.adobe.com/dc-acrobat-sdk-docs/standards/pdfstandards/pdf/PDF32000_2008.pdf"
+    name = "PDF32000_2008.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    assert len(reader.named_destinations) > 0
+    # 3nd case : Dests with Name tree
+    # TODO : case to be added
+
+
+def test_outline_with_missing_named_destination():
+    url = "https://corpora.tika.apache.org/base/docs/govdocs1/913/913678.pdf"
+    name = "tika-913678.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    # outline items in document reference a named destination that is not defined
+    assert reader.outline[1][0].title.startswith("Report for 2002AZ3B: Microbial")
+
+
+def test_outline_with_empty_action():
+    url = "https://corpora.tika.apache.org/base/docs/govdocs1/924/924546.pdf"
+    name = "tika-924546.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    # outline items (entitled Tables and Figures) utilize an empty action (/A)
+    # that has no type or destination
+    assert reader.outline[-4].title == "Tables"
+
+
+def test_outline_with_invalid_destinations():
+    reader = PdfReader(RESOURCE_ROOT / "outlines-with-invalid-destinations.pdf")
+    # contains 9 outline items, 6 with invalid destinations caused by different malformations
+    assert len(reader.outline) == 9
+
+
+def test_PdfReaderMultipleDefinitions(caplog):
+    # iss325
+    url = "https://github.com/py-pdf/PyPDF2/files/9176644/multipledefs.pdf"
+    name = "multipledefs.pdf"
+    reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
+    reader.pages[0].extract_text()
+    assert normalize_warnings(caplog.text) == [
+        "Multiple definitions in dictionary at byte 0xb5 for key /Group"
+    ]
+
+
+def test_wrong_password_error():
+    encrypted_pdf_path = RESOURCE_ROOT / "encrypted-file.pdf"
+    with pytest.raises(WrongPasswordError):
+        PdfReader(
+            encrypted_pdf_path,
+            password="definitely_the_wrong_password!",
+        )
+
+
+def test_get_page_number_by_indirect():
+    reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf")
+    reader._get_page_number_by_indirect(1)
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 27ff35712..10a6a19fc 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -1,11 +1,13 @@
 import io
 import os
+from pathlib import Path
 
 import pytest
 
 import PyPDF2._utils
 from PyPDF2._utils import (
     _get_max_pdf_version_header,
+    deprecate_bookmark,
     mark_location,
     matrix_multiply,
     read_block_backwards,
@@ -15,11 +17,11 @@
     skip_over_comment,
     skip_over_whitespace,
 )
-from PyPDF2.errors import PdfStreamError
+from PyPDF2.errors import PdfReadError, PdfStreamError
 
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
 
 
 @pytest.mark.parametrize(
@@ -219,3 +221,25 @@ def test_get_max_pdf_version_header():
     with pytest.raises(ValueError) as exc:
         _get_max_pdf_version_header(b"", b"PDF-1.2")
     assert exc.value.args[0] == "neither b'' nor b'PDF-1.2' are proper headers"
+
+
+def test_read_block_backwards_exception():
+    stream = io.BytesIO(b"foobar")
+    stream.seek(6)
+    with pytest.raises(PdfReadError) as exc:
+        read_block_backwards(stream, 7)
+    assert exc.value.args[0] == "Could not read malformed PDF file"
+
+
+def test_deprecate_bookmark():
+    @deprecate_bookmark(old_param="new_param")
+    def foo(old_param=1, baz=2):
+        return old_param * baz
+
+    with pytest.raises(TypeError) as exc:
+        foo(old_param=12, new_param=13)
+    expected_msg = (
+        "foo received both old_param and new_param as an argument. "
+        "old_param is deprecated. Use new_param instead."
+    )
+    assert exc.value.args[0] == expected_msg
diff --git a/tests/test_workflows.py b/tests/test_workflows.py
index aaf99ac65..0a7b1efb8 100644
--- a/tests/test_workflows.py
+++ b/tests/test_workflows.py
@@ -1,8 +1,15 @@
+"""
+Tests in this module behave like user code.
+
+They don't mock/patch anything, they cover typical user needs.
+"""
+
 import binascii
 import os
 import sys
 from io import BytesIO
 from pathlib import Path
+from re import findall
 
 import pytest
 
@@ -13,13 +20,64 @@
 from PyPDF2.errors import PdfReadError, PdfReadWarning
 from PyPDF2.filters import _xobj_to_image
 
-from . import get_pdf_from_url
+from . import get_pdf_from_url, normalize_warnings
+
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
+
+sys.path.append(str(PROJECT_ROOT))
+
+
+def test_basic_features(tmp_path):
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
+    reader = PdfReader(pdf_path)
+    writer = PdfWriter()
+
+    assert len(reader.pages) == 1
+
+    # add page 1 from input1 to output document, unchanged
+    writer.add_page(reader.pages[0])
+
+    # add page 2 from input1, but rotated clockwise 90 degrees
+    writer.add_page(reader.pages[0].rotate(90))
+
+    # add page 3 from input1, but first add a watermark from another PDF:
+    page3 = reader.pages[0]
+    watermark_pdf = pdf_path
+    watermark = PdfReader(watermark_pdf)
+    page3.merge_page(watermark.pages[0])
+    writer.add_page(page3)
+
+    # add page 4 from input1, but crop it to half size:
+    page4 = reader.pages[0]
+    page4.mediabox.upper_right = (
+        page4.mediabox.right / 2,
+        page4.mediabox.top / 2,
+    )
+    del page4.mediabox
+    writer.add_page(page4)
+
+    # add some Javascript to launch the print window on opening this PDF.
+    # the password dialog may prevent the print dialog from being shown,
+    # comment the the encription lines, if that's the case, to try this out
+    writer.add_js("this.print({bUI:true,bSilent:false,bShrinkToFit:true});")
+
+    # encrypt your new PDF and add a password
+    password = "secret"
+    writer.encrypt(password)
+
+    # finally, write "output" to PyPDF2-output.pdf
+    write_path = tmp_path / "PyPDF2-output.pdf"
+    with open(write_path, "wb") as output_stream:
+        writer.write(output_stream)
 
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
 
-sys.path.append(PROJECT_ROOT)
+def test_dropdown_items():
+    inputfile = RESOURCE_ROOT / "libreoffice-form.pdf"
+    reader = PdfReader(inputfile)
+    fields = reader.get_fields()
+    assert "/Opt" in fields["Nationality"].keys()
 
 
 def test_PdfReaderFileLoad():
@@ -28,16 +86,16 @@ def test_PdfReaderFileLoad():
     textual output. Expected outcome: file loads, text matches expected.
     """
 
-    with open(os.path.join(RESOURCE_ROOT, "crazyones.pdf"), "rb") as inputfile:
+    with open(RESOURCE_ROOT / "crazyones.pdf", "rb") as inputfile:
         # Load PDF file from file
         reader = PdfReader(inputfile)
         page = reader.pages[0]
 
         # Retrieve the text of the PDF
-        with open(os.path.join(RESOURCE_ROOT, "crazyones.txt"), "rb") as pdftext_file:
+        with open(RESOURCE_ROOT / "crazyones.txt", "rb") as pdftext_file:
             pdftext = pdftext_file.read()
 
-        text = page.extract_text(Tj_sep="", TJ_sep="").encode("utf-8")
+        text = page.extract_text().encode("utf-8")
 
         # Compare the text of the PDF to a known source
         for expected_line, actual_line in zip(text.split(b"\n"), pdftext.split(b"\n")):
@@ -55,12 +113,12 @@ def test_PdfReaderJpegImage():
     textual output. Expected outcome: file loads, image matches expected.
     """
 
-    with open(os.path.join(RESOURCE_ROOT, "jpeg.pdf"), "rb") as inputfile:
+    with open(RESOURCE_ROOT / "jpeg.pdf", "rb") as inputfile:
         # Load PDF file from file
         reader = PdfReader(inputfile)
 
         # Retrieve the text of the image
-        with open(os.path.join(RESOURCE_ROOT, "jpeg.txt")) as pdftext_file:
+        with open(RESOURCE_ROOT / "jpeg.txt") as pdftext_file:
             imagetext = pdftext_file.read()
 
         page = reader.pages[0]
@@ -75,9 +133,7 @@ def test_PdfReaderJpegImage():
 
 
 def test_decrypt():
-    with open(
-        os.path.join(RESOURCE_ROOT, "libreoffice-writer-password.pdf"), "rb"
-    ) as inputfile:
+    with open(RESOURCE_ROOT / "libreoffice-writer-password.pdf", "rb") as inputfile:
         reader = PdfReader(inputfile)
         assert reader.is_encrypted is True
         reader.decrypt("openpassword")
@@ -92,7 +148,7 @@ def test_decrypt():
 
 
 def test_text_extraction_encrypted():
-    inputfile = os.path.join(RESOURCE_ROOT, "libreoffice-writer-password.pdf")
+    inputfile = RESOURCE_ROOT / "libreoffice-writer-password.pdf"
     reader = PdfReader(inputfile)
     assert reader.is_encrypted is True
     reader.decrypt("openpassword")
@@ -107,14 +163,14 @@ def test_text_extraction_encrypted():
 
 @pytest.mark.parametrize("degree", [0, 90, 180, 270, 360, -90])
 def test_rotate(degree):
-    with open(os.path.join(RESOURCE_ROOT, "crazyones.pdf"), "rb") as inputfile:
+    with open(RESOURCE_ROOT / "crazyones.pdf", "rb") as inputfile:
         reader = PdfReader(inputfile)
         page = reader.pages[0]
         page.rotate(degree)
 
 
 def test_rotate_45():
-    with open(os.path.join(RESOURCE_ROOT, "crazyones.pdf"), "rb") as inputfile:
+    with open(RESOURCE_ROOT / "crazyones.pdf", "rb") as inputfile:
         reader = PdfReader(inputfile)
         page = reader.pages[0]
         with pytest.raises(ValueError) as exc:
@@ -142,6 +198,14 @@ def test_rotate_45():
         (True, "https://arxiv.org/pdf/2201.00200.pdf", [0, 1, 5, 6]),
         (True, "https://arxiv.org/pdf/2201.00022.pdf", [0, 1, 5, 10]),
         (True, "https://arxiv.org/pdf/2201.00029.pdf", [0, 1, 6, 10]),
+        # #1145
+        (True, "https://github.com/py-pdf/PyPDF2/files/9174594/2017.pdf", [0]),
+        # #1145, remaining issue (empty arguments for FlateEncoding)
+        (
+            True,
+            "https://github.com/py-pdf/PyPDF2/files/9175966/2015._pb_decode_pg0.pdf",
+            [0],
+        ),
         # 6 instead of 5: as there is an issue in page 5 (missing objects)
         # and too complex to handle the warning without hiding real regressions
         (True, "https://arxiv.org/pdf/1601.03642.pdf", [0, 1, 5, 7]),
@@ -158,7 +222,7 @@ def test_rotate_45():
         (True, "https://github.com/py-pdf/PyPDF2/files/8884469/999092.pdf", [0, 1]),
         (
             True,
-            "file://" + os.path.join(RESOURCE_ROOT, "test Orient.pdf"),
+            "file://" + str(RESOURCE_ROOT / "test Orient.pdf"),
             [0],
         ),  # TODO: preparation of text orientation validation
         (
@@ -194,6 +258,82 @@ def test_extract_textbench(enable, url, pages, print_result=False):
         pass
 
 
+def test_orientations():
+    p = PdfReader(RESOURCE_ROOT / "test Orient.pdf").pages[0]
+    try:
+        p.extract_text("", "")
+    except DeprecationWarning:
+        pass
+    else:
+        raise Exception("DeprecationWarning expected")
+    try:
+        p.extract_text("", "", 0)
+    except DeprecationWarning:
+        pass
+    else:
+        raise Exception("DeprecationWarning expected")
+    try:
+        p.extract_text("", "", 0, 200)
+    except DeprecationWarning:
+        pass
+    else:
+        raise Exception("DeprecationWarning expected")
+
+    try:
+        p.extract_text(Tj_sep="", TJ_sep="")
+    except DeprecationWarning:
+        pass
+    else:
+        raise Exception("DeprecationWarning expected")
+    assert findall("\\((.)\\)", p.extract_text()) == ["T", "B", "L", "R"]
+    try:
+        p.extract_text(None)
+    except Exception:
+        pass
+    else:
+        raise Exception("Argument 1 check invalid")
+    try:
+        p.extract_text("", 0)
+    except Exception:
+        pass
+    else:
+        raise Exception("Argument 2 check invalid")
+    try:
+        p.extract_text("", "", None)
+    except Exception:
+        pass
+    else:
+        raise Exception("Argument 3 check invalid")
+    try:
+        p.extract_text("", "", 0, "")
+    except Exception:
+        pass
+    else:
+        raise Exception("Argument 4 check invalid")
+    try:
+        p.extract_text(0, "")
+    except Exception:
+        pass
+    else:
+        raise Exception("Argument 1 new syntax check invalid")
+
+    p.extract_text(0, 0)
+    p.extract_text(orientations=0)
+
+    for (req, rst) in (
+        (0, ["T"]),
+        (90, ["L"]),
+        (180, ["B"]),
+        (270, ["R"]),
+        ((0,), ["T"]),
+        ((0, 180), ["T", "B"]),
+        ((45,), []),
+    ):
+        assert (
+            findall("\\((.)\\)", p.extract_text(req)) == rst
+        ), f"extract_text({req}) => {rst}"
+
+
 @pytest.mark.parametrize(
     ("base_path", "overlay_path"),
     [
@@ -211,11 +351,11 @@ def test_overlay(base_path, overlay_path):
     if base_path.startswith("http"):
         base_path = BytesIO(get_pdf_from_url(base_path, name="tika-935981.pdf"))
     else:
-        base_path = os.path.join(PROJECT_ROOT, base_path)
+        base_path = PROJECT_ROOT / base_path
     reader = PdfReader(base_path)
     writer = PdfWriter()
 
-    reader_overlay = PdfReader(os.path.join(PROJECT_ROOT, overlay_path))
+    reader_overlay = PdfReader(PROJECT_ROOT / overlay_path)
     overlay = reader_overlay.pages[0]
 
     for page in reader.pages:
@@ -225,7 +365,7 @@ def test_overlay(base_path, overlay_path):
         writer.write(fp)
 
     # Cleanup
-    os.remove("dont_commit_overlay.pdf")
+    os.remove("dont_commit_overlay.pdf")  # remove for manual inspection
 
 
 @pytest.mark.parametrize(
@@ -237,16 +377,13 @@ def test_overlay(base_path, overlay_path):
         )
     ],
 )
-def test_merge_with_warning(url, name):
+def test_merge_with_warning(tmp_path, url, name):
     data = BytesIO(get_pdf_from_url(url, name=name))
     reader = PdfReader(data)
     merger = PdfMerger()
     merger.append(reader)
     # This could actually be a performance bottleneck:
-    merger.write("tmp.merged.pdf")
-
-    # Cleanup
-    os.remove("tmp.merged.pdf")
+    merger.write(tmp_path / "tmp.merged.pdf")
 
 
 @pytest.mark.parametrize(
@@ -258,15 +395,12 @@ def test_merge_with_warning(url, name):
         )
     ],
 )
-def test_merge(url, name):
+def test_merge(tmp_path, url, name):
     data = BytesIO(get_pdf_from_url(url, name=name))
     reader = PdfReader(data)
     merger = PdfMerger()
     merger.append(reader)
-    merger.write("tmp.merged.pdf")
-
-    # Cleanup
-    os.remove("tmp.merged.pdf")
+    merger.write(tmp_path / "tmp.merged.pdf")
 
 
 @pytest.mark.parametrize(
@@ -285,18 +419,88 @@ def test_get_metadata(url, name):
 
 
 @pytest.mark.parametrize(
-    ("url", "name"),
+    ("url", "name", "strict", "exception"),
     [
         (
             "https://corpora.tika.apache.org/base/docs/govdocs1/938/938702.pdf",
             "tika-938702.pdf",
-        )
+            False,
+            (PdfReadError, "Unexpected end of stream"),
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/942/942358.pdf",
+            "tika-942358.pdf",
+            False,
+            None,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/911/911260.pdf",
+            "tika-911260.pdf",
+            False,
+            None,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/992/992472.pdf",
+            "tika-992472.pdf",
+            False,
+            None,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/978/978477.pdf",
+            "tika-978477.pdf",
+            False,
+            None,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/960/960317.pdf",
+            "tika-960317.pdf",
+            False,
+            None,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/930/930513.pdf",
+            "tika-930513.pdf",
+            False,
+            None,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/918/918113.pdf",
+            "tika-918113.pdf",
+            True,
+            None,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/940/940704.pdf",
+            "tika-940704.pdf",
+            True,
+            None,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/976/976488.pdf",
+            "tika-976488.pdf",
+            True,
+            None,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/948/948176.pdf",
+            "tika-948176.pdf",
+            True,
+            None,
+        ),
     ],
 )
-def test_extract_text(url, name):
+def test_extract_text(url, name, strict, exception):
     data = BytesIO(get_pdf_from_url(url, name=name))
-    reader = PdfReader(data)
-    reader.metadata
+    reader = PdfReader(data, strict=strict)
+    if not exception:
+        for page in reader.pages:
+            page.extract_text()
+    else:
+        exc, exc_text = exception
+        with pytest.raises(exc) as ex_info:
+            for page in reader.pages:
+                page.extract_text()
+        assert ex_info.value.args[0] == exc_text
 
 
 @pytest.mark.parametrize(
@@ -305,10 +509,14 @@ def test_extract_text(url, name):
         (
             "https://corpora.tika.apache.org/base/docs/govdocs1/938/938702.pdf",
             "tika-938702.pdf",
-        )
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/957/957304.pdf",
+            "tika-938702.pdf",
+        ),
     ],
 )
-def test_compress(url, name):
+def test_compress_raised(url, name):
     data = BytesIO(get_pdf_from_url(url, name=name))
     reader = PdfReader(data)
     # TODO: which page exactly?
@@ -319,19 +527,72 @@ def test_compress(url, name):
     assert exc.value.args[0] == "Unexpected end of stream"
 
 
-def test_get_fields():
-    url = "https://corpora.tika.apache.org/base/docs/govdocs1/961/961883.pdf"
-    name = "tika-961883.pdf"
+@pytest.mark.parametrize(
+    ("url", "name", "strict"),
+    [
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/915/915194.pdf",
+            "tika-915194.pdf",
+            False,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/950/950337.pdf",
+            "tika-950337.pdf",
+            False,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/962/962292.pdf",
+            "tika-962292.pdf",
+            True,
+        ),
+    ],
+)
+def test_compress(url, name, strict):
+    data = BytesIO(get_pdf_from_url(url, name=name))
+    reader = PdfReader(data, strict=strict)
+    # TODO: which page exactly?
+    # TODO: Is it reasonable to have an exception here?
+    for page in reader.pages:
+        page.compress_content_streams()
+
+
+@pytest.mark.parametrize(
+    ("url", "name"),
+    [
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/961/961883.pdf",
+            "tika-961883.pdf",
+        ),
+    ],
+)
+def test_get_fields_warns(tmp_path, caplog, url, name):
     data = BytesIO(get_pdf_from_url(url, name=name))
     reader = PdfReader(data)
-    with open("tmp.txt", "w") as fp:
-        with pytest.warns(PdfReadWarning, match="Object 2 0 not defined."):
-            retrieved_fields = reader.get_fields(fileobj=fp)
+    write_path = tmp_path / "tmp.txt"
+    with open(write_path, "w") as fp:
+        retrieved_fields = reader.get_fields(fileobj=fp)
 
     assert retrieved_fields == {}
+    assert normalize_warnings(caplog.text) == ["Object 2 0 not defined."]
 
-    # Cleanup
-    os.remove("tmp.txt")
+
+@pytest.mark.parametrize(
+    ("url", "name"),
+    [
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/942/942050.pdf",
+            "tika-942050.pdf",
+        ),
+    ],
+)
+def test_get_fields_no_warning(tmp_path, url, name):
+    data = BytesIO(get_pdf_from_url(url, name=name))
+    reader = PdfReader(data)
+    write_path = tmp_path / "tmp.txt"
+    with open(write_path, "w") as fp:
+        retrieved_fields = reader.get_fields(fileobj=fp)
+
+    assert len(retrieved_fields) == 10
 
 
 def test_scale_rectangle_indirect_object():
@@ -344,18 +605,17 @@ def test_scale_rectangle_indirect_object():
         page.scale(sx=2, sy=3)
 
 
-def test_merge_output():
+def test_merge_output(caplog):
     # Arrange
-    base = os.path.join(RESOURCE_ROOT, "Seige_of_Vicksburg_Sample_OCR.pdf")
-    crazy = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
-    expected = os.path.join(
-        RESOURCE_ROOT, "Seige_of_Vicksburg_Sample_OCR-crazyones-merged.pdf"
-    )
+    base = RESOURCE_ROOT / "Seige_of_Vicksburg_Sample_OCR.pdf"
+    crazy = RESOURCE_ROOT / "crazyones.pdf"
+    expected = RESOURCE_ROOT / "Seige_of_Vicksburg_Sample_OCR-crazyones-merged.pdf"
 
     # Act
     merger = PdfMerger(strict=True)
-    with pytest.warns(PdfReadWarning):
-        merger.append(base)
+    merger.append(base)
+    msg = "Xref table not zero-indexed. ID numbers for objects will be corrected."
+    assert normalize_warnings(caplog.text) == [msg]
     merger.merge(1, crazy)
     stream = BytesIO()
     merger.write(stream)
@@ -402,6 +662,22 @@ def test_merge_output():
             "https://corpora.tika.apache.org/base/docs/govdocs1/959/959184.pdf",
             "tika-959184.pdf",
         ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/958/958496.pdf",
+            "tika-958496.pdf",
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/972/972174.pdf",
+            "tika-972174.pdf",
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/972/972243.pdf",
+            "tika-972243.pdf",
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/969/969502.pdf",
+            "tika-969502.pdf",
+        ),
     ],
 )
 def test_image_extraction(url, name):
@@ -432,3 +708,161 @@ def test_image_extraction(url, name):
         for filepath in images_extracted:
             if os.path.exists(filepath):
                 os.remove(filepath)
+
+
+def test_image_extraction_strict():
+    # Emits log messages
+    url = "https://corpora.tika.apache.org/base/docs/govdocs1/914/914102.pdf"
+    name = "tika-914102.pdf"
+    data = BytesIO(get_pdf_from_url(url, name=name))
+    reader = PdfReader(data, strict=True)
+
+    images_extracted = []
+    root = Path("extracted-images")
+    if not root.exists():
+        os.mkdir(root)
+
+    for page in reader.pages:
+        if RES.XOBJECT in page[PG.RESOURCES]:
+            x_object = page[PG.RESOURCES][RES.XOBJECT].get_object()
+
+            for obj in x_object:
+                if x_object[obj][IA.SUBTYPE] == "/Image":
+                    extension, byte_stream = _xobj_to_image(x_object[obj])
+                    if extension is not None:
+                        filename = root / (obj[1:] + extension)
+                        with open(filename, "wb") as img:
+                            img.write(byte_stream)
+                        images_extracted.append(filename)
+
+    # Cleanup
+    do_cleanup = True  # set this to False for manual inspection
+    if do_cleanup:
+        for filepath in images_extracted:
+            if os.path.exists(filepath):
+                os.remove(filepath)
+
+
+@pytest.mark.parametrize(
+    ("url", "name"),
+    [
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/977/977609.pdf",
+            "tika-977609.pdf",
+        ),
+    ],
+)
+def test_image_extraction2(url, name):
+    data = BytesIO(get_pdf_from_url(url, name=name))
+    reader = PdfReader(data)
+
+    images_extracted = []
+    root = Path("extracted-images")
+    if not root.exists():
+        os.mkdir(root)
+
+    for page in reader.pages:
+        if RES.XOBJECT in page[PG.RESOURCES]:
+            x_object = page[PG.RESOURCES][RES.XOBJECT].get_object()
+
+            for obj in x_object:
+                if x_object[obj][IA.SUBTYPE] == "/Image":
+                    extension, byte_stream = _xobj_to_image(x_object[obj])
+                    if extension is not None:
+                        filename = root / (obj[1:] + extension)
+                        with open(filename, "wb") as img:
+                            img.write(byte_stream)
+                        images_extracted.append(filename)
+
+    # Cleanup
+    do_cleanup = True  # set this to False for manual inspection
+    if do_cleanup:
+        for filepath in images_extracted:
+            if os.path.exists(filepath):
+                os.remove(filepath)
+
+
+@pytest.mark.parametrize(
+    ("url", "name"),
+    [
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/918/918137.pdf",
+            "tika-918137.pdf",
+        ),
+        (
+            "https://unglueit-files.s3.amazonaws.com/ebf/7552c42e9280b4476e59e77acc0bc812.pdf",
+            "7552c42e9280b4476e59e77acc0bc812.pdf",
+        ),
+    ],
+)
+def test_get_outline(url, name):
+    data = BytesIO(get_pdf_from_url(url, name=name))
+    reader = PdfReader(data)
+    reader.outline
+
+
+@pytest.mark.parametrize(
+    ("url", "name"),
+    [
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/935/935981.pdf",
+            "tika-935981.pdf",
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/937/937334.pdf",
+            "tika-937334.pdf",
+        ),
+    ],
+)
+def test_get_xfa(url, name):
+    data = BytesIO(get_pdf_from_url(url, name=name))
+    reader = PdfReader(data)
+    reader.xfa
+
+
+@pytest.mark.parametrize(
+    ("url", "name", "strict"),
+    [
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/988/988698.pdf",
+            "tika-988698.pdf",
+            False,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/914/914133.pdf",
+            "tika-988698.pdf",
+            False,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/912/912552.pdf",
+            "tika-912552.pdf",
+            False,
+        ),
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/914/914102.pdf",
+            "tika-914102.pdf",
+            True,
+        ),
+    ],
+)
+def test_get_fonts(url, name, strict):
+    data = BytesIO(get_pdf_from_url(url, name=name))
+    reader = PdfReader(data, strict=strict)
+    for page in reader.pages:
+        page._get_fonts()
+
+
+@pytest.mark.parametrize(
+    ("url", "name", "strict"),
+    [
+        (
+            "https://corpora.tika.apache.org/base/docs/govdocs1/942/942303.pdf",
+            "tika-942303.pdf",
+            True,
+        ),
+    ],
+)
+def test_get_xmp(url, name, strict):
+    data = BytesIO(get_pdf_from_url(url, name=name))
+    reader = PdfReader(data, strict=strict)
+    reader.xmp_metadata
diff --git a/tests/test_writer.py b/tests/test_writer.py
index f2d3a56c6..9c8f0dae3 100644
--- a/tests/test_writer.py
+++ b/tests/test_writer.py
@@ -1,21 +1,42 @@
 import os
 from io import BytesIO
+from pathlib import Path
 
 import pytest
 
 from PyPDF2 import PageObject, PdfMerger, PdfReader, PdfWriter
 from PyPDF2.errors import PageSizeNotDefinedError
-from PyPDF2.generic import RectangleObject, StreamObject
+from PyPDF2.generic import (
+    IndirectObject,
+    NameObject,
+    RectangleObject,
+    StreamObject,
+)
 
 from . import get_pdf_from_url
 
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
+EXTERNAL_ROOT = Path(PROJECT_ROOT) / "sample-files"
+
+
+def test_writer_exception_non_binary(tmp_path, caplog):
+    src = RESOURCE_ROOT / "pdflatex-outline.pdf"
+
+    reader = PdfReader(src)
+    writer = PdfWriter()
+    writer.add_page(reader.pages[0])
+
+    with open(tmp_path / "out.txt", "w") as fp:
+        with pytest.raises(TypeError):
+            writer.write_stream(fp)
+    ending = "to write to is not in binary mode. It may not be written to correctly.\n"
+    assert caplog.text.endswith(ending)
 
 
 def test_writer_clone():
-    src = os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf")
+    src = RESOURCE_ROOT / "pdflatex-outline.pdf"
 
     reader = PdfReader(src)
     writer = PdfWriter()
@@ -24,48 +45,55 @@ def test_writer_clone():
     assert len(writer.pages) == 4
 
 
-def test_writer_operations():
+def writer_operate(writer):
     """
-    This test just checks if the operation throws an exception.
-
-    This should be done way more thoroughly: It should be checked if the
-    output is as expected.
+    To test the writer that initialized by each of the four usages.
     """
-    pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
-    pdf_outline_path = os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf")
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
+    pdf_outline_path = RESOURCE_ROOT / "pdflatex-outline.pdf"
 
     reader = PdfReader(pdf_path)
     reader_outline = PdfReader(pdf_outline_path)
 
-    writer = PdfWriter()
     page = reader.pages[0]
     with pytest.raises(PageSizeNotDefinedError) as exc:
         writer.add_blank_page()
     assert exc.value.args == ()
     writer.insert_page(page, 1)
     writer.insert_page(reader_outline.pages[0], 0)
-    writer.add_bookmark_destination(page)
+    writer.add_outline_item_destination(page)
     writer.remove_links()
-    writer.add_bookmark_destination(page)
-    bm = writer.add_bookmark(
-        "A bookmark", 0, None, (255, 0, 15), True, True, "/FitBV", 10
+    writer.add_outline_item_destination(page)
+    oi = writer.add_outline_item(
+        "An outline item", 0, None, (255, 0, 15), True, True, "/FitBV", 10
+    )
+    writer.add_outline_item(
+        "The XYZ fit", 0, oi, (255, 0, 15), True, True, "/XYZ", 10, 20, 3
+    )
+    writer.add_outline_item(
+        "The FitH fit", 0, oi, (255, 0, 15), True, True, "/FitH", 10
     )
-    writer.add_bookmark(
-        "The XYZ fit", 0, bm, (255, 0, 15), True, True, "/XYZ", 10, 20, 3
+    writer.add_outline_item(
+        "The FitV fit", 0, oi, (255, 0, 15), True, True, "/FitV", 10
     )
-    writer.add_bookmark("The FitH fit", 0, bm, (255, 0, 15), True, True, "/FitH", 10)
-    writer.add_bookmark("The FitV fit", 0, bm, (255, 0, 15), True, True, "/FitV", 10)
-    writer.add_bookmark(
-        "The FitR fit", 0, bm, (255, 0, 15), True, True, "/FitR", 10, 20, 30, 40
+    writer.add_outline_item(
+        "The FitR fit", 0, oi, (255, 0, 15), True, True, "/FitR", 10, 20, 30, 40
+    )
+    writer.add_outline_item("The FitB fit", 0, oi, (255, 0, 15), True, True, "/FitB")
+    writer.add_outline_item(
+        "The FitBH fit", 0, oi, (255, 0, 15), True, True, "/FitBH", 10
+    )
+    writer.add_outline_item(
+        "The FitBV fit", 0, oi, (255, 0, 15), True, True, "/FitBV", 10
     )
-    writer.add_bookmark("The FitB fit", 0, bm, (255, 0, 15), True, True, "/FitB")
-    writer.add_bookmark("The FitBH fit", 0, bm, (255, 0, 15), True, True, "/FitBH", 10)
-    writer.add_bookmark("The FitBV fit", 0, bm, (255, 0, 15), True, True, "/FitBV", 10)
     writer.add_blank_page()
     writer.add_uri(2, "https://example.com", RectangleObject([0, 0, 100, 100]))
-    writer.add_link(2, 1, RectangleObject([0, 0, 100, 100]))
+    with pytest.warns(PendingDeprecationWarning):
+        writer.add_link(2, 1, RectangleObject([0, 0, 100, 100]))
     assert writer._get_page_layout() is None
-    writer._set_page_layout("/SinglePage")
+    writer.page_layout = "broken"
+    assert writer.page_layout == "broken"
+    writer.page_layout = NameObject("/SinglePage")
     assert writer._get_page_layout() == "/SinglePage"
     assert writer._get_page_mode() is None
     writer.set_page_mode("/UseNone")
@@ -80,19 +108,101 @@ def test_writer_operations():
 
     writer.add_attachment("foobar.gif", b"foobarcontent")
 
-    # finally, write "output" to PyPDF2-output.pdf
-    tmp_path = "dont_commit_writer.pdf"
-    with open(tmp_path, "wb") as output_stream:
-        writer.write(output_stream)
-
     # Check that every key in _idnum_hash is correct
     objects_hash = [o.hash_value() for o in writer._objects]
     for k, v in writer._idnum_hash.items():
         assert v.pdf == writer
-        assert k in objects_hash, "Missing %s" % v
+        assert k in objects_hash, f"Missing {v}"
 
-    # cleanup
-    os.remove(tmp_path)
+
+tmp_path = "dont_commit_writer.pdf"
+
+
+@pytest.mark.parametrize(
+    ("write_data_here", "needs_cleanup"),
+    [
+        ("dont_commit_writer.pdf", True),
+        (Path("dont_commit_writer.pdf"), True),
+        (BytesIO(), False),
+    ],
+)
+def test_writer_operations_by_traditional_usage(write_data_here, needs_cleanup):
+    writer = PdfWriter()
+
+    writer_operate(writer)
+
+    # finally, write "output" to PyPDF2-output.pdf
+    if needs_cleanup:
+        with open(write_data_here, "wb") as output_stream:
+            writer.write(output_stream)
+    else:
+        output_stream = write_data_here
+        writer.write(output_stream)
+
+    if needs_cleanup:
+        os.remove(write_data_here)
+
+
+@pytest.mark.parametrize(
+    ("write_data_here", "needs_cleanup"),
+    [
+        ("dont_commit_writer.pdf", True),
+        (Path("dont_commit_writer.pdf"), True),
+        (BytesIO(), False),
+    ],
+)
+def test_writer_operations_by_semi_traditional_usage(write_data_here, needs_cleanup):
+    with PdfWriter() as writer:
+        writer_operate(writer)
+
+        # finally, write "output" to PyPDF2-output.pdf
+        if needs_cleanup:
+            with open(write_data_here, "wb") as output_stream:
+                writer.write(output_stream)
+        else:
+            output_stream = write_data_here
+            writer.write(output_stream)
+
+    if needs_cleanup:
+        os.remove(write_data_here)
+
+
+@pytest.mark.parametrize(
+    ("write_data_here", "needs_cleanup"),
+    [
+        ("dont_commit_writer.pdf", True),
+        (Path("dont_commit_writer.pdf"), True),
+        (BytesIO(), False),
+    ],
+)
+def test_writer_operations_by_semi_new_traditional_usage(
+    write_data_here, needs_cleanup
+):
+    with PdfWriter() as writer:
+        writer_operate(writer)
+
+        # finally, write "output" to PyPDF2-output.pdf
+        writer.write(write_data_here)
+
+    if needs_cleanup:
+        os.remove(write_data_here)
+
+
+@pytest.mark.parametrize(
+    ("write_data_here", "needs_cleanup"),
+    [
+        ("dont_commit_writer.pdf", True),
+        (Path("dont_commit_writer.pdf"), True),
+        (BytesIO(), False),
+    ],
+)
+def test_writer_operation_by_new_usage(write_data_here, needs_cleanup):
+    # This includes write "output" to PyPDF2-output.pdf
+    with PdfWriter(write_data_here) as writer:
+        writer_operate(writer)
+
+    if needs_cleanup:
+        os.remove(write_data_here)
 
 
 @pytest.mark.parametrize(
@@ -103,7 +213,7 @@ def test_writer_operations():
     ],
 )
 def test_remove_images(input_path, ignore_byte_string_object):
-    pdf_path = os.path.join(RESOURCE_ROOT, input_path)
+    pdf_path = RESOURCE_ROOT / input_path
 
     reader = PdfReader(pdf_path)
     writer = PdfWriter()
@@ -137,7 +247,7 @@ def test_remove_images(input_path, ignore_byte_string_object):
     ],
 )
 def test_remove_text(input_path, ignore_byte_string_object):
-    pdf_path = os.path.join(RESOURCE_ROOT, input_path)
+    pdf_path = RESOURCE_ROOT / input_path
 
     reader = PdfReader(pdf_path)
     writer = PdfWriter()
@@ -203,7 +313,7 @@ def test_remove_text_all_operators(ignore_byte_string_object):
         pdf_data.find(b"4 0 obj") + startx_correction,
         pdf_data.find(b"5 0 obj") + startx_correction,
         pdf_data.find(b"6 0 obj") + startx_correction,
-        # startx_correction should be -1 due to double % at the beginning indiducing an error on startxref computation
+        # startx_correction should be -1 due to double % at the beginning inducing an error on startxref computation
         pdf_data.find(b"xref"),
     )
     print(pdf_data.decode())
@@ -226,7 +336,7 @@ def test_remove_text_all_operators(ignore_byte_string_object):
 
 
 def test_write_metadata():
-    pdf_path = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
 
     reader = PdfReader(pdf_path)
     writer = PdfWriter()
@@ -254,7 +364,7 @@ def test_write_metadata():
 
 
 def test_fill_form():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "form.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "form.pdf")
     writer = PdfWriter()
 
     page = reader.pages[0]
@@ -265,60 +375,86 @@ def test_fill_form():
         writer.pages[0], {"foo": "some filled in text"}, flags=1
     )
 
+    writer.update_page_form_field_values(
+        writer.pages[0], {"foo": "some filled in text"}
+    )
+
     # write "output" to PyPDF2-output.pdf
     tmp_filename = "dont_commit_filled_pdf.pdf"
     with open(tmp_filename, "wb") as output_stream:
         writer.write(output_stream)
 
+    os.remove(tmp_filename)  # cleanup
+
 
 @pytest.mark.parametrize(
-    "use_128bit",
-    [(True), (False)],
+    ("use_128bit", "user_pwd", "owner_pwd"),
+    [(True, "userpwd", "ownerpwd"), (False, "userpwd", "ownerpwd")],
 )
-def test_encrypt(use_128bit):
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "form.pdf"))
+def test_encrypt(use_128bit, user_pwd, owner_pwd):
+    reader = PdfReader(RESOURCE_ROOT / "form.pdf")
     writer = PdfWriter()
 
     page = reader.pages[0]
     orig_text = page.extract_text()
 
     writer.add_page(page)
-    writer.encrypt(user_pwd="userpwd", owner_pwd="ownerpwd", use_128bit=use_128bit)
+    writer.encrypt(user_pwd=user_pwd, owner_pwd=owner_pwd, use_128bit=use_128bit)
 
     # write "output" to PyPDF2-output.pdf
     tmp_filename = "dont_commit_encrypted.pdf"
     with open(tmp_filename, "wb") as output_stream:
         writer.write(output_stream)
 
+    # Test that the data is not there in clear text
     with open(tmp_filename, "rb") as input_stream:
         data = input_stream.read()
-
     assert b"foo" not in data
 
+    # Test the user password (str):
     reader = PdfReader(tmp_filename, password="userpwd")
     new_text = reader.pages[0].extract_text()
     assert reader.metadata.get("/Producer") == "PyPDF2"
+    assert new_text == orig_text
 
+    # Test the owner password (str):
+    reader = PdfReader(tmp_filename, password="ownerpwd")
+    new_text = reader.pages[0].extract_text()
+    assert reader.metadata.get("/Producer") == "PyPDF2"
+    assert new_text == orig_text
+
+    # Test the user password (bytes):
+    reader = PdfReader(tmp_filename, password=b"userpwd")
+    new_text = reader.pages[0].extract_text()
+    assert reader.metadata.get("/Producer") == "PyPDF2"
+    assert new_text == orig_text
+
+    # Test the owner password (stbytesr):
+    reader = PdfReader(tmp_filename, password=b"ownerpwd")
+    new_text = reader.pages[0].extract_text()
+    assert reader.metadata.get("/Producer") == "PyPDF2"
     assert new_text == orig_text
 
     # Cleanup
     os.remove(tmp_filename)
 
 
-def test_add_bookmark():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf"))
+def test_add_outline_item():
+    reader = PdfReader(RESOURCE_ROOT / "pdflatex-outline.pdf")
     writer = PdfWriter()
 
     for page in reader.pages:
         writer.add_page(page)
 
-    bookmark = writer.add_bookmark(
-        "A bookmark", 1, None, (255, 0, 15), True, True, "/Fit", 200, 0, None
+    outline_item = writer.add_outline_item(
+        "An outline item", 1, None, (255, 0, 15), True, True, "/Fit", 200, 0, None
+    )
+    writer.add_outline_item(
+        "Another", 2, outline_item, None, False, False, "/Fit", 0, 0, None
     )
-    writer.add_bookmark("Another", 2, bookmark, None, False, False, "/Fit", 0, 0, None)
 
     # write "output" to PyPDF2-output.pdf
-    tmp_filename = "dont_commit_bookmark.pdf"
+    tmp_filename = "dont_commit_outline_item.pdf"
     with open(tmp_filename, "wb") as output_stream:
         writer.write(output_stream)
 
@@ -327,22 +463,21 @@ def test_add_bookmark():
 
 
 def test_add_named_destination():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "pdflatex-outline.pdf")
     writer = PdfWriter()
+    assert writer.get_named_dest_root() == []
 
     for page in reader.pages:
         writer.add_page(page)
 
-    from PyPDF2.generic import NameObject
+    assert writer.get_named_dest_root() == []
 
     writer.add_named_destination(NameObject("A named dest"), 2)
     writer.add_named_destination(NameObject("A named dest2"), 2)
 
-    from PyPDF2.generic import IndirectObject
-
     assert writer.get_named_dest_root() == [
         "A named dest",
-        IndirectObject(7, 0, writer),
+        IndirectObject(9, 0, writer),
         "A named dest2",
         IndirectObject(10, 0, writer),
     ]
@@ -357,14 +492,12 @@ def test_add_named_destination():
 
 
 def test_add_uri():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "pdflatex-outline.pdf")
     writer = PdfWriter()
 
     for page in reader.pages:
         writer.add_page(page)
 
-    from PyPDF2.generic import RectangleObject
-
     writer.add_uri(
         1,
         "http://www.example.com",
@@ -400,38 +533,42 @@ def test_add_uri():
 
 
 def test_add_link():
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "pdflatex-outline.pdf")
     writer = PdfWriter()
 
     for page in reader.pages:
         writer.add_page(page)
 
-    from PyPDF2.generic import RectangleObject
-
-    writer.add_link(
-        1,
-        2,
-        RectangleObject([0, 0, 100, 100]),
-        border=[1, 2, 3, [4]],
-        fit="/Fit",
-    )
-    writer.add_link(2, 3, RectangleObject([20, 30, 50, 80]), [1, 2, 3], "/FitH", None)
-    writer.add_link(
-        3,
-        0,
-        "[ 200 300 250 350 ]",
-        [0, 0, 0],
-        "/XYZ",
-        0,
-        0,
-        2,
-    )
-    writer.add_link(
-        3,
-        0,
-        [100, 200, 150, 250],
-        border=[0, 0, 0],
-    )
+    with pytest.warns(
+        PendingDeprecationWarning,
+        match="add_link is deprecated and will be removed in PyPDF2",
+    ):
+        writer.add_link(
+            1,
+            2,
+            RectangleObject([0, 0, 100, 100]),
+            border=[1, 2, 3, [4]],
+            fit="/Fit",
+        )
+        writer.add_link(
+            2, 3, RectangleObject([20, 30, 50, 80]), [1, 2, 3], "/FitH", None
+        )
+        writer.add_link(
+            3,
+            0,
+            "[ 200 300 250 350 ]",
+            [0, 0, 0],
+            "/XYZ",
+            0,
+            0,
+            2,
+        )
+        writer.add_link(
+            3,
+            0,
+            [100, 200, 150, 250],
+            border=[0, 0, 0],
+        )
 
     # write "output" to PyPDF2-output.pdf
     tmp_filename = "dont_commit_link.pdf"
@@ -445,7 +582,7 @@ def test_add_link():
 def test_io_streams():
     """This is the example from the docs ("Streaming data")."""
 
-    filepath = os.path.join(RESOURCE_ROOT, "pdflatex-outline.pdf")
+    filepath = RESOURCE_ROOT / "pdflatex-outline.pdf"
     with open(filepath, "rb") as fh:
         bytes_stream = BytesIO(fh.read())
 
@@ -460,20 +597,24 @@ def test_io_streams():
 
 
 def test_regression_issue670():
-    filepath = os.path.join(RESOURCE_ROOT, "crazyones.pdf")
+    tmp_file = "dont_commit_issue670.pdf"
+    filepath = RESOURCE_ROOT / "crazyones.pdf"
     reader = PdfReader(filepath, strict=False)
     for _ in range(2):
         writer = PdfWriter()
         writer.add_page(reader.pages[0])
-        with open("dont_commit_issue670.pdf", "wb") as f_pdf:
+        with open(tmp_file, "wb") as f_pdf:
             writer.write(f_pdf)
 
+    # cleanup
+    os.remove(tmp_file)
+
 
 def test_issue301():
     """
     Test with invalid stream length object
     """
-    with open(os.path.join(RESOURCE_ROOT, "issue-301.pdf"), "rb") as f:
+    with open(RESOURCE_ROOT / "issue-301.pdf", "rb") as f:
         reader = PdfReader(f)
         writer = PdfWriter()
         writer.append_pages_from_reader(reader)
@@ -481,6 +622,16 @@ def test_issue301():
         writer.write(o)
 
 
+def test_append_pages_from_reader_append():
+    """use append_pages_from_reader with a callable"""
+    with open(RESOURCE_ROOT / "issue-301.pdf", "rb") as f:
+        reader = PdfReader(f)
+        writer = PdfWriter()
+        writer.append_pages_from_reader(reader, callable)
+        o = BytesIO()
+        writer.write(o)
+
+
 def test_sweep_indirect_references_nullobject_exception():
     # TODO: Check this more closely... this looks weird
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/924/924666.pdf"
@@ -494,7 +645,7 @@ def test_sweep_indirect_references_nullobject_exception():
     os.remove("tmp-merger-do-not-commit.pdf")
 
 
-def test_write_bookmark_on_page_fitv():
+def test_write_outline_item_on_page_fitv():
     url = "https://corpora.tika.apache.org/base/docs/govdocs1/922/922840.pdf"
     name = "tika-922840.pdf"
     reader = PdfReader(BytesIO(get_pdf_from_url(url, name=name)))
@@ -510,7 +661,7 @@ def test_pdf_header():
     writer = PdfWriter()
     assert writer.pdf_header == b"%PDF-1.3"
 
-    reader = PdfReader(os.path.join(RESOURCE_ROOT, "crazyones.pdf"))
+    reader = PdfReader(RESOURCE_ROOT / "crazyones.pdf")
     writer.add_page(reader.pages[0])
     assert writer.pdf_header == b"%PDF-1.5"
 
@@ -529,7 +680,6 @@ def test_write_dict_stream_object():
         b"(The single quote operator) ' "
         b"ET"
     )
-    from PyPDF2.generic import NameObject, IndirectObject
 
     stream_object = StreamObject()
     stream_object[NameObject("/Type")] = NameObject("/Text")
@@ -570,3 +720,80 @@ def test_write_dict_stream_object():
         assert k in objects_hash, "Missing %s" % v
 
     os.remove("tmp-writer-do-not-commit.pdf")
+
+
+def test_add_single_annotation():
+    pdf_path = RESOURCE_ROOT / "crazyones.pdf"
+    reader = PdfReader(pdf_path)
+    page = reader.pages[0]
+    writer = PdfWriter()
+    writer.add_page(page)
+
+    annot_dict = {
+        "/Type": "/Annot",
+        "/Subtype": "/Text",
+        "/Rect": [270.75, 596.25, 294.75, 620.25],
+        "/Contents": "Note in second paragraph",
+        "/C": [1, 1, 0],
+        "/M": "D:20220406191858+02'00",
+        "/Popup": {
+            "/Type": "/Annot",
+            "/Subtype": "/Popup",
+            "/Rect": [294.75, 446.25, 494.75, 596.25],
+            "/M": "D:20220406191847+02'00",
+        },
+        "/T": "moose",
+    }
+    writer.add_annotation(0, annot_dict)
+    # Assert manually
+    target = "annot-single-out.pdf"
+    with open(target, "wb") as fp:
+        writer.write(fp)
+
+    # Cleanup
+    os.remove(target)  # remove for testing
+
+
+def test_deprecate_bookmark_decorator():
+    reader = PdfReader(RESOURCE_ROOT / "outlines-with-invalid-destinations.pdf")
+    page = reader.pages[0]
+    outline_item = reader.outline[0]
+    writer = PdfWriter()
+    writer.add_page(page)
+    with pytest.warns(
+        UserWarning,
+        match="bookmark is deprecated as an argument. Use outline_item instead",
+    ):
+        writer.add_outline_item_dict(bookmark=outline_item)
+
+
+def test_colors_in_outline_item():
+    reader = PdfReader(EXTERNAL_ROOT / "004-pdflatex-4-pages/pdflatex-4-pages.pdf")
+    writer = PdfWriter()
+    writer.clone_document_from_reader(reader)
+    purple_rgb = (0.50196, 0, 0.50196)
+    writer.add_outline_item("First Outline Item", pagenum=2, color="800080")
+    writer.add_outline_item("Second Outline Item", pagenum=3, color="#800080")
+    writer.add_outline_item("Third Outline Item", pagenum=4, color=purple_rgb)
+
+    target = "tmp-named-color-outline.pdf"
+    with open(target, "wb") as f:
+        writer.write(f)
+
+    reader2 = PdfReader(target)
+    for outline_item in reader2.outline:
+        # convert float to string because of mutability
+        assert [str(c) for c in outline_item.color] == [str(p) for p in purple_rgb]
+
+    # Cleanup
+    os.remove(target)  # remove for testing
+
+
+def test_write_empty_stream():
+    reader = PdfReader(EXTERNAL_ROOT / "004-pdflatex-4-pages/pdflatex-4-pages.pdf")
+    writer = PdfWriter()
+    writer.clone_document_from_reader(reader)
+
+    with pytest.raises(ValueError) as exc:
+        writer.write("")
+    assert exc.value.args[0] == "Output(stream=) is empty."
diff --git a/tests/test_xmp.py b/tests/test_xmp.py
index d7dfc4685..a53b27b0e 100644
--- a/tests/test_xmp.py
+++ b/tests/test_xmp.py
@@ -1,6 +1,6 @@
-import os
 from datetime import datetime
 from io import BytesIO
+from pathlib import Path
 
 import pytest
 
@@ -11,16 +11,16 @@
 
 from . import get_pdf_from_url
 
-TESTS_ROOT = os.path.abspath(os.path.dirname(__file__))
-PROJECT_ROOT = os.path.dirname(TESTS_ROOT)
-RESOURCE_ROOT = os.path.join(PROJECT_ROOT, "resources")
+TESTS_ROOT = Path(__file__).parent.resolve()
+PROJECT_ROOT = TESTS_ROOT.parent
+RESOURCE_ROOT = PROJECT_ROOT / "resources"
 
 
 @pytest.mark.parametrize(
     ("src", "has_xmp"),
     [
-        (os.path.join(RESOURCE_ROOT, "commented-xmp.pdf"), True),
-        (os.path.join(RESOURCE_ROOT, "crazyones.pdf"), False),
+        (RESOURCE_ROOT / "commented-xmp.pdf", True),
+        (RESOURCE_ROOT / "crazyones.pdf", False),
     ],
 )
 def test_read_xmp(src, has_xmp):
@@ -74,7 +74,7 @@ def test_regression_issue774():
 
 
 def test_regression_issue914():
-    path = os.path.join(RESOURCE_ROOT, "issue-914-xmp-data.pdf")
+    path = RESOURCE_ROOT / "issue-914-xmp-data.pdf"
     reader = PdfReader(path)
     assert reader.xmp_metadata.xmp_modify_date == datetime(2022, 4, 9, 15, 22, 43)
 
@@ -183,7 +183,7 @@ def test_issue585():
 #     class Tst:  # to replace pdf
 #         strict = False
 
-#     reader = PdfReader(os.path.join(RESOURCE_ROOT, "commented-xmp.pdf"))
+#     reader = PdfReader(RESOURCE_ROOT / "commented-xmp.pdf")
 #     xmp_info = reader.xmp_metadata
 #     # <?xpacket begin='' id='W5M0MpCehiHzreSzNTczkc9d'?>
 #     # <x:xmpmeta xmlns:x='adobe:ns:meta/' x:xmptk='Image::ExifTool 11.88'>