Coverage for /pythoncovmergedfiles/medio/medio/usr/local/lib/python3.11/site-packages/pypdf/_page_labels.py: 14%

Shortcuts on this page

r m x   toggle line displays

j k   next/prev highlighted chunk

0   (zero) top of page

1   (one) first highlighted chunk

125 statements  

1""" 

2Page labels are shown by PDF viewers as "the page number". 

3 

4A page has a numeric index, starting at 0. Additionally, the page 

5has a label. In the most simple case: 

6 

7 label = index + 1 

8 

9However, the title page and the table of contents might have Roman numerals as 

10page labels. This makes things more complicated. 

11 

12Example 1 

13--------- 

14 

15>>> reader.root_object["/PageLabels"]["/Nums"] 

16[0, IndirectObject(18, 0, 139929798197504), 

17 8, IndirectObject(19, 0, 139929798197504)] 

18>>> reader.get_object(reader.root_object["/PageLabels"]["/Nums"][1]) 

19{'/S': '/r'} 

20>>> reader.get_object(reader.root_object["/PageLabels"]["/Nums"][3]) 

21{'/S': '/D'} 

22 

23Example 2 

24--------- 

25The following is a document with pages labeled 

26i, ii, iii, iv, 1, 2, 3, A-8, A-9, ... 

27 

281 0 obj 

29 << /Type /Catalog 

30 /PageLabels << /Nums [ 

31 0 << /S /r >> 

32 4 << /S /D >> 

33 7 << /S /D 

34 /P ( A- ) 

35 /St 8 

36 >> 

37 % A number tree containing 

38 % three page label dictionaries 

39 ] 

40 >> 

41 ... 

42 >> 

43endobj 

44 

45 

46§12.4.2 PDF Specification 1.7 and 2.0 

47===================================== 

48 

49Entries in a page label dictionary 

50---------------------------------- 

51The /S key: 

52D Decimal Arabic numerals 

53R Uppercase Roman numerals 

54r Lowercase Roman numerals 

55A Uppercase letters (A to Z for the first 26 pages, 

56 AA to ZZ for the next 26, and so on) 

57a Lowercase letters (a to z for the first 26 pages, 

58 aa to zz for the next 26, and so on) 

59""" 

60import string 

61from typing import TYPE_CHECKING, Optional, cast 

62 

63from ._protocols import PdfCommonDocProtocol 

64from ._utils import logger_warning 

65from .generic import ( 

66 ArrayObject, 

67 DictionaryObject, 

68 NullObject, 

69 NumberObject, 

70 is_null_or_none, 

71) 

72 

73if TYPE_CHECKING: 

74 from collections.abc import Callable 

75 

76# The largest number one can represent using the usual symbols 

77# and subtractive notation without additional conventions. 

78# Aligns with https://github.com/AA-Turner/roman-numerals/blob/master/python/roman_numerals/__init__.py 

79MAXIMUM_ROMAN_NUMERAL = 3_999 

80 

81# Allow values of up to 512 * 26 = 13_312 for the `/A` and `/a`. 

82# Please note that this limit is somehow arbitrary and some trade-off 

83# between rather restrictive <= 256 output bytes per label and the possibly 

84# unrealistic 1000 output bytes per label. 

85MAXIMUM_PAGE_LABEL_LENGTH = 512 

86 

87 

88def number2uppercase_roman_numeral(num: int) -> str: 

89 if num <= 0: 

90 raise ValueError("Expecting a positive number.") 

91 if num > MAXIMUM_ROMAN_NUMERAL: 

92 raise ValueError("Number is out of range.") 

93 roman = [ 

94 (1000, "M"), 

95 (900, "CM"), 

96 (500, "D"), 

97 (400, "CD"), 

98 (100, "C"), 

99 (90, "XC"), 

100 (50, "L"), 

101 (40, "XL"), 

102 (10, "X"), 

103 (9, "IX"), 

104 (5, "V"), 

105 (4, "IV"), 

106 (1, "I"), 

107 ] 

108 

109 result = [] 

110 for value, symbol in roman: 

111 count, num = divmod(num, value) 

112 result.append(symbol * count) 

113 

114 return "".join(result) 

115 

116 

117def number2lowercase_roman_numeral(number: int) -> str: 

118 return number2uppercase_roman_numeral(number).lower() 

