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

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

120 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""" 

60 

61from collections.abc import Callable, Iterator 

62from typing import Optional, cast 

63 

64from ._protocols import PdfCommonDocProtocol 

65from ._utils import logger_warning 

66from .generic import ( 

67 ArrayObject, 

68 DictionaryObject, 

69 NullObject, 

70 NumberObject, 

71 is_null_or_none, 

72) 

73 

74 

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

76 if num <= 0: 

77 raise ValueError("Expecting a positive number") 

78 roman = [ 

79 (1000, "M"), 

80 (900, "CM"), 

81 (500, "D"), 

82 (400, "CD"), 

83 (100, "C"), 

84 (90, "XC"), 

85 (50, "L"), 

86 (40, "XL"), 

87 (10, "X"), 

88 (9, "IX"), 

89 (5, "V"), 

90 (4, "IV"), 

91 (1, "I"), 

92 ] 

93 

94 def roman_num(num: int) -> Iterator[str]: 

95 for decimal, roman_repr in roman: 

96 x, _ = divmod(num, decimal) 

97 yield roman_repr * x 

98 num -= decimal * x 

99 if num <= 0: 

100 break 

101 

102 return "".join(list(roman_num(num))) 

103 

104 

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

106 return number2uppercase_roman_numeral(number).lower() 

107 

108 

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

110 if number <= 0: 

111 raise ValueError("Expecting a positive number") 

112 alphabet = [chr(i) for i in range(ord("A"), ord("Z") + 1)] 

113 rep = "" 

114 while number > 0: 

115 remainder = number % 26 

116 if remainder == 0: 

117 remainder = 26 

118 rep = alphabet[remainder - 1] + rep 

119 # update 

120 number -= remainder 

121 number = number // 26 

122 return rep 

123 

124 

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

126 return number2uppercase_letter(number).lower() 

127 

128 

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

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

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

132 # where each key_i is an integer and the corresponding 

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

134 # The keys shall be sorted in numerical order, 

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

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

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

138 nums_length = len(nums) 

139 i = 0 

140 value = None 

141 start_index = 0 

142 while i < nums_length: 

143 if i + 1 >= nums_length: 

144 logger_warning( 

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

146 ) 

147 break 

148 start_index = nums[i] 

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

150 if i + 2 == nums_length: 

151 break 

152 if nums[i + 2] > index: 

153 break 

154 i += 2 

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

156 None: lambda _: "", 

157 "/D": str, 

158 "/R": number2uppercase_roman_numeral, 

159 "/r": number2lowercase_roman_numeral, 

160 "/A": number2uppercase_letter, 

161 "/a": number2lowercase_letter, 

162 } 

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

164 if not isinstance(value, dict): 

165 return str(index + 1) # Fallback 

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

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

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

169 if mapping_function is None: 

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

171 logger_warning( 

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

173 source=__name__, 

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

175 ) 

176 return str(index + 1) # Fallback 

177 try: 

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

179 except (TypeError, ValueError): 

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

181 logger_warning( 

182 "Ignoring malformed page label entry in /Nums (/St=%(start)r, /P=%(prefix)r).", 

183 source=__name__, 

184 start=start, 

185 prefix=prefix, 

186 ) 

187 return str(index + 1) # Fallback 

188 

189 

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

191 """ 

192 See 7.9.7 "Number Trees". 

193 

194 Args: 

195 reader: The PdfReader 

196 index: The index of the page 

197 

198 Returns: 

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

200 

201 """ 

202 root = cast(DictionaryObject, reader.root_object) 

203 if "/PageLabels" not in root: 

204 return str(index + 1) # Fallback 

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

206 if not isinstance(number_tree, DictionaryObject): 

207 logger_warning( 

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

209 source=__name__, 

210 number_tree=number_tree, 

211 ) 

212 return str(index + 1) # Fallback 

213 if "/Nums" in number_tree: 

214 return get_label_from_nums(number_tree, index) 

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

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

217 # Limit maximum depth. 

218 level = 0 

219 while level < 100: 

220 kids = cast(list[DictionaryObject], number_tree["/Kids"]) 

221 for kid in kids: 

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

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

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

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

226 logger_warning( 

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

228 source=__name__, 

229 ) 

230 continue 

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

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

233 # Recursive definition. 

234 level += 1 

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

236 raise NotImplementedError( 

237 "Too deep nesting is not supported." 

238 ) 

239 number_tree = kid 

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

241 # next iteration of the `while` loop. 

242 break 

243 return get_label_from_nums(kid, index) 

244 else: 

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

246 # and continue with the fallback. 

247 break 

248 

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

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

251 

252 

253def nums_insert( 

254 key: NumberObject, 

255 value: DictionaryObject, 

256 nums: ArrayObject, 

257) -> None: 

258 """ 

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

260 

261 See 7.9.7 "Number Trees". 

262 

263 Args: 

264 key: number key of the entry 

265 value: value of the entry 

266 nums: Nums array to modify 

267 

268 """ 

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

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

271 

272 i = len(nums) 

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

274 i = i - 2 

275 

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

277 nums[i + 1] = value 

278 else: 

279 nums.insert(i, key) 

280 nums.insert(i + 1, value) 

281 

282 

283def nums_clear_range( 

284 key: NumberObject, 

285 page_index_to: int, 

286 nums: ArrayObject, 

287) -> None: 

288 """ 

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

290 

291 See 7.9.7 "Number Trees". 

292 

293 Args: 

294 key: number key of the entry before the range 

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

296 nums: Nums array to modify 

297 

298 """ 

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

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

301 if page_index_to < key: 

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

303 

304 i = nums.index(key) + 2 

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

306 nums.pop(i) 

307 nums.pop(i) 

308 

309 

310def nums_next( 

311 key: NumberObject, 

312 nums: ArrayObject, 

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

314 """ 

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

316 

317 See 7.9.7 "Number Trees". 

318 

319 Args: 

320 key: number key of the entry 

321 nums: Nums array 

322 

323 """ 

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

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

326 

327 i = nums.index(key) + 2 

328 if i < len(nums): 

329 return (nums[i], nums[i + 1]) 

330 return (None, None)