Files
notes-qpid-pro/app/vendor/pypdf/_page_labels.py

339 lines
11 KiB
Python

"""
Page labels are shown by PDF viewers as "the page number".
A page has a numeric index, starting at 0. Additionally, the page
has a label. In the most simple case:
label = index + 1
However, the title page and the table of contents might have Roman numerals as
page labels. This makes things more complicated.
Example 1
---------
>>> reader.root_object["/PageLabels"]["/Nums"]
[0, IndirectObject(18, 0, 139929798197504),
8, IndirectObject(19, 0, 139929798197504)]
>>> reader.get_object(reader.root_object["/PageLabels"]["/Nums"][1])
{'/S': '/r'}
>>> reader.get_object(reader.root_object["/PageLabels"]["/Nums"][3])
{'/S': '/D'}
Example 2
---------
The following is a document with pages labeled
i, ii, iii, iv, 1, 2, 3, A-8, A-9, ...
1 0 obj
<< /Type /Catalog
/PageLabels << /Nums [
0 << /S /r >>
4 << /S /D >>
7 << /S /D
/P ( A- )
/St 8
>>
% A number tree containing
% three page label dictionaries
]
>>
...
>>
endobj
§12.4.2 PDF Specification 1.7 and 2.0
=====================================
Entries in a page label dictionary
----------------------------------
The /S key:
D Decimal Arabic numerals
R Uppercase Roman numerals
r Lowercase Roman numerals
A Uppercase letters (A to Z for the first 26 pages,
AA to ZZ for the next 26, and so on)
a Lowercase letters (a to z for the first 26 pages,
aa to zz for the next 26, and so on)
"""
import string
from typing import TYPE_CHECKING, Optional, cast
from ._protocols import PdfCommonDocProtocol
from ._utils import logger_warning
from .generic import (
ArrayObject,
DictionaryObject,
NullObject,
NumberObject,
is_null_or_none,
)
if TYPE_CHECKING:
from collections.abc import Callable
# The largest number one can represent using the usual symbols
# and subtractive notation without additional conventions.
# Aligns with https://github.com/AA-Turner/roman-numerals/blob/master/python/roman_numerals/__init__.py
MAXIMUM_ROMAN_NUMERAL = 3_999
# Allow values of up to 512 * 26 = 13_312 for the `/A` and `/a`.
# Please note that this limit is somehow arbitrary and some trade-off
# between rather restrictive <= 256 output bytes per label and the possibly
# unrealistic 1000 output bytes per label.
MAXIMUM_PAGE_LABEL_LENGTH = 512
def number2uppercase_roman_numeral(num: int) -> str:
if num <= 0:
raise ValueError("Expecting a positive number.")
if num > MAXIMUM_ROMAN_NUMERAL:
raise ValueError("Number is out of range.")
roman = [
(1000, "M"),
(900, "CM"),
(500, "D"),
(400, "CD"),
(100, "C"),
(90, "XC"),
(50, "L"),
(40, "XL"),
(10, "X"),
(9, "IX"),
(5, "V"),
(4, "IV"),
(1, "I"),
]
result = []
for value, symbol in roman:
count, num = divmod(num, value)
result.append(symbol * count)
return "".join(result)
def number2lowercase_roman_numeral(number: int) -> str:
return number2uppercase_roman_numeral(number).lower()
def number2uppercase_letter(number: int) -> str:
if number <= 0:
raise ValueError("Expecting a positive number.")
# A to Z, then the same letter repeated: AA to ZZ, AAA to ZZZ, and so on.
repetitions, position = divmod(number - 1, 26)
length = repetitions + 1
if length > MAXIMUM_PAGE_LABEL_LENGTH:
raise ValueError("Number is too large.")
return string.ascii_uppercase[position] * (repetitions + 1)
def number2lowercase_letter(number: int) -> str:
return number2uppercase_letter(number).lower()
def get_label_from_nums(dictionary_object: DictionaryObject, index: int) -> str:
# [Nums] shall be an array of the form
# [ key_1 value_1 key_2 value_2 ... key_n value_n ]
# where each key_i is an integer and the corresponding
# value_i shall be the object associated with that key.
# The keys shall be sorted in numerical order,
# analogously to the arrangement of keys in a name tree
# as described in 7.9.6, "Name Trees."
nums = cast(ArrayObject, dictionary_object["/Nums"])
nums_length = len(nums)
i = 0
value = None
start_index = 0
while i < nums_length:
if i + 1 >= nums_length:
logger_warning(
"Ignoring last /Nums key without a value.", source=__name__
)
break
start_index = nums[i]
value = nums[i + 1].get_object()
if i + 2 == nums_length:
break
if nums[i + 2] > index:
break
i += 2
m: dict[Optional[str], Callable[[int], str]] = {
None: lambda _: "",
"/D": str,
"/R": number2uppercase_roman_numeral,
"/r": number2lowercase_roman_numeral,
"/A": number2uppercase_letter,
"/a": number2lowercase_letter,
}
# if /Nums array is not following the specification or if /Nums is empty
if not isinstance(value, dict):
return str(index + 1) # Fallback
start = value.get("/St", 1)
prefix = cast(str, value.get("/P", ""))
mapping_function = m.get(value.get("/S"))
if mapping_function is None:
# Unknown /S numbering style; fall back to the page position.
logger_warning(
"Ignoring unknown page label numbering style %(style)r in /Nums.",
source=__name__,
style=value.get("/S"),
)
return str(index + 1) # Fallback
try:
return prefix + mapping_function(index - start_index + start)
except (TypeError, ValueError) as exception:
# Malformed /St or /P value; fall back to the page position.
logger_warning(
"Ignoring malformed page label entry in /Nums (/St=%(start)r, /P=%(prefix)r): %(exception)s",
source=__name__,
start=start,
prefix=prefix,
exception=exception,
)
return str(index + 1) # Fallback
def index2label(reader: PdfCommonDocProtocol, index: int) -> str:
"""
See 7.9.7 "Number Trees".
Args:
reader: The PdfReader
index: The index of the page
Returns:
The label of the page, e.g. "iv" or "4".
"""
root = cast(DictionaryObject, reader.root_object)
if "/PageLabels" not in root:
return str(index + 1) # Fallback
number_tree = root["/PageLabels"].get_object()
if not isinstance(number_tree, DictionaryObject):
logger_warning(
"Page labels are not a dictionary: %(number_tree)s",
source=__name__,
number_tree=number_tree,
)
return str(index + 1) # Fallback
if "/Nums" in number_tree:
return get_label_from_nums(number_tree, index)
if "/Kids" in number_tree and not isinstance(number_tree["/Kids"], NullObject):
# number_tree = {'/Kids': [IndirectObject(7333, 0, 140132998195856), ...]}
# Limit maximum depth.
level = 0
while level < 100:
kids = cast(list[DictionaryObject], number_tree["/Kids"])
for kid in kids:
# kid = {'/Limits': [0, 63], '/Nums': [0, {'/P': 'C1'}, ...]}
limits = kid.get("/Limits", NullObject()).get_object()
if not isinstance(limits, list) or len(limits) < 2:
# Skip kids whose /Limits range is missing or malformed.
logger_warning(
"Ignoring kid with missing or malformed /Limits in /PageLabels.",
source=__name__,
)
continue
if limits[0] <= index <= limits[1]:
if not is_null_or_none(kid.get("/Kids", None)):
# Recursive definition.
level += 1
if level == 100: # pragma: no cover
raise NotImplementedError(
"Too deep nesting is not supported."
)
number_tree = kid
# Exit the inner `for` loop and continue at the next level with the
# next iteration of the `while` loop.
break
return get_label_from_nums(kid, index)
else:
# When there are no kids, make sure to exit the `while` loop directly
# and continue with the fallback.
break
logger_warning("Could not reliably determine page label for %(index)d.", source=__name__, index=index)
return str(index + 1) # Fallback if neither /Nums nor /Kids is in the number_tree
def nums_insert(
key: NumberObject,
value: DictionaryObject,
nums: ArrayObject,
) -> None:
"""
Insert a key, value pair in a Nums array.
See 7.9.7 "Number Trees".
Args:
key: number key of the entry
value: value of the entry
nums: Nums array to modify
"""
if len(nums) % 2 != 0:
raise ValueError("A nums like array must have an even number of elements")
i = len(nums)
while i != 0 and key <= nums[i - 2]:
i = i - 2
if i < len(nums) and key == nums[i]:
nums[i + 1] = value
else:
nums.insert(i, key)
nums.insert(i + 1, value)
def nums_clear_range(
key: NumberObject,
page_index_to: int,
nums: ArrayObject,
) -> None:
"""
Remove all entries in a number tree in a range after an entry.
See 7.9.7 "Number Trees".
Args:
key: number key of the entry before the range
page_index_to: The page index of the upper limit of the range
nums: Nums array to modify
"""
if len(nums) % 2 != 0:
raise ValueError("A nums like array must have an even number of elements")
if page_index_to < key:
raise ValueError("page_index_to must be greater or equal than key")
i = nums.index(key) + 2
while i < len(nums) and nums[i] <= page_index_to:
nums.pop(i)
nums.pop(i)
def nums_next(
key: NumberObject,
nums: ArrayObject,
) -> tuple[Optional[NumberObject], Optional[DictionaryObject]]:
"""
Return the (key, value) pair of the entry after the given one.
See 7.9.7 "Number Trees".
Args:
key: number key of the entry
nums: Nums array
"""
if len(nums) % 2 != 0:
raise ValueError("A nums like array must have an even number of elements")
i = nums.index(key) + 2
if i < len(nums):
return nums[i], nums[i + 1]
return None, None