119 

120 

121def number2uppercase_letter(number: int) -> str: 

122 if number <= 0: 

123 raise ValueError("Expecting a positive number.") 

124 # A to Z, then the same letter repeated: AA to ZZ, AAA to ZZZ, and so on. 

125 repetitions, position = divmod(number - 1, 26) 

126 length = repetitions + 1 

127 if length > MAXIMUM_PAGE_LABEL_LENGTH: 

128 raise ValueError("Number is too large.") 

129 return string.ascii_uppercase[position] * (repetitions + 1) 

130 

131 

132def number2lowercase_letter(number: int) -> str: 

133 return number2uppercase_letter(number).lower() 

134 

135 

136def get_label_from_nums(dictionary_object: DictionaryObject, index: int) -> str: 

137 # [Nums] shall be an array of the form 

138 # [ key_1 value_1 key_2 value_2 ... key_n value_n ] 

139 # where each key_i is an integer and the corresponding 

140 # value_i shall be the object associated with that key. 

141 # The keys shall be sorted in numerical order, 

142 # analogously to the arrangement of keys in a name tree 

143 # as described in 7.9.6, "Name Trees." 

144 nums = cast(ArrayObject, dictionary_object["/Nums"]) 

145 nums_length = len(nums) 

146 i = 0 

147 value = None 

148 start_index = 0 

149 while i < nums_length: 

150 if i + 1 >= nums_length: 

151 logger_warning( 

152 "Ignoring last /Nums key without a value.", source=__name__ 

153 ) 

154 break 

155 start_index = nums[i] 

156 value = nums[i + 1].get_object() 

157 if i + 2 == nums_length: 

158 break 

159 if nums[i + 2] > index: 

160 break 

161 i += 2 

162 m: dict[Optional[str], Callable[[int], str]] = { 

163 None: lambda _: "", 

164 "/D": str, 

165 "/R": number2uppercase_roman_numeral, 

166 "/r": number2lowercase_roman_numeral, 

167 "/A": number2uppercase_letter, 

168 "/a": number2lowercase_letter, 

169 } 

170 # if /Nums array is not following the specification or if /Nums is empty 

171 if not isinstance(value, dict): 

172 return str(index + 1) # Fallback 

173 start = value.get("/St", 1) 

174 prefix = cast(str, value.get("/P", "")) 

175 mapping_function = m.get(value.get("/S")) 

176 if mapping_function is None: 

177 # Unknown /S numbering style; fall back to the page position. 

178 logger_warning( 

179 "Ignoring unknown page label numbering style %(style)r in /Nums.", 

180 source=__name__, 

181 style=value.get("/S"), 

182 ) 

183 return str(index + 1) # Fallback 

184 try: 

185 return prefix + mapping_function(index - start_index + start) 

186 except (TypeError, ValueError) as exception: 

187 # Malformed /St or /P value; fall back to the page position. 

188 logger_warning( 

189 "Ignoring malformed page label entry in /Nums (/St=%(start)r, /P=%(prefix)r): %(exception)s", 

190 source=__name__, 

191 start=start, 

192 prefix=prefix, 

193 exception=exception, 

194 ) 

195 return str(index + 1) # Fallback 

196 

197 

198def index2label(reader: PdfCommonDocProtocol, index: int) -> str: 

199 """ 

200 See 7.9.7 "Number Trees". 

201 

202 Args: 

203 reader: The PdfReader 

204 index: The index of the page 

205 

206 Returns: 

207 The label of the page, e.g. "iv" or "4". 

208 

209 """ 

210 root = cast(DictionaryObject, reader.root_object) 

211 if "/PageLabels" not in root: 

212 return str(index + 1) # Fallback 

213 number_tree = root["/PageLabels"].get_object() 

214 if not isinstance(number_tree, DictionaryObject): 

215 logger_warning( 

216 "Page labels are not a dictionary: %(number_tree)s", 

217 source=__name__, 

218 number_tree=number_tree, 

219 ) 

220 return str(index + 1) # Fallback 

221 if "/Nums" in number_tree: 

222 return get_label_from_nums(number_tree, index) 

223 if "/Kids" in number_tree and not isinstance(number_tree["/Kids"], NullObject): 

224 # number_tree = {'/Kids': [IndirectObject(7333, 0, 140132998195856), ...]} 

225 # Limit maximum depth. 

226 level = 0 

227 while level < 100: 

228 kids = number_tree["/Kids"].get_object() 

229 if not isinstance(kids, ArrayObject): 

230 logger_warning( 

231 "Page label kids are not an array: %(kids)s", 

232 source=__name__, 

233 kids=kids, 

234 ) 

235 break 

236 for kid in kids: 

237 # kid = {'/Limits': [0, 63], '/Nums': [0, {'/P': 'C1'}, ...]} 

238 kid = kid.get_object() 

239 if not isinstance(kid, DictionaryObject): 

240 logger_warning( 

241 "Ignoring kid which is not a dictionary in /PageLabels.", 

242 source=__name__, 

243 ) 

244 continue 

245 limits = kid.get("/Limits", NullObject()).get_object() 

246 if not isinstance(limits, list) or len(limits) < 2: 

247 # Skip kids whose /Limits range is missing or malformed. 

248 logger_warning( 

249 "Ignoring kid with missing or malformed /Limits in /PageLabels.", 

250 source=__name__, 

251 ) 

252 continue 

253 if limits[0] <= index <= limits[1]: 

254 if not is_null_or_none(kid.get("/Kids", None)): 

255 # Recursive definition. 

256 level += 1 

257 if level == 100: # pragma: no cover 

258 raise NotImplementedError( 

259 "Too deep nesting is not supported." 

260 ) 

261 number_tree = kid 

262 # Exit the inner `for` loop and continue at the next level with the 

263 # next iteration of the `while` loop. 

264 break 

265 return get_label_from_nums(kid, index) 

266 else: 

267 # When there are no kids, make sure to exit the `while` loop directly 

268 # and continue with the fallback. 

269 break 

270 

271 logger_warning("Could not reliably determine page label for %(index)d.", source=__name__, index=index) 

272 return str(index + 1) # Fallback if neither /Nums nor /Kids is in the number_tree 

273 

274 

275def nums_insert( 

276 key: NumberObject, 

277 value: DictionaryObject, 

278 nums: ArrayObject, 

279) -> None: 

280 """ 

281 Insert a key, value pair in a Nums array. 

282 

283 See 7.9.7 "Number Trees". 

284 

285 Args: 

286 key: number key of the entry 

287 value: value of the entry 

288 nums: Nums array to modify 

289 

290 """ 

291 if len(nums) % 2 != 0: 

292 raise ValueError("A nums like array must have an even number of elements") 

293 

294 i = len(nums) 

295 while i != 0 and key <= nums[i - 2]: 

296 i = i - 2 

297 

298 if i < len(nums) and key == nums[i]: 

299 nums[i + 1] = value 

300 else: 

301 nums.insert(i, key) 

302 nums.insert(i + 1, value) 

303 

304 

305def nums_clear_range( 

306 key: NumberObject, 

307 page_index_to: int, 

308 nums: ArrayObject, 

309) -> None: 

310 """ 

311 Remove all entries in a number tree in a range after an entry. 

312 

313 See 7.9.7 "Number Trees". 

314 

315 Args: 

316 key: number key of the entry before the range 

317 page_index_to: The page index of the upper limit of the range 

318 nums: Nums array to modify 

319 

320 """ 

321 if len(nums) % 2 != 0: 

322 raise ValueError("A nums like array must have an even number of elements") 

323 if page_index_to < key: 

324 raise ValueError("page_index_to must be greater or equal than key") 

325 

326 i = nums.index(key) + 2 

327 while i < len(nums) and nums[i] <= page_index_to: 

328 nums.pop(i) 

329 nums.pop(i) 

330 

331 

332def nums_next( 

333 key: NumberObject, 

334 nums: ArrayObject, 

335) -> tuple[Optional[NumberObject], Optional[DictionaryObject]]: 

336 """ 

337 Return the (key, value) pair of the entry after the given one. 

338 

339 See 7.9.7 "Number Trees". 

340 

341 Args: 

342 key: number key of the entry 

343 nums: Nums array 

344 

345 """ 

346 if len(nums) % 2 != 0: 

347 raise ValueError("A nums like array must have an even number of elements") 

348 

349 i = nums.index(key) + 2 

350 if i < len(nums): 

351 return nums[i], nums[i + 1] 

352 return None, None