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

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

974 statements  

1# Copyright (c) 2006, Mathieu Fenniak 

2# Copyright (c) 2007, Ashish Kulkarni <kulkarni.ashish@gmail.com> 

3# 

4# All rights reserved. 

5# 

6# Redistribution and use in source and binary forms, with or without 

7# modification, are permitted provided that the following conditions are 

8# met: 

9# 

10# * Redistributions of source code must retain the above copyright notice, 

11# this list of conditions and the following disclaimer. 

12# * Redistributions in binary form must reproduce the above copyright notice, 

13# this list of conditions and the following disclaimer in the documentation 

14# and/or other materials provided with the distribution. 

15# * The name of the author may not be used to endorse or promote products 

16# derived from this software without specific prior written permission. 

17# 

18# THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" 

19# AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE 

20# IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE 

21# ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR CONTRIBUTORS BE 

22# LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR 

23# CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF 

24# SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS 

25# INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN 

26# CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) 

27# ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE 

28# POSSIBILITY OF SUCH DAMAGE. 

29 

30import math 

31from collections.abc import Iterable, Iterator, Sequence 

32from copy import deepcopy 

33from dataclasses import asdict, dataclass 

34from decimal import Decimal 

35from io import BytesIO 

36from pathlib import Path 

37from typing import ( 

38 Any, 

39 Callable, 

40 Literal, 

41 Optional, 

42 Union, 

43 cast, 

44 overload, 

45) 

46 

47from ._font import Font 

48from ._protocols import PdfCommonDocProtocol 

49from ._text_extraction import ( 

50 _layout_mode, 

51) 

52from ._text_extraction._text_extractor import TextExtraction 

53from ._utils import ( 

54 CompressedTransformationMatrix, 

55 TransformationMatrixType, 

56 _human_readable_bytes, 

57 deprecate, 

58 deprecate_no_replacement, 

59 deprecate_with_replacement, 

60 logger_warning, 

61 matrix_multiply, 

62) 

63from .actions import Action, PageTrigger 

64from .constants import ( 

65 _INLINE_IMAGE_KEY_MAPPING, 

66 _INLINE_IMAGE_VALUE_MAPPING, 

67 AnnotationDictionaryAttributes, 

68 ImageAttributes, 

69) 

70from .constants import PageAttributes as PG 

71from .constants import Resources as RES 

72from .errors import PageSizeNotDefinedError, PdfReadError 

73from .generic import ( 

74 ArrayObject, 

75 ContentStream, 

76 DictionaryObject, 

77 EncodedStreamObject, 

78 FloatObject, 

79 IndirectObject, 

80 NameObject, 

81 NullObject, 

82 NumberObject, 

83 PdfObject, 

84 RectangleObject, 

85 StreamObject, 

86 is_null_or_none, 

87) 

88 

89try: 

90 from PIL.Image import Image 

91 

92 pil_not_imported = False 

93except ImportError: 

94 Image = object # type: ignore[assignment,misc,unused-ignore] # TODO: Remove unused-ignore on Python 3.10 

95 pil_not_imported = True # error will be raised only when using images 

96 

97MERGE_CROP_BOX = "cropbox" # pypdf <= 3.4.0 used "trimbox" 

98 

99 

100def _get_rectangle(self: Any, name: str, defaults: Iterable[str]) -> RectangleObject: 

101 retval: Union[None, RectangleObject, ArrayObject, IndirectObject] = self.get(name) 

102 if isinstance(retval, RectangleObject): 

103 return retval 

104 if is_null_or_none(retval): 

105 for d in defaults: 

106 retval = self.get(d) 

107 if retval is not None: 

108 break 

109 if isinstance(retval, IndirectObject): 

110 retval = self.pdf.get_object(retval) 

111 if isinstance(retval, ArrayObject) and (length := len(retval)) != 4: 

112 if length > 4: 

113 # Keep backwards-compatibility with files previously written in a 

114 # broken way by pypdf, which carried more than four values. 

115 logger_warning( 

116 "Expected four values, got %(length)d: %(retval)s", 

117 source=__name__, 

118 length=length, 

119 retval=retval, 

120 ) 

121 retval = RectangleObject(tuple(retval[:4])) 

122 else: 

123 raise ValueError( 

124 f"Expected four values for {name}, got {length}: {retval}" 

125 ) 

126 else: 

127 retval = RectangleObject(retval) # type: ignore[arg-type] 

128 _set_rectangle(self, name, retval) 

129 return retval 

130 

131 

132def _set_rectangle(self: Any, name: str, value: Union[RectangleObject, float]) -> None: 

133 self[NameObject(name)] = value 

134 

135 

136def _delete_rectangle(self: Any, name: str) -> None: 

137 del self[name] 

138 

139 

140def _create_rectangle_accessor(name: str, fallback: Iterable[str]) -> property: 

141 return property( 

142 lambda self: _get_rectangle(self, name, fallback), 

143 lambda self, value: _set_rectangle(self, name, value), 

144 lambda self: _delete_rectangle(self, name), 

145 ) 

146 

147 

148class Transformation: 

149 """ 

150 Represent a 2D transformation. 

151 

152 The transformation between two coordinate systems is represented by a 3-by-3 

153 transformation matrix with the following form:: 

154 

155 a b 0 

156 c d 0 

157 e f 1 

158 

159 Because a transformation matrix has only six elements that can be changed, 

160 it is usually specified in PDF as the six-element array [ a b c d e f ]. 

161 

162 Coordinate transformations are expressed as matrix multiplications:: 

163 

164 a b 0 

165 [ x′ y′ 1 ] = [ x y 1 ] × c d 0 

166 e f 1 

167 

168 

169 Example: 

170 >>> from pypdf import PdfWriter, Transformation 

171 >>> page = PdfWriter().add_blank_page(800, 600) 

172 >>> op = Transformation().scale(sx=2, sy=3).translate(tx=10, ty=20) 

173 >>> page.add_transformation(op) 

174 

175 """ 

176 

177 def __init__(self, ctm: CompressedTransformationMatrix = (1, 0, 0, 1, 0, 0)) -> None: 

178 self.ctm = ctm 

179 

180 @property 

181 def matrix(self) -> TransformationMatrixType: 

182 """ 

183 Return the transformation matrix as a tuple of tuples in the form: 

184 

185 ((a, b, 0), (c, d, 0), (e, f, 1)) 

186 """ 

187 return ( 

188 (self.ctm[0], self.ctm[1], 0), 

189 (self.ctm[2], self.ctm[3], 0), 

190 (self.ctm[4], self.ctm[5], 1), 

191 ) 

192 

193 @staticmethod 

194 def compress(matrix: TransformationMatrixType) -> CompressedTransformationMatrix: 

195 """ 

196 Compresses the transformation matrix into a tuple of (a, b, c, d, e, f). 

197 

198 Args: 

199 matrix: The transformation matrix as a tuple of tuples. 

200 

201 Returns: 

202 A tuple representing the transformation matrix as (a, b, c, d, e, f) 

203 

204 """ 

205 return ( 

206 matrix[0][0], 

207 matrix[0][1], 

208 matrix[1][0], 

209 matrix[1][1], 

210 matrix[2][0], 

211 matrix[2][1], 

212 ) 

213 

214 def _to_cm(self) -> str: 

215 # Returns the cm operation string for the given transformation matrix 

216 return ( 

217 f"{self.ctm[0]:.4f} {self.ctm[1]:.4f} {self.ctm[2]:.4f} " 

218 f"{self.ctm[3]:.4f} {self.ctm[4]:.4f} {self.ctm[5]:.4f} cm" 

219 ) 

220 

221 def transform(self, m: "Transformation") -> "Transformation": 

222 """ 

223 Apply one transformation to another. 

224 

225 Args: 

226 m: a Transformation to apply. 

227 

228 Returns: 

229 A new ``Transformation`` instance 

230 

231 Example: 

232 >>> from pypdf import PdfWriter, Transformation 

233 >>> height, width = 40, 50 

234 >>> page = PdfWriter().add_blank_page(800, 600) 

235 >>> op = Transformation((1, 0, 0, -1, 0, height)) # vertical mirror 

236 >>> op = Transformation().transform(Transformation((-1, 0, 0, 1, width, 0))) # horizontal mirror 

237 >>> page.add_transformation(op) 

238 

239 """ 

240 ctm = Transformation.compress(matrix_multiply(self.matrix, m.matrix)) 

241 return Transformation(ctm) 

242 

243 def translate(self, tx: float = 0, ty: float = 0) -> "Transformation": 

244 """ 

245 Translate the contents of a page. 

246 

247 Args: 

248 tx: The translation along the x-axis. 

249 ty: The translation along the y-axis. 

250 

251 Returns: 

252 A new ``Transformation`` instance 

253 

254 """ 

255 m = self.ctm 

256 return Transformation(ctm=(m[0], m[1], m[2], m[3], m[4] + tx, m[5] + ty)) 

257 

258 def scale( 

259 self, sx: Optional[float] = None, sy: Optional[float] = None 

260 ) -> "Transformation": 

261 """ 

262 Scale the contents of a page towards the origin of the coordinate system. 

263 

264 Typically, that is the lower-left corner of the page. That can be 

265 changed by translating the contents / the page boxes. 

266 

267 Args: 

268 sx: The scale factor along the x-axis. 

269 sy: The scale factor along the y-axis. 

270 

271 Returns: 

272 A new Transformation instance with the scaled matrix. 

273 

274 """ 

275 if sx is None and sy is None: 

276 raise ValueError("Either sx or sy must be specified") 

277 if sx is None: 

278 sx = sy 

279 if sy is None: 

280 sy = sx 

281 assert sx is not None 

282 assert sy is not None 

283 op: TransformationMatrixType = ((sx, 0, 0), (0, sy, 0), (0, 0, 1)) 

284 ctm = Transformation.compress(matrix_multiply(self.matrix, op)) 

285 return Transformation(ctm) 

286 

287 def rotate(self, rotation: float) -> "Transformation": 

288 """ 

289 Rotate the contents of a page. 

290 

291 Args: 

292 rotation: The angle of rotation in degrees. 

293 

294 Returns: 

295 A new ``Transformation`` instance with the rotated matrix. 

296 

297 """ 

298 rotation = math.radians(rotation) 

299 op: TransformationMatrixType = ( 

300 (math.cos(rotation), math.sin(rotation), 0), 

301 (-math.sin(rotation), math.cos(rotation), 0), 

302 (0, 0, 1), 

303 ) 

304 ctm = Transformation.compress(matrix_multiply(self.matrix, op)) 

305 return Transformation(ctm) 

306 

307 def __repr__(self) -> str: 

308 return f"Transformation(ctm={self.ctm})" 

309 

310 @overload 

311 def apply_on(self, pt: list[float], as_object: bool = False) -> list[float]: 

312 ... 

313 

314 @overload 

315 def apply_on( 

316 self, pt: tuple[float, float], as_object: bool = False 

317 ) -> tuple[float, float]: 

318 ... 

319 

320 def apply_on( 

321 self, 

322 pt: Union[tuple[float, float], list[float]], 

323 as_object: bool = False, 

324 ) -> Union[tuple[float, float], list[float]]: 

325 """ 

326 Apply the transformation matrix on the given point. 

327 

328 Args: 

329 pt: A tuple or list representing the point in the form (x, y). 

330 as_object: If True, return items as FloatObject, otherwise as plain floats. 

331 

332 Returns: 

333 A tuple or list representing the transformed point in the form (x', y') 

334 

335 """ 

336 typ = FloatObject if as_object else float 

337 pt1 = ( 

338 typ(float(pt[0]) * self.ctm[0] + float(pt[1]) * self.ctm[2] + self.ctm[4]), 

339 typ(float(pt[0]) * self.ctm[1] + float(pt[1]) * self.ctm[3] + self.ctm[5]), 

340 ) 

341 return list(pt1) if isinstance(pt, list) else pt1 

342 

343 

344@dataclass 

345class ImageFile: 

346 """ 

347 Image within the PDF file. *This object is not designed to be built.* 

348 

349 This object should not be modified except using :func:`ImageFile.replace` to replace the image with a new one. 

350 """ 

351 

352 name: str = "" 

353 """ 

354 Filename as identified within the PDF file. 

355 """ 

356 

357 data: bytes = b"" 

358 """ 

359 Data as bytes. 

360 """ 

361 

362 image: Optional[Image] = None 

363 """ 

364 Data as PIL image. 

365 """ 

366 

367 indirect_reference: Optional[IndirectObject] = None 

368 """ 

369 Reference to the object storing the stream. 

370 """ 

371 

372 is_inline: bool = False 

373 """ 

374 True if this is an inline image (~0~, ~1~, etc.). 

375 """ 

376 

377 is_displayed: bool = False 

378 """ 

379 True if this image is displayed in the page content stream. 

380 """ 

381 

382 def replace(self, new_image: Image, **kwargs: Any) -> None: 

383 """ 

384 Replace the image with a new PIL image. 

385 

386 Args: 

387 new_image (PIL.Image.Image): The new PIL image to replace the existing image. 

388 **kwargs: Additional keyword arguments to pass to `Image.save()`. 

389 

390 Raises: 

391 TypeError: If the image is inline or in a PdfReader. 

392 TypeError: If the image does not belong to a PdfWriter. 

393 TypeError: If `new_image` is not a PIL Image. 

394 

395 Note: 

396 This method replaces the existing image with a new image. 

397 It is not allowed for inline images or images within a PdfReader. 

398 The `kwargs` parameter allows passing additional parameters 

399 to `Image.save()`, such as quality. 

400 

401 """ 

402 if pil_not_imported: 

403 raise ImportError( 

404 "pillow is required to do image extraction. " 

405 "It can be installed via 'pip install pypdf[image]'" 

406 ) 

407 

408 from ._reader import PdfReader # noqa: PLC0415 

409 from .generic import DictionaryObject, PdfObject # noqa: PLC0415 

410 from .generic._image_xobject import _xobj_to_image # noqa: PLC0415 

411 

412 if self.indirect_reference is None: 

413 raise TypeError("Cannot update an inline image.") 

414 if not hasattr(self.indirect_reference.pdf, "_id_translated"): 

415 raise TypeError("Cannot update an image not belonging to a PdfWriter.") 

416 if not isinstance(new_image, Image): 

417 raise TypeError("new_image shall be a PIL Image") 

418 b = BytesIO() 

419 new_image.save(b, "PDF", **kwargs) 

420 reader = PdfReader(b) 

421 page_image = reader.pages[0].images[0] 

422 assert page_image.indirect_reference is not None 

423 self.indirect_reference.pdf._objects[self.indirect_reference.idnum - 1] = ( 

424 page_image.indirect_reference.get_object() 

425 ) 

426 cast( 

427 PdfObject, self.indirect_reference.get_object() 

428 ).indirect_reference = self.indirect_reference 

429 # change the object attributes 

430 extension, byte_stream, img = _xobj_to_image( 

431 cast(DictionaryObject, self.indirect_reference.get_object()), 

432 pillow_parameters=kwargs, 

433 ) 

434 assert extension is not None 

435 self.name = self.name[: self.name.rfind(".")] + extension 

436 self.data = byte_stream 

437 self.image = img 

438 

439 def __str__(self) -> str: 

440 return f"{self.__class__.__name__}(name={self.name}, data: {_human_readable_bytes(len(self.data))})" 

441 

442 def __repr__(self) -> str: 

443 return self.__str__()[:-1] + f", hash: {hash(self.data)})" 

444 

445 

446class VirtualListImages(Sequence[ImageFile]): 

447 """ 

448 Provides access to images referenced within a page. 

449 Only one copy will be returned if the usage is used on the same page multiple times. 

450 See :func:`PageObject.images` for more details. 

451 """ 

452 

453 def __init__( 

454 self, 

455 ids_function: Callable[[], list[Union[str, list[str]]]], 

456 get_function: Callable[[Union[str, list[str], tuple[str]]], ImageFile], 

457 ) -> None: 

458 self.ids_function = ids_function 

459 self.get_function = get_function 

460 self.current = -1 

461 

462 def __len__(self) -> int: 

463 return len(self.ids_function()) 

464 

465 def keys(self) -> list[Union[str, list[str]]]: 

466 return self.ids_function() 

467 

468 def items(self) -> list[tuple[Union[str, list[str]], ImageFile]]: 

469 return [(x, self[x]) for x in self.ids_function()] 

470 

471 @overload 

472 def __getitem__(self, index: Union[int, str, list[str]]) -> ImageFile: 

473 ... 

474 

475 @overload 

476 def __getitem__(self, index: slice) -> Sequence[ImageFile]: 

477 ... 

478 

479 def __getitem__( 

480 self, index: Union[int, slice, str, list[str], tuple[str]] 

481 ) -> Union[ImageFile, Sequence[ImageFile]]: 

482 lst = self.ids_function() 

483 if isinstance(index, slice): 

484 indices = range(*index.indices(len(self))) 

485 lst = [lst[x] for x in indices] 

486 cls = type(self) 

487 return cls((lambda: lst), self.get_function) 

488 if isinstance(index, (str, list, tuple)): 

489 return self.get_function(index) 

490 if not isinstance(index, int): 

491 raise TypeError("Invalid sequence indices type") 

492 len_self = len(lst) 

493 if index < 0: 

494 # support negative indexes 

495 index += len_self 

496 if not (0 <= index < len_self): 

497 raise IndexError("Sequence index out of range") 

498 return self.get_function(lst[index]) 

499 

500 def __iter__(self) -> Iterator[ImageFile]: 

501 for i in range(len(self)): 

502 yield self[i] 

503 

504 def __str__(self) -> str: 

505 p = [f"Image_{i}={n}" for i, n in enumerate(self.ids_function())] 

506 return f"[{', '.join(p)}]" 

507 

508 

509class PageObject(DictionaryObject): 

510 """ 

511 PageObject represents a single page within a PDF file. 

512 

513 Typically these objects will be created by accessing the 

514 :attr:`pages<pypdf.PdfReader.pages>` property of the 

515 :class:`PdfReader<pypdf.PdfReader>` class, but it is 

516 also possible to create an empty page with the 

517 :meth:`create_blank_page()<pypdf._page.PageObject.create_blank_page>` static method. 

518 

519 Args: 

520 pdf: PDF file the page belongs to. 

521 indirect_reference: Stores the original indirect reference to 

522 this object in its source PDF 

523 

524 """ 

525 

526 original_page: "PageObject" # very local use in writer when appending 

527 

528 def __init__( 

529 self, 

530 pdf: Optional[PdfCommonDocProtocol] = None, 

531 indirect_reference: Optional[IndirectObject] = None, 

532 ) -> None: 

533 DictionaryObject.__init__(self) 

534 self.pdf = pdf 

535 self._content_stream_images: Optional[dict[str, Optional[ImageFile]]] = None 

536 self.indirect_reference = indirect_reference 

537 if not is_null_or_none(indirect_reference): 

538 assert indirect_reference is not None, "mypy" 

539 self.update(cast(DictionaryObject, indirect_reference.get_object())) 

540 

541 def hash_bin(self) -> int: 

542 """ 

543 Used to detect modified object. 

544 

545 Note: this function is overloaded to return the same results 

546 as a DictionaryObject. 

547 

548 Returns: 

549 Hash considering type and value. 

550 

551 """ 

552 return hash( 

553 (DictionaryObject, tuple(((k, v.hash_bin()) for k, v in self.items()))) 

554 ) 

555 

556 def hash_value_data(self) -> bytes: 

557 data = super().hash_value_data() 

558 data += f"{id(self)}".encode() 

559 return data 

560 

561 @property 

562 def user_unit(self) -> float: 

563 """ 

564 A read-only positive number giving the size of user space units. 

565 

566 It is in multiples of 1/72 inch. Hence a value of 1 means a user 

567 space unit is 1/72 inch, and a value of 3 means that a user 

568 space unit is 3/72 inch. 

569 """ 

570 return cast(float, self.get(PG.USER_UNIT, 1)) 

571 

572 @staticmethod 

573 def create_blank_page( 

574 pdf: Optional[PdfCommonDocProtocol] = None, 

575 width: Union[float, Decimal, None] = None, 

576 height: Union[float, Decimal, None] = None, 

577 ) -> "PageObject": 

578 """ 

579 Return a new blank page. 

580 

581 If ``width`` or ``height`` is ``None``, try to get the page size 

582 from the last page of *pdf*. 

583 

584 Args: 

585 pdf: PDF file the page is within. 

586 width: The width of the new page expressed in default user 

587 space units. 

588 height: The height of the new page expressed in default user 

589 space units. 

590 

591 Returns: 

592 The new blank page 

593 

594 Raises: 

595 PageSizeNotDefinedError: if ``pdf`` is ``None`` or contains 

596 no page 

597 

598 """ 

599 page = PageObject(pdf) 

600 

601 # Creates a new page (cf PDF Reference §7.7.3.3) 

602 page.__setitem__(NameObject(PG.TYPE), NameObject("/Page")) 

603 page.__setitem__(NameObject(PG.PARENT), NullObject()) 

604 page.__setitem__(NameObject(PG.RESOURCES), DictionaryObject()) 

605 if width is None or height is None: 

606 if pdf is not None and len(pdf.pages) > 0: 

607 lastpage = pdf.pages[len(pdf.pages) - 1] 

608 width = lastpage.mediabox.width 

609 height = lastpage.mediabox.height 

610 else: 

611 raise PageSizeNotDefinedError 

612 page.__setitem__( 

613 NameObject(PG.MEDIABOX), RectangleObject((0, 0, width, height)) # type: ignore[arg-type] 

614 ) 

615 

616 return page 

617 

618 def _get_ids_image( 

619 self, 

620 obj: Optional[DictionaryObject] = None, 

621 ancest: Optional[list[str]] = None, 

622 call_stack: Optional[list[Any]] = None, 

623 ) -> list[Union[str, list[str]]]: 

624 if call_stack is None: 

625 call_stack = [] 

626 _i = getattr(obj, "indirect_reference", None) 

627 if _i in call_stack: 

628 return [] 

629 call_stack.append(_i) 

630 if self._content_stream_images is None: 

631 self._content_stream_images = self._parse_images_from_content_stream() 

632 if obj is None: 

633 obj = self 

634 if ancest is None: 

635 ancest = [] 

636 lst: list[Union[str, list[str]]] = [] 

637 if ( 

638 PG.RESOURCES not in obj or 

639 is_null_or_none(resources := obj[PG.RESOURCES]) or 

640 RES.XOBJECT not in cast(DictionaryObject, resources) 

641 ): 

642 # Forms without XObject resources have no images inside them 

643 if len(ancest) > 0: 

644 return [] 

645 # for inline images, cache dict entries are not None 

646 return [image_name for image_name, image_value in self._content_stream_images.items() if image_value] 

647 

648 x_object = resources[RES.XOBJECT].get_object() # type: ignore 

649 

650 # Iterate through all XObject resources 

651 for o in x_object: 

652 # Skip non-stream objects (only process StreamObject) 

653 if not isinstance(x_object[o], StreamObject): 

654 continue 

655 if x_object[o][ImageAttributes.SUBTYPE] == "/Image": 

656 # If it's an image, add it to lst for further processing 

657 lst.append(o if len(ancest) == 0 else [*ancest, o]) 

658 else: 

659 # If it's a form, recursively search for images inside it 

660 # Forms may contain images that are Do-referenced in their content stream 

661 lst.extend(self._get_ids_image(x_object[o], [*ancest, o], call_stack)) 

662 

663 # Removes duplicates and preserves order 

664 deduplicated = lst.copy() 

665 

666 # Add inline images from _content_stream_images 

667 for object_name, object_value in self._content_stream_images.items(): 

668 # inline images have cache populated 

669 if object_name not in deduplicated and object_value: 

670 deduplicated.append(object_name) 

671 

672 return deduplicated 

673 

674 def _get_image( 

675 self, 

676 id: Union[str, list[str], tuple[str]], 

677 obj: Optional[DictionaryObject] = None, 

678 ) -> ImageFile: 

679 if obj is None: 

680 obj = cast(DictionaryObject, self) 

681 if isinstance(id, tuple): 

682 id = list(id) 

683 if isinstance(id, list) and len(id) == 1: 

684 id = id[0] 

685 xobjs: Optional[DictionaryObject] = None 

686 try: 

687 xobjs = cast( 

688 DictionaryObject, cast(DictionaryObject, obj[PG.RESOURCES])[RES.XOBJECT] 

689 ) 

690 except KeyError as exc: 

691 if not (id[0] == "~" and id[-1] == "~"): 

692 raise KeyError( 

693 f"Cannot access image object {id} without XObject resources" 

694 ) from exc 

695 if isinstance(id, str): 

696 if id[0] == "~" and id[-1] == "~": 

697 if self._content_stream_images is None: 

698 self._content_stream_images = self._parse_images_from_content_stream() 

699 if id not in self._content_stream_images: 

700 raise KeyError(f"Image {id} not found") 

701 image_file = self._content_stream_images[id] 

702 assert image_file is not None 

703 return image_file 

704 

705 # Do-referenced image name (non-inline string keys like /Im0) 

706 assert xobjs is not None 

707 if id not in xobjs: 

708 raise KeyError(f"Image {id} not found") 

709 xobj = cast(DictionaryObject, xobjs[id]) 

710 if xobj.get(ImageAttributes.SUBTYPE, "") != "/Image": 

711 raise KeyError(f"XObject {id} is not an image") 

712 

713 # Check if displayed (in content stream) 

714 is_displayed = self._content_stream_images is not None and id in self._content_stream_images 

715 

716 from .generic._image_xobject import _xobj_to_image # noqa: PLC0415 

717 extension, byte_stream, img = _xobj_to_image(xobj) 

718 return ImageFile( 

719 name=f"{id[1:]}{extension}", 

720 data=byte_stream, 

721 image=img, 

722 indirect_reference=xobj.indirect_reference, 

723 is_inline=False, 

724 is_displayed=is_displayed, 

725 ) 

726 # in a subobject 

727 assert xobjs is not None 

728 ids = id[1:] 

729 return self._get_image(ids, cast(DictionaryObject, xobjs[id[0]])) 

730 

731 @property 

732 def images(self) -> VirtualListImages: 

733 """ 

734 Read-only property emulating a list of images on a page. 

735 

736 Get a list of all images on the page. The key can be: 

737 - A string (for the top object) 

738 - A tuple (for images within XObject forms) 

739 - An integer 

740 

741 Examples: 

742 * `reader.pages[0].images[0]` # return first image 

743 * `reader.pages[0].images['/I0']` # return image '/I0' 

744 * `reader.pages[0].images['/TP1','/Image1']` # return image '/Image1' within '/TP1' XObject form 

745 * `for img in reader.pages[0].images:` # loops through all objects 

746 

747 Example usage: 

748 

749 >>> from pypdf import PdfWriter 

750 >>> writer = PdfWriter() 

751 >>> page = writer.add_blank_page(800, 600) 

752 >>> images = page.images 

753 

754 """ 

755 return VirtualListImages(self._get_ids_image, self._get_image) 

756 

757 @property 

758 def inline_images(self) -> Optional[dict[str, ImageFile]]: 

759 """ 

760 Return only inline images from the page. 

761 

762 .. deprecated:: 

763 Use :attr:`images` and filter by :attr:`ImageFile.is_inline` instead. 

764 This property will be removed in pypdf 7.0. 

765 

766 Examples: 

767 >>> from pypdf import PdfWriter 

768 >>> writer = PdfWriter() 

769 >>> page = writer.add_blank_page(800,600) 

770 >>> for image_name, image_file in page.images.items(): 

771 ... if image_file.is_inline: 

772 ... print(f"{image_name} is inline") 

773 """ 

774 deprecate_with_replacement( 

775 "PageObject.inline_images", 

776 "PageObject.images", 

777 "7.0.0", 

778 ) 

779 if self._content_stream_images is None: 

780 return None 

781 return { 

782 image_name: image_file 

783 for image_name, image_file in self._content_stream_images.items() 

784 if image_file and image_file.is_inline # for inline images, image_file is populated 

785 } 

786 

787 @inline_images.setter 

788 def inline_images(self, value: Optional[dict[str, ImageFile]]) -> None: 

789 deprecate_no_replacement( 

790 "PageObject.inline_images", 

791 "7.0.0", 

792 ) 

793 if value is None: 

794 self._content_stream_images = None 

795 else: 

796 assert self._content_stream_images is not None, "Can't edit inline_images before accessing images" 

797 self._content_stream_images.update(value) 

798 

799 def _translate_value_inline_image(self, k: str, v: PdfObject) -> PdfObject: 

800 """Translate values used in inline image""" 

801 try: 

802 v = NameObject(_INLINE_IMAGE_VALUE_MAPPING[cast(str, v)]) 

803 except (TypeError, KeyError): 

804 if isinstance(v, NameObject): 

805 # It is a custom name, thus we have to look in resources. 

806 # The only applicable case is for ColorSpace. 

807 try: 

808 res = cast(DictionaryObject, self["/Resources"])["/ColorSpace"] 

809 v = cast(DictionaryObject, res)[v] 

810 except KeyError: # for res and v 

811 raise PdfReadError(f"Cannot find resource entry {v} for {k}") 

812 return v 

813 

814 def _parse_images_from_content_stream(self) -> dict[str, Optional[ImageFile]]: 

815 """Load images from content stream. Includes both inline images and Do-referenced images. 

816 

817 This method scans the page content stream and extracts: 

818 

819 1. **Inline images** (~0~, ~1~...): Embedded directly in content stream via BI/EI operators 

820 - is_inline=True, is_displayed=True, indirect_reference=None 

821 

822 2. **Do-referenced objects** (/Im0, /Im1..., /Form1...): Referenced via "Do" operator 

823 - is_inline=False, is_displayed=True, indirect_reference=<image object> 

824 

825 3. **Pure XObject images** (/I0, /Image1...): Defined in Resources only (not in content stream) 

826 - is_inline=False, is_displayed=False, indirect_reference=<image object> 

827 

828 Returns: 

829 Dictionary mapping names to ImageFile instances (inline) or None (Do-referenced). 

830 """ 

831 content = self.get_contents() 

832 if is_null_or_none(content): 

833 return {} 

834 imgs_data = [] 

835 do_image_names: list[bytes] = [] 

836 assert content is not None, "mypy" 

837 for param, ope in content.operations: 

838 if ope == b"INLINE IMAGE": 

839 imgs_data.append( 

840 {"settings": param["settings"], "__streamdata__": param["data"]} 

841 ) 

842 elif ope == b"Do" and param: 

843 do_image_names.append(param[0]) # First operand is the XObject name 

844 elif ope in (b"BI", b"EI", b"ID"): # pragma: no cover 

845 raise PdfReadError( 

846 f"{ope!r} operator met whereas not expected, " 

847 "please share use case with pypdf dev team" 

848 ) 

849 

850 files: dict[str, Optional[ImageFile]] = {} 

851 # Process Do-referenced objects first (images + forms, no subtype check) 

852 files = dict.fromkeys(list(map(str, do_image_names)), None) 

853 

854 # Then process inline images 

855 for num, ii in enumerate(imgs_data): 

856 init = { 

857 "__streamdata__": ii["__streamdata__"], 

858 "/Length": len(ii["__streamdata__"]), 

859 } 

860 for k, v in ii["settings"].items(): 

861 if k in {"/Length", "/L"}: # no length is expected 

862 continue 

863 if isinstance(v, list): 

864 v = ArrayObject( 

865 [self._translate_value_inline_image(k, x) for x in v] 

866 ) 

867 else: 

868 v = self._translate_value_inline_image(k, v) 

869 if k in _INLINE_IMAGE_KEY_MAPPING: 

870 k = NameObject(_INLINE_IMAGE_KEY_MAPPING[k]) 

871 else: 

872 logger_warning( 

873 "Unknown inline image key %(key)s, keeping it as-is.", 

874 source=__name__, 

875 key=k, 

876 ) 

877 k = NameObject(k) 

878 if k not in init: 

879 init[k] = v 

880 ii["object"] = EncodedStreamObject.initialize_from_dictionary(init) 

881 from .generic._image_xobject import _xobj_to_image # noqa: PLC0415 

882 extension, byte_stream, img = _xobj_to_image(ii["object"]) 

883 files[f"~{num}~"] = ImageFile( 

884 name=f"~{num}~{extension}", 

885 data=byte_stream, 

886 image=img, 

887 indirect_reference=None, 

888 is_inline=True, 

889 is_displayed=True, 

890 ) 

891 

892 return files 

893 

894 @property 

895 def rotation(self) -> int: 

896 """ 

897 The visual rotation of the page. 

898 

899 This number has to be a multiple of 90 degrees: 0, 90, 180, or 270 are 

900 valid values. This property does not affect ``/Contents``. 

901 """ 

902 rotate_obj = self.get(PG.ROTATE, 0) 

903 return rotate_obj if isinstance(rotate_obj, int) else rotate_obj.get_object() 

904 

905 @rotation.setter 

906 def rotation(self, r: float) -> None: 

907 self[NameObject(PG.ROTATE)] = NumberObject((((int(r) + 45) // 90) * 90) % 360) 

908 

909 def transfer_rotation_to_content(self) -> None: 

910 """ 

911 Apply the rotation of the page to the content and the media/crop/... 

912 boxes. 

913 

914 It is recommended to apply this function before page merging. 

915 """ 

916 r = -self.rotation # rotation to apply is in the otherway 

917 self.rotation = 0 

918 mb = RectangleObject(self.mediabox) 

919 trsf = ( 

920 Transformation() 

921 .translate( 

922 -float(mb.left + mb.width / 2), -float(mb.bottom + mb.height / 2) 

923 ) 

924 .rotate(r) 

925 ) 

926 pt1 = trsf.apply_on(mb.lower_left) 

927 pt2 = trsf.apply_on(mb.upper_right) 

928 trsf = trsf.translate(-min(pt1[0], pt2[0]), -min(pt1[1], pt2[1])) 

929 self.add_transformation(trsf, False) 

930 for b in ["/MediaBox", "/CropBox", "/BleedBox", "/TrimBox", "/ArtBox"]: 

931 if b in self: 

932 rr = RectangleObject(self[b]) # type: ignore[arg-type] 

933 pt1 = trsf.apply_on(rr.lower_left) 

934 pt2 = trsf.apply_on(rr.upper_right) 

935 self[NameObject(b)] = RectangleObject( 

936 ( 

937 min(pt1[0], pt2[0]), 

938 min(pt1[1], pt2[1]), 

939 max(pt1[0], pt2[0]), 

940 max(pt1[1], pt2[1]), 

941 ) 

942 ) 

943 

944 def rotate(self, angle: int) -> "PageObject": 

945 """ 

946 Rotate a page clockwise by increments of 90 degrees. 

947 

948 Args: 

949 angle: Angle to rotate the page. Must be an increment of 90 deg. 

950 

951 Returns: 

952 The rotated PageObject 

953 

954 """ 

955 if angle % 90 != 0: 

956 raise ValueError("Rotation angle must be a multiple of 90") 

957 self[NameObject(PG.ROTATE)] = NumberObject(self.rotation + angle) 

958 return self 

959 

960 def _merge_resources( 

961 self, 

962 res1: DictionaryObject, 

963 res2: DictionaryObject, 

964 resource: Any, 

965 new_res1: bool = True, 

966 ) -> tuple[dict[str, Any], dict[str, Any]]: 

967 try: 

968 assert isinstance(self.indirect_reference, IndirectObject) 

969 pdf = self.indirect_reference.pdf 

970 is_pdf_writer = hasattr( 

971 pdf, "_add_object" 

972 ) # expect isinstance(pdf, PdfWriter) 

973 except (AssertionError, AttributeError): 

974 pdf = None 

975 is_pdf_writer = False 

976 

977 def compute_unique_key(base_key: str) -> tuple[str, bool]: 

978 """ 

979 Find a key that either doesn't already exist or has the same value 

980 (indicated by the bool) 

981 

982 Args: 

983 base_key: An index is added to this to get the computed key 

984 

985 Returns: 

986 A tuple (computed key, bool) where the boolean indicates 

987 if there is a resource of the given computed_key with the same 

988 value. 

989 

990 """ 

991 value = page2res.raw_get(base_key) 

992 # TODO: a possible improvement for writer, the indirect_reference 

993 # cannot be found because translated 

994 

995 # try the current key first (e.g. "foo"), but otherwise iterate 

996 # through "foo-0", "foo-1", etc. new_res can contain only finitely 

997 # many keys, thus this'll eventually end, even if it's been crafted 

998 # to be maximally annoying. 

999 computed_key = base_key 

1000 idx = 0 

1001 while computed_key in new_res: 

1002 if new_res.raw_get(computed_key) == value: 

1003 # there's already a resource of this name, with the exact 

1004 # same value 

1005 return computed_key, True 

1006 computed_key = f"{base_key}-{idx}" 

1007 idx += 1 

1008 return computed_key, False 

1009 

1010 if new_res1: 

1011 new_res = DictionaryObject() 

1012 new_res.update(res1.get(resource, DictionaryObject()).get_object()) 

1013 else: 

1014 new_res = cast(DictionaryObject, res1[resource]) 

1015 page2res = cast( 

1016 DictionaryObject, res2.get(resource, DictionaryObject()).get_object() 

1017 ) 

1018 rename_res = {} 

1019 for key in page2res: 

1020 unique_key, same_value = compute_unique_key(key) 

1021 newname = NameObject(unique_key) 

1022 if key != unique_key: 

1023 # we have to use a different name for this 

1024 rename_res[key] = newname 

1025 

1026 if not same_value: 

1027 if is_pdf_writer: 

1028 new_res[newname] = page2res.raw_get(key).clone(pdf) 

1029 try: 

1030 new_res[newname] = new_res[newname].indirect_reference 

1031 except AttributeError: 

1032 pass 

1033 else: 

1034 new_res[newname] = page2res.raw_get(key) 

1035 lst = sorted(new_res.items()) 

1036 new_res.clear() 

1037 for el in lst: 

1038 new_res[el[0]] = el[1] 

1039 return new_res, rename_res 

1040 

1041 @staticmethod 

1042 def _content_stream_rename( 

1043 stream: ContentStream, 

1044 rename: dict[Any, Any], 

1045 pdf: Optional[PdfCommonDocProtocol], 

1046 ) -> ContentStream: 

1047 if not rename: 

1048 return stream 

1049 stream = ContentStream(stream, pdf) 

1050 for operands, _operator in stream.operations: 

1051 if isinstance(operands, list): 

1052 for i, op in enumerate(operands): 

1053 if isinstance(op, NameObject): 

1054 operands[i] = rename.get(op, op) 

1055 elif isinstance(operands, dict): 

1056 for i, op in operands.items(): 

1057 if isinstance(op, NameObject): 

1058 operands[i] = rename.get(op, op) 

1059 else: 

1060 raise KeyError(f"Type of operands is {type(operands)}") 

1061 return stream 

1062 

1063 @staticmethod 

1064 def _add_transformation_matrix( 

1065 contents: Any, 

1066 pdf: Optional[PdfCommonDocProtocol], 

1067 ctm: CompressedTransformationMatrix, 

1068 ) -> ContentStream: 

1069 """Add transformation matrix at the beginning of the given contents stream.""" 

1070 content_stream = ContentStream(contents, pdf) 

1071 content_stream.operations.insert( 

1072 0, 

1073 ( 

1074 [FloatObject(x) for x in ctm], 

1075 b"cm", 

1076 ), 

1077 ) 

1078 return content_stream 

1079 

1080 def _get_contents_as_bytes(self) -> Optional[bytes]: 

1081 """ 

1082 Return the page contents as bytes. 

1083 

1084 Returns: 

1085 The ``/Contents`` object as bytes, or ``None`` if it doesn't exist. 

1086 

1087 """ 

1088 if PG.CONTENTS in self: 

1089 obj = self[PG.CONTENTS].get_object() 

1090 if isinstance(obj, list): 

1091 return b"".join(x.get_object().get_data() for x in obj) 

1092 return cast(EncodedStreamObject, obj).get_data() 

1093 return None 

1094 

1095 def get_contents(self) -> Optional[ContentStream]: 

1096 """ 

1097 Access the page contents. 

1098 

1099 Returns: 

1100 The ``/Contents`` object, or ``None`` if it does not exist. 

1101 ``/Contents`` is optional, as described in §7.7.3.3 of the PDF Reference. 

1102 

1103 """ 

1104 if PG.CONTENTS in self: 

1105 try: 

1106 pdf = cast(IndirectObject, self.indirect_reference).pdf 

1107 except AttributeError: 

1108 pdf = None 

1109 obj = self[PG.CONTENTS] 

1110 if is_null_or_none(obj): 

1111 return None 

1112 resolved_object = obj.get_object() 

1113 return ContentStream(resolved_object, pdf) 

1114 return None 

1115 

1116 def replace_contents( 

1117 self, content: Union[None, ContentStream, EncodedStreamObject, ArrayObject] 

1118 ) -> None: 

1119 """ 

1120 Replace the page contents with the new content and nullify old objects 

1121 Args: 

1122 content: new content; if None delete the content field. 

1123 """ 

1124 if not hasattr(self, "indirect_reference") or self.indirect_reference is None: 

1125 # the page is not attached : the content is directly attached. 

1126 self[NameObject(PG.CONTENTS)] = content 

1127 return 

1128 

1129 from pypdf._writer import PdfWriter # noqa: PLC0415 

1130 if not isinstance(self.indirect_reference.pdf, PdfWriter): 

1131 deprecate( 

1132 "Calling `PageObject.replace_contents()` for pages not assigned to a writer is deprecated " 

1133 "and will be removed in pypdf 7.0.0. Attach the page to the writer first or use " 

1134 "`PdfWriter(clone_from=...)` directly. The existing approach has proved being unreliable." 

1135 ) 

1136 

1137 writer = self.indirect_reference.pdf 

1138 if isinstance(self.get(PG.CONTENTS, None), ArrayObject): 

1139 content_array = cast(ArrayObject, self[PG.CONTENTS]) 

1140 for reference in content_array: 

1141 try: 

1142 writer._replace_object(indirect_reference=reference.indirect_reference, obj=NullObject()) 

1143 except ValueError: 

1144 # Occurs when called on PdfReader. 

1145 pass 

1146 

1147 if isinstance(content, ArrayObject): 

1148 content = ArrayObject(writer._add_object(obj) for obj in content) 

1149 

1150 if is_null_or_none(content): 

1151 if PG.CONTENTS not in self: 

1152 return 

1153 assert self[PG.CONTENTS].indirect_reference is not None 

1154 writer._replace_object(indirect_reference=self[PG.CONTENTS].indirect_reference, obj=NullObject()) 

1155 del self[PG.CONTENTS] 

1156 elif not hasattr(self.get(PG.CONTENTS, None), "indirect_reference"): 

1157 try: 

1158 self[NameObject(PG.CONTENTS)] = writer._add_object(content) 

1159 except AttributeError: 

1160 # applies at least for page not in writer 

1161 # as a backup solution, we put content as an object although not in accordance with pdf ref 

1162 # this will be fixed with the _add_object 

1163 self[NameObject(PG.CONTENTS)] = content 

1164 else: 

1165 assert content is not None, "mypy" 

1166 content.indirect_reference = self[ 

1167 PG.CONTENTS 

1168 ].indirect_reference # TODO: in the future may require generation management 

1169 try: 

1170 writer._replace_object(indirect_reference=content.indirect_reference, obj=content) 

1171 except AttributeError: 

1172 # applies at least for page not in writer 

1173 # as a backup solution, we put content as an object although not in accordance with pdf ref 

1174 # this will be fixed with the _add_object 

1175 self[NameObject(PG.CONTENTS)] = content 

1176 # forces recalculation of images 

1177 self._content_stream_images = None 

1178 

1179 def merge_page( 

1180 self, page2: "PageObject", expand: bool = False, over: bool = True 

1181 ) -> None: 

1182 """ 

1183 Merge the content streams of two pages into one. 

1184 

1185 Resource references (e.g. fonts) are maintained from both pages. 

1186 The mediabox, cropbox, etc of this page are not altered. 

1187 The parameter page's content stream will 

1188 be added to the end of this page's content stream, 

1189 meaning that it will be drawn after, or "on top" of this page. 

1190 

1191 Args: 

1192 page2: The page to be merged into this one. Should be 

1193 an instance of :class:`PageObject<PageObject>`. 

1194 over: set the page2 content over page1 if True (default) else under 

1195 expand: If True, the current page dimensions will be 

1196 expanded to accommodate the dimensions of the page to be merged. 

1197 

1198 """ 

1199 self._merge_page(page2, over=over, expand=expand) 

1200 

1201 def _merge_page( 

1202 self, 

1203 page2: "PageObject", 

1204 page2_transformation: Optional[Callable[[Any], ContentStream]] = None, 

1205 ctm: Optional[CompressedTransformationMatrix] = None, 

1206 over: bool = True, 

1207 expand: bool = False, 

1208 ) -> None: 

1209 # First we work on merging the resource dictionaries. This allows us 

1210 # to find out what symbols in the content streams we might need to 

1211 # rename. 

1212 try: 

1213 assert isinstance(self.indirect_reference, IndirectObject) 

1214 if hasattr(self.indirect_reference.pdf, "_add_object"): # to detect PdfWriter 

1215 return self._merge_page_writer( 

1216 page2, page2_transformation, ctm, over, expand 

1217 ) 

1218 except (AssertionError, AttributeError): 

1219 pass 

1220 

1221 new_resources = DictionaryObject() 

1222 rename: dict[str, Any] = {} 

1223 original_resources = cast(DictionaryObject, self.get(PG.RESOURCES, DictionaryObject()).get_object()) 

1224 page2_resources = cast(DictionaryObject, page2.get(PG.RESOURCES, DictionaryObject()).get_object()) 

1225 new_annots = ArrayObject() 

1226 

1227 for page in (self, page2): 

1228 if PG.ANNOTS in page: 

1229 annots = page[PG.ANNOTS] 

1230 if isinstance(annots, ArrayObject): 

1231 new_annots.extend(annots) 

1232 self[NameObject(PG.ANNOTS)] = new_annots 

1233 

1234 for res in ( 

1235 RES.EXT_G_STATE, 

1236 RES.COLOR_SPACE, 

1237 RES.PATTERN, 

1238 RES.SHADING, 

1239 RES.XOBJECT, 

1240 RES.FONT, 

1241 RES.PROPERTIES, 

1242 ): 

1243 new, new_resource_name = self._merge_resources( 

1244 original_resources, page2_resources, res 

1245 ) 

1246 if new: 

1247 new_resources[NameObject(res)] = new 

1248 rename.update(new_resource_name) 

1249 

1250 # Combine /ProcSet sets, making sure there is a consistent order 

1251 new_resources[NameObject(RES.PROC_SET)] = ArrayObject( 

1252 sorted( 

1253 set( 

1254 original_resources.get(RES.PROC_SET, ArrayObject()).get_object() 

1255 ).union( 

1256 set(page2_resources.get(RES.PROC_SET, ArrayObject()).get_object()) 

1257 ) 

1258 ) 

1259 ) 

1260 

1261 new_content_array = ArrayObject() 

1262 original_content = self.get_contents() 

1263 if original_content is not None: 

1264 original_content.isolate_graphics_state() 

1265 new_content_array.append(original_content) 

1266 

1267 page2_content = page2.get_contents() 

1268 if page2_content is not None: 

1269 rect = getattr(page2, MERGE_CROP_BOX) 

1270 page2_content.operations.insert( 

1271 0, 

1272 ( 

1273 map( 

1274 FloatObject, 

1275 [ 

1276 rect.left, 

1277 rect.bottom, 

1278 rect.width, 

1279 rect.height, 

1280 ], 

1281 ), 

1282 b"re", 

1283 ), 

1284 ) 

1285 page2_content.operations.insert(1, ([], b"W")) 

1286 page2_content.operations.insert(2, ([], b"n")) 

1287 if page2_transformation is not None: 

1288 page2_content = page2_transformation(page2_content) 

1289 page2_content = PageObject._content_stream_rename( 

1290 page2_content, rename, self.pdf 

1291 ) 

1292 page2_content.isolate_graphics_state() 

1293 if over: 

1294 new_content_array.append(page2_content) 

1295 else: 

1296 new_content_array.insert(0, page2_content) 

1297 

1298 # if expanding the page to fit a new page, calculate the new media box size 

1299 if expand: 

1300 self._expand_mediabox(page2, ctm) 

1301 

1302 self.replace_contents(ContentStream(new_content_array, self.pdf)) 

1303 self[NameObject(PG.RESOURCES)] = new_resources 

1304 

1305 return None 

1306 

1307 def _merge_page_writer( 

1308 self, 

1309 page2: "PageObject", 

1310 page2transformation: Optional[Callable[[Any], ContentStream]] = None, 

1311 ctm: Optional[CompressedTransformationMatrix] = None, 

1312 over: bool = True, 

1313 expand: bool = False, 

1314 ) -> None: 

1315 # First we work on merging the resource dictionaries. This allows us 

1316 # to find which symbols in the content streams we might need to 

1317 # rename. 

1318 assert isinstance(self.indirect_reference, IndirectObject) 

1319 pdf = self.indirect_reference.pdf 

1320 

1321 if PG.RESOURCES not in self: 

1322 self[NameObject(PG.RESOURCES)] = DictionaryObject() 

1323 original_resources = cast(DictionaryObject, self[PG.RESOURCES].get_object()) 

1324 if PG.RESOURCES not in page2: 

1325 page2resources = DictionaryObject() 

1326 else: 

1327 page2resources = cast(DictionaryObject, page2[PG.RESOURCES].get_object()) 

1328 

1329 rename = {} 

1330 for res in ( 

1331 RES.EXT_G_STATE, 

1332 RES.COLOR_SPACE, 

1333 RES.PATTERN, 

1334 RES.SHADING, 

1335 RES.XOBJECT, 

1336 RES.FONT, 

1337 RES.PROPERTIES, 

1338 ): 

1339 if res in page2resources: 

1340 if res not in original_resources: 

1341 original_resources[NameObject(res)] = DictionaryObject() 

1342 _, newrename = self._merge_resources( 

1343 original_resources, page2resources, res, False 

1344 ) 

1345 rename.update(newrename) 

1346 # Combine /ProcSet sets 

1347 if RES.PROC_SET in page2resources: 

1348 if RES.PROC_SET not in original_resources: 

1349 original_resources[NameObject(RES.PROC_SET)] = ArrayObject() 

1350 arr = cast(ArrayObject, original_resources[RES.PROC_SET]) 

1351 for x in cast(ArrayObject, page2resources[RES.PROC_SET]): 

1352 if x not in arr: 

1353 arr.append(x) 

1354 arr.sort() 

1355 

1356 if not is_null_or_none(page2.get(PG.ANNOTS, None)): 

1357 if PG.ANNOTS not in self: 

1358 self[NameObject(PG.ANNOTS)] = ArrayObject() 

1359 annots = cast(ArrayObject, self[PG.ANNOTS].get_object()) 

1360 if ctm is None: 

1361 trsf = Transformation() 

1362 else: 

1363 trsf = Transformation(ctm) 

1364 # Ensure we are working on a copy of the list. Otherwise, if both pages 

1365 # are the same object, we might run into an infinite loop. 

1366 for a in cast(ArrayObject, deepcopy(page2[PG.ANNOTS])): 

1367 a = a.get_object() 

1368 aa = a.clone( 

1369 pdf, 

1370 ignore_fields=("/P", "/StructParent", "/Parent"), 

1371 force_duplicate=True, 

1372 ) 

1373 r = cast(ArrayObject, a["/Rect"]) 

1374 pt1 = trsf.apply_on((r[0], r[1]), True) 

1375 pt2 = trsf.apply_on((r[2], r[3]), True) 

1376 aa[NameObject("/Rect")] = ArrayObject( 

1377 ( 

1378 min(pt1[0], pt2[0]), 

1379 min(pt1[1], pt2[1]), 

1380 max(pt1[0], pt2[0]), 

1381 max(pt1[1], pt2[1]), 

1382 ) 

1383 ) 

1384 if "/QuadPoints" in a: 

1385 q = cast(ArrayObject, a["/QuadPoints"]) 

1386 aa[NameObject("/QuadPoints")] = ArrayObject( 

1387 trsf.apply_on((q[0], q[1]), True) 

1388 + trsf.apply_on((q[2], q[3]), True) 

1389 + trsf.apply_on((q[4], q[5]), True) 

1390 + trsf.apply_on((q[6], q[7]), True) 

1391 ) 

1392 try: 

1393 aa["/Popup"][NameObject("/Parent")] = aa.indirect_reference 

1394 except KeyError: 

1395 pass 

1396 try: 

1397 aa[NameObject("/P")] = self.indirect_reference 

1398 annots.append(aa.indirect_reference) 

1399 except AttributeError: 

1400 pass 

1401 

1402 new_content_array = ArrayObject() 

1403 original_content = self.get_contents() 

1404 if original_content is not None: 

1405 original_content.isolate_graphics_state() 

1406 new_content_array.append(original_content) 

1407 

1408 page2content = page2.get_contents() 

1409 if page2content is not None: 

1410 rect = getattr(page2, MERGE_CROP_BOX) 

1411 page2content.operations.insert( 

1412 0, 

1413 ( 

1414 map( 

1415 FloatObject, 

1416 [ 

1417 rect.left, 

1418 rect.bottom, 

1419 rect.width, 

1420 rect.height, 

1421 ], 

1422 ), 

1423 b"re", 

1424 ), 

1425 ) 

1426 page2content.operations.insert(1, ([], b"W")) 

1427 page2content.operations.insert(2, ([], b"n")) 

1428 if page2transformation is not None: 

1429 page2content = page2transformation(page2content) 

1430 page2content = PageObject._content_stream_rename( 

1431 page2content, rename, self.pdf 

1432 ) 

1433 page2content.isolate_graphics_state() 

1434 if over: 

1435 new_content_array.append(page2content) 

1436 else: 

1437 new_content_array.insert(0, page2content) 

1438 

1439 # if expanding the page to fit a new page, calculate the new media box size 

1440 if expand: 

1441 self._expand_mediabox(page2, ctm) 

1442 

1443 self.replace_contents(new_content_array) 

1444 

1445 def _expand_mediabox( 

1446 self, page2: "PageObject", ctm: Optional[CompressedTransformationMatrix] 

1447 ) -> None: 

1448 corners1 = ( 

1449 self.mediabox.left.as_numeric(), 

1450 self.mediabox.bottom.as_numeric(), 

1451 self.mediabox.right.as_numeric(), 

1452 self.mediabox.top.as_numeric(), 

1453 ) 

1454 corners2 = ( 

1455 page2.mediabox.left.as_numeric(), 

1456 page2.mediabox.bottom.as_numeric(), 

1457 page2.mediabox.left.as_numeric(), 

1458 page2.mediabox.top.as_numeric(), 

1459 page2.mediabox.right.as_numeric(), 

1460 page2.mediabox.top.as_numeric(), 

1461 page2.mediabox.right.as_numeric(), 

1462 page2.mediabox.bottom.as_numeric(), 

1463 ) 

1464 if ctm is not None: 

1465 ctm = tuple(float(x) for x in ctm) # type: ignore[assignment] 

1466 new_x = tuple( 

1467 ctm[0] * corners2[i] + ctm[2] * corners2[i + 1] + ctm[4] 

1468 for i in range(0, 8, 2) 

1469 ) 

1470 new_y = tuple( 

1471 ctm[1] * corners2[i] + ctm[3] * corners2[i + 1] + ctm[5] 

1472 for i in range(0, 8, 2) 

1473 ) 

1474 else: 

1475 new_x = corners2[0:8:2] 

1476 new_y = corners2[1:8:2] 

1477 lowerleft = (min(new_x), min(new_y)) 

1478 upperright = (max(new_x), max(new_y)) 

1479 lowerleft = (min(corners1[0], lowerleft[0]), min(corners1[1], lowerleft[1])) 

1480 upperright = ( 

1481 max(corners1[2], upperright[0]), 

1482 max(corners1[3], upperright[1]), 

1483 ) 

1484 

1485 self.mediabox.lower_left = lowerleft 

1486 self.mediabox.upper_right = upperright 

1487 

1488 def merge_transformed_page( 

1489 self, 

1490 page2: "PageObject", 

1491 ctm: Union[CompressedTransformationMatrix, Transformation], 

1492 over: bool = True, 

1493 expand: bool = False, 

1494 ) -> None: 

1495 """ 

1496 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but a transformation 

1497 matrix is applied to the merged stream. 

1498 

1499 Args: 

1500 page2: The page to be merged into this one. 

1501 ctm: a 6-element tuple containing the operands of the 

1502 transformation matrix 

1503 over: set the page2 content over page1 if True (default) else under 

1504 expand: Whether the page should be expanded to fit the dimensions 

1505 of the page to be merged. 

1506 

1507 """ 

1508 if isinstance(ctm, Transformation): 

1509 ctm = ctm.ctm 

1510 self._merge_page( 

1511 page2, 

1512 lambda page2_content: PageObject._add_transformation_matrix( 

1513 page2_content, page2.pdf, ctm 

1514 ), 

1515 ctm, 

1516 over, 

1517 expand, 

1518 ) 

1519 

1520 def merge_scaled_page( 

1521 self, page2: "PageObject", scale: float, over: bool = True, expand: bool = False 

1522 ) -> None: 

1523 """ 

1524 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but the stream to be merged 

1525 is scaled by applying a transformation matrix. 

1526 

1527 Args: 

1528 page2: The page to be merged into this one. 

1529 scale: The scaling factor 

1530 over: set the page2 content over page1 if True (default) else under 

1531 expand: Whether the page should be expanded to fit the 

1532 dimensions of the page to be merged. 

1533 

1534 """ 

1535 op = Transformation().scale(scale, scale) 

1536 self.merge_transformed_page(page2, op, over, expand) 

1537 

1538 def merge_rotated_page( 

1539 self, 

1540 page2: "PageObject", 

1541 rotation: float, 

1542 over: bool = True, 

1543 expand: bool = False, 

1544 ) -> None: 

1545 """ 

1546 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but the stream to be merged 

1547 is rotated by applying a transformation matrix. 

1548 

1549 Args: 

1550 page2: The page to be merged into this one. 

1551 rotation: The angle of the rotation, in degrees 

1552 over: set the page2 content over page1 if True (default) else under 

1553 expand: Whether the page should be expanded to fit the 

1554 dimensions of the page to be merged. 

1555 

1556 """ 

1557 op = Transformation().rotate(rotation) 

1558 self.merge_transformed_page(page2, op, over, expand) 

1559 

1560 def merge_translated_page( 

1561 self, 

1562 page2: "PageObject", 

1563 tx: float, 

1564 ty: float, 

1565 over: bool = True, 

1566 expand: bool = False, 

1567 ) -> None: 

1568 """ 

1569 Similar to :meth:`~pypdf._page.PageObject.merge_page`, but the stream to be 

1570 merged is translated by applying a transformation matrix. 

1571 

1572 Args: 

1573 page2: the page to be merged into this one. 

1574 tx: The translation on X axis 

1575 ty: The translation on Y axis 

1576 over: set the page2 content over page1 if True (default) else under 

1577 expand: Whether the page should be expanded to fit the 

1578 dimensions of the page to be merged. 

1579 

1580 """ 

1581 op = Transformation().translate(tx, ty) 

1582 self.merge_transformed_page(page2, op, over, expand) 

1583 

1584 def add_transformation( 

1585 self, 

1586 ctm: Union[Transformation, CompressedTransformationMatrix], 

1587 expand: bool = False, 

1588 ) -> None: 

1589 """ 

1590 Apply a transformation matrix to the page. 

1591 

1592 Args: 

1593 ctm: A 6-element tuple containing the operands of the 

1594 transformation matrix. Alternatively, a 

1595 :py:class:`Transformation<pypdf.Transformation>` 

1596 object can be passed. 

1597 

1598 See :doc:`/user/cropping-and-transforming`. 

1599 

1600 """ 

1601 if isinstance(ctm, Transformation): 

1602 ctm = ctm.ctm 

1603 content = self.get_contents() 

1604 if content is not None: 

1605 content = PageObject._add_transformation_matrix(content, self.pdf, ctm) 

1606 content.isolate_graphics_state() 

1607 self.replace_contents(content) 

1608 # if expanding the page to fit a new page, calculate the new media box size 

1609 if expand: 

1610 corners = [ 

1611 self.mediabox.left.as_numeric(), 

1612 self.mediabox.bottom.as_numeric(), 

1613 self.mediabox.left.as_numeric(), 

1614 self.mediabox.top.as_numeric(), 

1615 self.mediabox.right.as_numeric(), 

1616 self.mediabox.top.as_numeric(), 

1617 self.mediabox.right.as_numeric(), 

1618 self.mediabox.bottom.as_numeric(), 

1619 ] 

1620 

1621 ctm = tuple(float(x) for x in ctm) # type: ignore[assignment] 

1622 new_x = [ 

1623 ctm[0] * corners[i] + ctm[2] * corners[i + 1] + ctm[4] 

1624 for i in range(0, 8, 2) 

1625 ] 

1626 new_y = [ 

1627 ctm[1] * corners[i] + ctm[3] * corners[i + 1] + ctm[5] 

1628 for i in range(0, 8, 2) 

1629 ] 

1630 

1631 self.mediabox.lower_left = (min(new_x), min(new_y)) 

1632 self.mediabox.upper_right = (max(new_x), max(new_y)) 

1633 

1634 def scale(self, sx: float, sy: float) -> None: 

1635 """ 

1636 Scale a page by the given factors by applying a transformation matrix 

1637 to its content and updating the page size. 

1638 

1639 This updates the various page boundaries (bleedbox, trimbox, etc.) 

1640 and the contents of the page. 

1641 

1642 Args: 

1643 sx: The scaling factor on horizontal axis. 

1644 sy: The scaling factor on vertical axis. 

1645 

1646 """ 

1647 self.add_transformation((sx, 0, 0, sy, 0, 0)) 

1648 self.bleedbox = self.bleedbox.scale(sx, sy) 

1649 self.trimbox = self.trimbox.scale(sx, sy) 

1650 self.artbox = self.artbox.scale(sx, sy) 

1651 self.cropbox = self.cropbox.scale(sx, sy) 

1652 self.mediabox = self.mediabox.scale(sx, sy) 

1653 

1654 if PG.ANNOTS in self: 

1655 annotations = self[PG.ANNOTS] 

1656 if isinstance(annotations, ArrayObject): 

1657 for annotation in annotations: 

1658 annotation_obj = annotation.get_object() 

1659 if AnnotationDictionaryAttributes.Rect in annotation_obj: 

1660 rectangle = annotation_obj[AnnotationDictionaryAttributes.Rect] 

1661 if isinstance(rectangle, ArrayObject): 

1662 rectangle[0] = FloatObject(float(rectangle[0]) * sx) 

1663 rectangle[1] = FloatObject(float(rectangle[1]) * sy) 

1664 rectangle[2] = FloatObject(float(rectangle[2]) * sx) 

1665 rectangle[3] = FloatObject(float(rectangle[3]) * sy) 

1666 

1667 if PG.VP in self: 

1668 viewport = self[PG.VP] 

1669 if isinstance(viewport, ArrayObject): 

1670 bbox = viewport[0]["/BBox"] 

1671 else: 

1672 bbox = viewport["/BBox"] # type: ignore[index] 

1673 scaled_bbox = RectangleObject( 

1674 ( 

1675 float(bbox[0]) * sx, 

1676 float(bbox[1]) * sy, 

1677 float(bbox[2]) * sx, 

1678 float(bbox[3]) * sy, 

1679 ) 

1680 ) 

1681 if isinstance(viewport, ArrayObject): 

1682 self[NameObject(PG.VP)][NumberObject(0)][ # type: ignore[index] 

1683 NameObject("/BBox") 

1684 ] = scaled_bbox 

1685 else: 

1686 self[NameObject(PG.VP)][NameObject("/BBox")] = scaled_bbox # type: ignore[index] 

1687 

1688 def scale_by(self, factor: float) -> None: 

1689 """ 

1690 Scale a page by the given factor by applying a transformation matrix to 

1691 its content and updating the page size. 

1692 

1693 Args: 

1694 factor: The scaling factor (for both X and Y axis). 

1695 

1696 """ 

1697 self.scale(factor, factor) 

1698 

1699 def scale_to(self, width: float, height: float) -> None: 

1700 """ 

1701 Scale a page to the specified dimensions by applying a transformation 

1702 matrix to its content and updating the page size. 

1703 

1704 Args: 

1705 width: The new width. 

1706 height: The new height. 

1707 

1708 """ 

1709 sx = width / float(self.mediabox.width) 

1710 sy = height / float(self.mediabox.height) 

1711 self.scale(sx, sy) 

1712 

1713 def compress_content_streams(self, level: int = -1) -> None: 

1714 """ 

1715 Compress the size of this page by joining all content streams and 

1716 applying a FlateDecode filter. 

1717 

1718 However, it is possible that this function will perform no action if 

1719 content stream compression becomes "automatic". 

1720 """ 

1721 content = self.get_contents() 

1722 if content is not None: 

1723 content_obj = content.flate_encode(level) 

1724 try: 

1725 content.indirect_reference.pdf._objects[ # type: ignore[union-attr] 

1726 content.indirect_reference.idnum - 1 # type: ignore[union-attr] 

1727 ] = content_obj 

1728 except AttributeError: 

1729 if self.indirect_reference is not None and hasattr( 

1730 self.indirect_reference.pdf, "_add_object" 

1731 ): 

1732 self.replace_contents(content_obj) 

1733 else: 

1734 raise ValueError("Page must be part of a PdfWriter") 

1735 

1736 @property 

1737 def page_number(self) -> Optional[int]: 

1738 """ 

1739 Read-only property which returns the page number within the PDF file. 

1740 

1741 Returns: 

1742 Page number; None if the page is not attached to a PDF. 

1743 

1744 """ 

1745 if self.indirect_reference is None: 

1746 return None 

1747 try: 

1748 lst = self.indirect_reference.pdf.pages 

1749 return int(lst.index(self)) 

1750 except ValueError: 

1751 return None 

1752 

1753 def _debug_for_extract(self) -> str: # pragma: no cover 

1754 out = "" 

1755 for ope, op in ContentStream( 

1756 self["/Contents"].get_object(), self.pdf, "bytes" 

1757 ).operations: 

1758 if op == b"TJ": 

1759 s = [x for x in ope[0] if isinstance(x, str)] 

1760 else: 

1761 s = [] 

1762 out += op.decode("utf-8") + " " + "".join(s) + ope.__repr__() + "\n" 

1763 out += "\n=============================\n" 

1764 try: 

1765 for fo in self[PG.RESOURCES]["/Font"]: # type:ignore 

1766 out += fo + "\n" 

1767 out += self[PG.RESOURCES]["/Font"][fo].__repr__() + "\n" # type:ignore 

1768 try: 

1769 enc_repr = self[PG.RESOURCES]["/Font"][fo][ # type:ignore 

1770 "/Encoding" 

1771 ].__repr__() 

1772 out += enc_repr + "\n" 

1773 except Exception: 

1774 pass 

1775 try: 

1776 out += ( 

1777 self[PG.RESOURCES]["/Font"][fo][ # type:ignore 

1778 "/ToUnicode" 

1779 ] 

1780 .get_data() 

1781 .decode() 

1782 + "\n" 

1783 ) 

1784 except Exception: 

1785 pass 

1786 

1787 except KeyError: 

1788 out += "No Font\n" 

1789 return out 

1790 

1791 def _extract_text( 

1792 self, 

1793 obj: DictionaryObject, 

1794 pdf: Any, 

1795 orientations: tuple[int, ...] = (0, 90, 180, 270), 

1796 space_width: float = 200.0, 

1797 content_key: Optional[str] = PG.CONTENTS, 

1798 visitor_operand_before: Optional[Callable[[Any, Any, Any, Any], None]] = None, 

1799 visitor_operand_after: Optional[Callable[[Any, Any, Any, Any], None]] = None, 

1800 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None, 

1801 *, 

1802 known_ids: Optional[set[int]] = None, 

1803 ) -> str: 

1804 """ 

1805 See extract_text for most arguments. 

1806 

1807 Args: 

1808 content_key: indicate the default key where to extract data 

1809 None = the object; this allows reusing the function on an XObject 

1810 default = "/Content" 

1811 

1812 """ 

1813 if known_ids is None: 

1814 known_ids = set() 

1815 

1816 extractor = TextExtraction() 

1817 font_resources: dict[str, DictionaryObject] = {} 

1818 fonts: dict[str, Font] = {} 

1819 

1820 resources_dict = cast( 

1821 Optional[DictionaryObject], 

1822 obj.get_inherited(key=PG.RESOURCES, default=DictionaryObject()) 

1823 ) 

1824 if is_null_or_none(resources_dict) or not resources_dict: 

1825 # No resources means no text is possible (no font); we consider the 

1826 # file as not damaged, no need to check for TJ or Tj 

1827 return "" 

1828 

1829 if ( 

1830 "/Font" in resources_dict 

1831 and (font_resources_dict := cast(DictionaryObject, resources_dict["/Font"])) 

1832 ): 

1833 for font_resource in font_resources_dict: 

1834 try: 

1835 font_resource_object = cast(DictionaryObject, font_resources_dict[font_resource].get_object()) 

1836 font_resources[font_resource] = font_resource_object 

1837 fonts[font_resource] = Font.from_font_resource(font_resource_object) 

1838 # Override space width, if applicable 

1839 if fonts[font_resource].character_widths.get(fonts[font_resource].space_char, 0) == 0: 

1840 fonts[font_resource].space_width = space_width 

1841 except (AttributeError, TypeError): 

1842 pass 

1843 

1844 try: 

1845 content = ( 

1846 obj[content_key].get_object() if isinstance(content_key, str) else obj 

1847 ) 

1848 if not isinstance(content, ContentStream): 

1849 content = ContentStream(content, pdf, "bytes") 

1850 except (AttributeError, KeyError): # no content can be extracted (certainly empty page) 

1851 return "" 

1852 # We check all strings are TextStringObjects. ByteStringObjects 

1853 # are strings where the byte->string encoding was unknown, so adding 

1854 # them to the text here would be gibberish. 

1855 

1856 # Initialize the extractor with the necessary parameters 

1857 extractor.initialize_extraction(orientations, visitor_text, font_resources, fonts) 

1858 

1859 for operands, operator in content.operations: 

1860 if visitor_operand_before is not None: 

1861 visitor_operand_before(operator, operands, extractor.cm_matrix, extractor.tm_matrix) 

1862 # Multiple operators are handled here 

1863 if operator == b"'": 

1864 extractor.process_operation(b"T*", []) 

1865 extractor.process_operation(b"Tj", operands) 

1866 elif operator == b'"' and len(operands) >= 3: 

1867 extractor.process_operation(b"Tw", [operands[0]]) 

1868 extractor.process_operation(b"Tc", [operands[1]]) 

1869 extractor.process_operation(b"T*", []) 

1870 extractor.process_operation(b"Tj", operands[2:]) 

1871 elif operator == b"TJ": 

1872 # The space width may be smaller than the font width, so the width should be 95%. 

1873 _confirm_space_width = extractor._space_width * 0.95 

1874 if operands: 

1875 for op in operands[0]: 

1876 if isinstance(op, (str, bytes)): 

1877 extractor.process_operation(b"Tj", [op]) 

1878 if isinstance(op, (int, float, NumberObject, FloatObject)) and ( 

1879 abs(float(op)) >= _confirm_space_width 

1880 and extractor.text 

1881 and extractor.text[-1] != " " 

1882 ): 

1883 extractor.process_operation(b"Tj", [" "]) 

1884 elif operator == b"TD" and len(operands) >= 2: 

1885 extractor.process_operation(b"TL", [-operands[1]]) 

1886 extractor.process_operation(b"Td", operands) 

1887 elif operator == b"Do": 

1888 extractor.output += extractor.text 

1889 if visitor_text is not None: 

1890 visitor_text( 

1891 extractor.text, 

1892 extractor.memo_cm, 

1893 extractor.memo_tm, 

1894 extractor.font_resource, 

1895 extractor.font_size, 

1896 ) 

1897 try: 

1898 if extractor.output[-1] != "\n": 

1899 extractor.output += "\n" 

1900 if visitor_text is not None: 

1901 visitor_text( 

1902 "\n", 

1903 extractor.memo_cm, 

1904 extractor.memo_tm, 

1905 extractor.font_resource, 

1906 extractor.font_size, 

1907 ) 

1908 except IndexError: 

1909 pass 

1910 try: 

1911 xobj = cast(DictionaryObject, resources_dict["/XObject"]) 

1912 xform = cast(EncodedStreamObject, xobj[operands[0]]) 

1913 if xform["/Subtype"] != NameObject("/Image"): 

1914 xform_id = id(xform) 

1915 if xform_id in known_ids: 

1916 logger_warning( 

1917 "Detected cyclic form XObject reference, skipping %(operand)s.", 

1918 source=__name__, 

1919 operand=operands[0] 

1920 ) 

1921 text = "" 

1922 else: 

1923 known_ids.add(xform_id) 

1924 try: 

1925 text = self.extract_xform_text( 

1926 xform, 

1927 orientations, 

1928 space_width, 

1929 visitor_operand_before, 

1930 visitor_operand_after, 

1931 visitor_text, 

1932 known_ids=known_ids, 

1933 ) 

1934 finally: 

1935 known_ids.discard(xform_id) 

1936 extractor.output += text 

1937 if visitor_text is not None: 

1938 visitor_text( 

1939 text, 

1940 extractor.memo_cm, 

1941 extractor.memo_tm, 

1942 extractor.font_resource, 

1943 extractor.font_size, 

1944 ) 

1945 except Exception as exception: 

1946 logger_warning( 

1947 "Impossible to decode XFormObject %(operand)s: %(exception)s", 

1948 source=__name__, 

1949 operand=operands[0], 

1950 exception=exception, 

1951 ) 

1952 finally: 

1953 extractor.text = "" 

1954 extractor.memo_cm = extractor.cm_matrix.copy() 

1955 extractor.memo_tm = extractor.tm_matrix.copy() 

1956 else: 

1957 extractor.process_operation(operator, operands) 

1958 if visitor_operand_after is not None: 

1959 visitor_operand_after(operator, operands, extractor.cm_matrix, extractor.tm_matrix) 

1960 extractor.output += extractor.text # just in case 

1961 if extractor.text != "" and visitor_text is not None: 

1962 visitor_text( 

1963 extractor.text, 

1964 extractor.memo_cm, 

1965 extractor.memo_tm, 

1966 extractor.font_resource, 

1967 extractor.font_size, 

1968 ) 

1969 return extractor.output 

1970 

1971 def _layout_mode_fonts(self) -> dict[str, Font]: 

1972 """ 

1973 Get fonts formatted for "layout" mode text extraction. 

1974 

1975 Returns: 

1976 Dict[str, Font]: dictionary of Font instances keyed by font name 

1977 

1978 """ 

1979 # Font retrieval logic adapted from pypdf.PageObject._extract_text() 

1980 obj: Any = self 

1981 fonts: dict[str, Font] = {} 

1982 visited: set[int] = set() 

1983 while True: 

1984 obj_id = id(obj) 

1985 if obj_id in visited: 

1986 logger_warning("Detected cycle in /Parent hierarchy when retrieving fonts.", source=__name__) 

1987 break 

1988 visited.add(obj_id) 

1989 

1990 resources_dict: Any = obj.get(PG.RESOURCES, {}) 

1991 if "/Font" in resources_dict and self.pdf is not None: 

1992 for font_name in resources_dict["/Font"]: 

1993 fonts[font_name] = Font.from_font_resource(resources_dict["/Font"][font_name]) 

1994 

1995 if "/Parent" not in obj: 

1996 break 

1997 obj = obj["/Parent"].get_object() 

1998 

1999 return fonts 

2000 

2001 def _layout_mode_text( 

2002 self, 

2003 space_vertically: bool = True, 

2004 scale_weight: float = 1.25, 

2005 strip_rotated: bool = True, 

2006 debug_path: Optional[Path] = None, 

2007 font_height_weight: float = 1, 

2008 ) -> str: 

2009 """ 

2010 Get text preserving fidelity to source PDF text layout. 

2011 

2012 Args: 

2013 space_vertically: include blank lines inferred from y distance + font 

2014 height. Defaults to True. 

2015 scale_weight: multiplier for string length when calculating weighted 

2016 average character width. Defaults to 1.25. 

2017 strip_rotated: Removes text that is rotated w.r.t. to the page from 

2018 layout mode output. Defaults to True. 

2019 debug_path (Path | None): if supplied, must target a directory. 

2020 creates the following files with debug information for layout mode 

2021 functions if supplied: 

2022 - fonts.json: output of self._layout_mode_fonts 

2023 - tjs.json: individual text render ops with corresponding transform matrices 

2024 - bts.json: text render ops left justified and grouped by BT/ET operators 

2025 - bt_groups.json: BT/ET operations grouped by rendered y-coord (aka lines) 

2026 Defaults to None. 

2027 font_height_weight: multiplier for font height when calculating 

2028 blank lines. Defaults to 1. 

2029 

2030 Returns: 

2031 str: multiline string containing page text in a fixed width format that 

2032 closely adheres to the rendered layout in the source pdf. 

2033 

2034 """ 

2035 fonts = self._layout_mode_fonts() 

2036 if debug_path: # pragma: no cover 

2037 import json # noqa: PLC0415 

2038 

2039 debug_path.joinpath("fonts.json").write_text( 

2040 json.dumps(fonts, indent=2, default=asdict), 

2041 "utf-8" 

2042 ) 

2043 

2044 ops = iter( 

2045 ContentStream(self["/Contents"].get_object(), self.pdf, "bytes").operations 

2046 ) 

2047 bt_groups = _layout_mode.text_show_operations( 

2048 ops, fonts, strip_rotated, debug_path 

2049 ) 

2050 

2051 if not bt_groups: 

2052 return "" 

2053 

2054 ty_groups = _layout_mode.y_coordinate_groups(bt_groups, debug_path) 

2055 

2056 char_width = _layout_mode.fixed_char_width(bt_groups, scale_weight) 

2057 

2058 return _layout_mode.fixed_width_page(ty_groups, char_width, space_vertically, font_height_weight) 

2059 

2060 def extract_text( 

2061 self, 

2062 *args: Any, 

2063 orientations: Union[int, tuple[int, ...]] = (0, 90, 180, 270), 

2064 space_width: float = 200.0, 

2065 visitor_operand_before: Optional[Callable[[Any, Any, Any, Any], None]] = None, 

2066 visitor_operand_after: Optional[Callable[[Any, Any, Any, Any], None]] = None, 

2067 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None, 

2068 extraction_mode: Literal["plain", "layout"] = "plain", 

2069 **kwargs: Any, 

2070 ) -> str: 

2071 """ 

2072 Locate all text drawing commands, in the order they are provided in the 

2073 content stream, and extract the text. 

2074 

2075 This works well for some PDF files, but poorly for others, depending on 

2076 the generator used. This will be refined in the future. 

2077 

2078 Do not rely on the order of text coming out of this function, as it 

2079 will change if this function is made more sophisticated. 

2080 

2081 Arabic and Hebrew are extracted in the correct order. 

2082 If required a custom RTL range of characters can be defined; 

2083 see function set_custom_rtl. 

2084 

2085 Additionally you can provide visitor methods to get informed on all 

2086 operations and all text objects. 

2087 For example in some PDF files this can be useful to parse tables. 

2088 

2089 Args: 

2090 orientations: list of orientations extract_text will look for 

2091 default = (0, 90, 180, 270) 

2092 note: currently only 0 (up),90 (turned left), 180 (upside down), 

2093 270 (turned right) 

2094 Silently ignored in "layout" mode. 

2095 space_width: force default space width 

2096 if not extracted from font (default: 200) 

2097 Silently ignored in "layout" mode. 

2098 visitor_operand_before: function to be called before processing an operation. 

2099 It has four arguments: operator, operand-arguments, 

2100 current transformation matrix and text matrix. 

2101 Ignored with a warning in "layout" mode. 

2102 visitor_operand_after: function to be called after processing an operation. 

2103 It has four arguments: operator, operand-arguments, 

2104 current transformation matrix and text matrix. 

2105 Ignored with a warning in "layout" mode. 

2106 visitor_text: function to be called when extracting some text at some position. 

2107 It has five arguments: text, current transformation matrix, 

2108 text matrix, font-dictionary and font-size. 

2109 The font-dictionary may be None in case of unknown fonts. 

2110 If not None it may e.g. contain key "/BaseFont" with value "/Arial,Bold". 

2111 Ignored with a warning in "layout" mode. 

2112 extraction_mode (Literal["plain", "layout"]): "plain" for legacy functionality, 

2113 "layout" for experimental layout mode functionality. 

2114 NOTE: orientations, space_width, and visitor_* parameters are NOT respected 

2115 in "layout" mode. 

2116 

2117 kwargs: 

2118 layout_mode_space_vertically (bool): include blank lines inferred from 

2119 y distance + font height. Defaults to True. 

2120 layout_mode_scale_weight (float): multiplier for string length when calculating 

2121 weighted average character width. Defaults to 1.25. 

2122 layout_mode_strip_rotated (bool): layout mode does not support rotated text. 

2123 Set to False to include rotated text anyway. If rotated text is discovered, 

2124 layout will be degraded and a warning will result. Defaults to True. 

2125 layout_mode_debug_path (Path | None): if supplied, must target a directory. 

2126 creates the following files with debug information for layout mode 

2127 functions if supplied: 

2128 

2129 - fonts.json: output of self._layout_mode_fonts 

2130 - tjs.json: individual text render ops with corresponding transform matrices 

2131 - bts.json: text render ops left justified and grouped by BT/ET operators 

2132 - bt_groups.json: BT/ET operations grouped by rendered y-coord (aka lines) 

2133 layout_mode_font_height_weight (float): multiplier for font height when calculating 

2134 blank lines. Defaults to 1. 

2135 

2136 Returns: 

2137 The extracted text 

2138 

2139 """ 

2140 if extraction_mode not in ["plain", "layout"]: 

2141 raise ValueError(f"Invalid text extraction mode '{extraction_mode}'") 

2142 if extraction_mode == "layout": 

2143 for visitor in ( 

2144 "visitor_operand_before", 

2145 "visitor_operand_after", 

2146 "visitor_text", 

2147 ): 

2148 if locals()[visitor]: 

2149 logger_warning( 

2150 "Argument %(visitor)s is ignored in layout mode", 

2151 source=__name__, 

2152 visitor=visitor, 

2153 ) 

2154 return self._layout_mode_text( 

2155 space_vertically=kwargs.get("layout_mode_space_vertically", True), 

2156 scale_weight=kwargs.get("layout_mode_scale_weight", 1.25), 

2157 strip_rotated=kwargs.get("layout_mode_strip_rotated", True), 

2158 debug_path=kwargs.get("layout_mode_debug_path"), 

2159 font_height_weight=kwargs.get("layout_mode_font_height_weight", 1) 

2160 ) 

2161 if len(args) >= 1: 

2162 if isinstance(args[0], str): 

2163 if len(args) >= 3: 

2164 if isinstance(args[2], (tuple, int)): 

2165 orientations = args[2] 

2166 else: 

2167 raise TypeError(f"Invalid positional parameter {args[2]}") 

2168 if len(args) >= 4: 

2169 if isinstance(args[3], (float, int)): 

2170 space_width = args[3] 

2171 else: 

2172 raise TypeError(f"Invalid positional parameter {args[3]}") 

2173 elif isinstance(args[0], (tuple, int)): 

2174 orientations = args[0] 

2175 if len(args) >= 2: 

2176 if isinstance(args[1], (float, int)): 

2177 space_width = args[1] 

2178 else: 

2179 raise TypeError(f"Invalid positional parameter {args[1]}") 

2180 else: 

2181 raise TypeError(f"Invalid positional parameter {args[0]}") 

2182 

2183 if isinstance(orientations, int): 

2184 orientations = (orientations,) 

2185 

2186 return self._extract_text( 

2187 self, 

2188 self.pdf, 

2189 orientations, 

2190 space_width, 

2191 PG.CONTENTS, 

2192 visitor_operand_before, 

2193 visitor_operand_after, 

2194 visitor_text, 

2195 ) 

2196 

2197 def extract_xform_text( 

2198 self, 

2199 xform: EncodedStreamObject, 

2200 orientations: tuple[int, ...] = (0, 90, 270, 360), 

2201 space_width: float = 200.0, 

2202 visitor_operand_before: Optional[Callable[[Any, Any, Any, Any], None]] = None, 

2203 visitor_operand_after: Optional[Callable[[Any, Any, Any, Any], None]] = None, 

2204 visitor_text: Optional[Callable[[Any, Any, Any, Any, Any], None]] = None, 

2205 *, 

2206 known_ids: Optional[set[int]] = None, 

2207 ) -> str: 

2208 """ 

2209 Extract text from an XObject. 

2210 

2211 Args: 

2212 xform: 

2213 orientations: 

2214 space_width: force default space width (if not extracted from font (default 200) 

2215 visitor_operand_before: 

2216 visitor_operand_after: 

2217 visitor_text: 

2218 

2219 Returns: 

2220 The extracted text 

2221 

2222 """ 

2223 return self._extract_text( 

2224 xform, 

2225 self.pdf, 

2226 orientations, 

2227 space_width, 

2228 None, 

2229 visitor_operand_before, 

2230 visitor_operand_after, 

2231 visitor_text, 

2232 known_ids=known_ids, 

2233 ) 

2234 

2235 def _get_fonts(self) -> tuple[set[str], set[str]]: 

2236 """ 

2237 Get the names of embedded fonts and unembedded fonts. 

2238 

2239 Returns: 

2240 A tuple (set of embedded fonts, set of unembedded fonts) 

2241 

2242 """ 

2243 obj = self.get_object() 

2244 assert isinstance(obj, DictionaryObject) 

2245 fonts: set[str] = set() 

2246 embedded: set[str] = set() 

2247 fonts, embedded = _get_fonts_walk(obj, fonts, embedded) 

2248 unembedded = fonts - embedded 

2249 return embedded, unembedded 

2250 

2251 mediabox = _create_rectangle_accessor(PG.MEDIABOX, ()) 

2252 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in 

2253 default user space units, defining the boundaries of the physical medium on 

2254 which the page is intended to be displayed or printed.""" 

2255 

2256 cropbox = _create_rectangle_accessor("/CropBox", (PG.MEDIABOX,)) 

2257 """ 

2258 A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in 

2259 default user space units, defining the visible region of default user 

2260 space. 

2261 

2262 When the page is displayed or printed, its contents are to be clipped 

2263 (cropped) to this rectangle and then imposed on the output medium in some 

2264 implementation-defined manner. Default value: same as 

2265 :attr:`mediabox<mediabox>`. 

2266 """ 

2267 

2268 bleedbox = _create_rectangle_accessor("/BleedBox", ("/CropBox", PG.MEDIABOX)) 

2269 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in 

2270 default user space units, defining the region to which the contents of the 

2271 page should be clipped when output in a production environment.""" 

2272 

2273 trimbox = _create_rectangle_accessor("/TrimBox", ("/CropBox", PG.MEDIABOX)) 

2274 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in 

2275 default user space units, defining the intended dimensions of the finished 

2276 page after trimming.""" 

2277 

2278 artbox = _create_rectangle_accessor("/ArtBox", ("/CropBox", PG.MEDIABOX)) 

2279 """A :class:`RectangleObject<pypdf.generic.RectangleObject>`, expressed in 

2280 default user space units, defining the extent of the page's meaningful 

2281 content as intended by the page's creator.""" 

2282 

2283 @property 

2284 def annotations(self) -> Optional[ArrayObject]: 

2285 if "/Annots" not in self: 

2286 return None 

2287 return cast(ArrayObject, self["/Annots"]) 

2288 

2289 @annotations.setter 

2290 def annotations(self, value: Optional[ArrayObject]) -> None: 

2291 """ 

2292 Set the annotations array of the page. 

2293 

2294 Typically you do not want to set this value, but append to it. 

2295 If you append to it, remember to add the object first to the writer 

2296 and only add the indirect object. 

2297 """ 

2298 if value is None: 

2299 if "/Annots" not in self: 

2300 return 

2301 del self[NameObject("/Annots")] 

2302 else: 

2303 self[NameObject("/Annots")] = value 

2304 

2305 def add_action(self, trigger: PageTrigger, action: Action) -> None: 

2306 """ 

2307 Add an action which will launch on the given trigger event of this page. 

2308 

2309 Args: 

2310 trigger: The action trigger to use. 

2311 action: The action to be done. 

2312 

2313 Example: 

2314 >>> from pypdf import PdfWriter 

2315 >>> from pypdf.actions import JavaScript, PageTrigger 

2316 >>> writer = PdfWriter() 

2317 >>> page = writer.add_blank_page(595, 842) 

2318 >>> # Display the page number when the page is opened 

2319 >>> page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) 

2320 >>> # Display the page number when the page is closed 

2321 >>> page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) 

2322 """ 

2323 return Action._create_new(self, trigger, action) 

2324 

2325 def delete_action(self, trigger: PageTrigger) -> None: 

2326 """ 

2327 Delete all actions associated with an open or close trigger event of this page. 

2328 

2329 Args: 

2330 trigger: An open or close trigger. 

2331 

2332 Example: 

2333 >>> from pypdf import PdfWriter 

2334 >>> from pypdf.actions import JavaScript, PageTrigger 

2335 >>> writer = PdfWriter() 

2336 >>> page = writer.add_blank_page(595, 842) 

2337 >>> page.add_action(PageTrigger("open"), JavaScript("app.alert('This is page ' + this.pageNum);")) 

2338 >>> page.add_action(PageTrigger("close"), JavaScript("app.alert('This is page ' + this.pageNum);")) 

2339 >>> # Delete all actions triggered by a page open 

2340 >>> page.delete_action(PageTrigger("open")) 

2341 >>> # Delete all actions triggered by a page close 

2342 >>> page.delete_action(PageTrigger("close")) 

2343 """ 

2344 return Action._delete(self, trigger) 

2345 

2346 

2347class _VirtualList(Sequence[PageObject]): 

2348 def __init__( 

2349 self, 

2350 length_function: Callable[[], int], 

2351 get_function: Callable[[int], PageObject], 

2352 ) -> None: 

2353 self.length_function = length_function 

2354 self.get_function = get_function 

2355 self.current = -1 

2356 

2357 def __len__(self) -> int: 

2358 return self.length_function() 

2359 

2360 @overload 

2361 def __getitem__(self, index: int) -> PageObject: 

2362 ... 

2363 

2364 @overload 

2365 def __getitem__(self, index: slice) -> Sequence[PageObject]: 

2366 ... 

2367 

2368 def __getitem__( 

2369 self, index: Union[int, slice] 

2370 ) -> Union[PageObject, Sequence[PageObject]]: 

2371 if isinstance(index, slice): 

2372 indices = range(*index.indices(len(self))) 

2373 cls = type(self) 

2374 return cls(indices.__len__, lambda idx: self[indices[idx]]) 

2375 if not isinstance(index, int): 

2376 raise TypeError("Sequence indices must be integers") 

2377 len_self = len(self) 

2378 if index < 0: 

2379 # support negative indexes 

2380 index += len_self 

2381 if not (0 <= index < len_self): 

2382 raise IndexError("Sequence index out of range") 

2383 return self.get_function(index) 

2384 

2385 def __delitem__(self, index: Union[int, slice]) -> None: 

2386 if isinstance(index, slice): 

2387 r = list(range(*index.indices(len(self)))) 

2388 # pages have to be deleted from last to first 

2389 r.sort() 

2390 r.reverse() 

2391 for p in r: 

2392 del self[p] # recursive call 

2393 return 

2394 if not isinstance(index, int): 

2395 raise TypeError("Index must be integers") 

2396 len_self = len(self) 

2397 if index < 0: 

2398 # support negative indexes 

2399 index += len_self 

2400 if not (0 <= index < len_self): 

2401 raise IndexError("Index out of range") 

2402 ind = self[index].indirect_reference 

2403 assert ind is not None 

2404 parent: Optional[PdfObject] = cast(DictionaryObject, ind.get_object()).get( 

2405 "/Parent", None 

2406 ) 

2407 first = True 

2408 while parent is not None: 

2409 parent = cast(DictionaryObject, parent.get_object()) 

2410 try: 

2411 i = cast(ArrayObject, parent["/Kids"]).index(ind) 

2412 del cast(ArrayObject, parent["/Kids"])[i] 

2413 first = False 

2414 try: 

2415 assert ind is not None 

2416 del ind.pdf.flattened_pages[index] # case of page in a Reader 

2417 except Exception: # pragma: no cover 

2418 pass 

2419 if "/Count" in parent: 

2420 parent[NameObject("/Count")] = NumberObject( 

2421 cast(int, parent["/Count"]) - 1 

2422 ) 

2423 if len(cast(ArrayObject, parent["/Kids"])) == 0: 

2424 # No more objects in this part of this subtree 

2425 ind = parent.indirect_reference 

2426 parent = parent.get("/Parent", None) 

2427 except ValueError: # from index 

2428 if first: 

2429 raise PdfReadError(f"Page not found in page tree: {ind}") 

2430 break 

2431 

2432 def __iter__(self) -> Iterator[PageObject]: 

2433 for i in range(len(self)): 

2434 yield self[i] 

2435 

2436 def __str__(self) -> str: 

2437 p = [f"PageObject({i})" for i in range(self.length_function())] 

2438 return f"[{', '.join(p)}]" 

2439 

2440 

2441def _get_fonts_walk( 

2442 obj: DictionaryObject, 

2443 fnt: set[str], 

2444 emb: set[str], 

2445) -> tuple[set[str], set[str]]: 

2446 """ 

2447 Get the set of all fonts and all embedded fonts. 

2448 

2449 Args: 

2450 obj: Page resources dictionary 

2451 fnt: font 

2452 emb: embedded fonts 

2453 

2454 Returns: 

2455 A tuple (fnt, emb) 

2456 

2457 If there is a key called 'BaseFont', that is a font that is used in the document. 

2458 If there is a key called 'FontName' and another key in the same dictionary object 

2459 that is called 'FontFilex' (where x is null, 2, or 3), then that fontname is 

2460 embedded. 

2461 

2462 We create and add to two sets, fnt = fonts used and emb = fonts embedded. 

2463 

2464 """ 

2465 fontkeys = ("/FontFile", "/FontFile2", "/FontFile3") 

2466 

2467 def process_font(f: DictionaryObject) -> None: 

2468 nonlocal fnt, emb 

2469 f = cast(DictionaryObject, f.get_object()) # to be sure 

2470 if "/BaseFont" in f: 

2471 fnt.add(cast(str, f["/BaseFont"])) 

2472 

2473 if ( 

2474 ("/CharProcs" in f) 

2475 or ( 

2476 "/FontDescriptor" in f 

2477 and any( 

2478 x in cast(DictionaryObject, f["/FontDescriptor"]) for x in fontkeys 

2479 ) 

2480 ) 

2481 or ( 

2482 "/DescendantFonts" in f 

2483 and "/FontDescriptor" 

2484 in cast( 

2485 DictionaryObject, 

2486 cast(ArrayObject, f["/DescendantFonts"])[0].get_object(), 

2487 ) 

2488 and any( 

2489 x 

2490 in cast( 

2491 DictionaryObject, 

2492 cast( 

2493 DictionaryObject, 

2494 cast(ArrayObject, f["/DescendantFonts"])[0].get_object(), 

2495 )["/FontDescriptor"], 

2496 ) 

2497 for x in fontkeys 

2498 ) 

2499 ) 

2500 ): 

2501 # the list comprehension ensures there is FontFile 

2502 try: 

2503 emb.add(cast(str, f["/BaseFont"])) 

2504 except KeyError: 

2505 emb.add("(" + cast(str, f["/Subtype"]) + ")") 

2506 

2507 if "/DR" in obj and "/Font" in cast(DictionaryObject, obj["/DR"]): 

2508 for f in cast(DictionaryObject, cast(DictionaryObject, obj["/DR"])["/Font"]): 

2509 process_font(f) 

2510 if "/Resources" in obj: 

2511 if "/Font" in cast(DictionaryObject, obj["/Resources"]): 

2512 for f in cast( 

2513 DictionaryObject, cast(DictionaryObject, obj["/Resources"])["/Font"] 

2514 ).values(): 

2515 process_font(f) 

2516 if "/XObject" in cast(DictionaryObject, obj["/Resources"]): 

2517 for x in cast( 

2518 DictionaryObject, cast(DictionaryObject, obj["/Resources"])["/XObject"] 

2519 ).values(): 

2520 _get_fonts_walk(cast(DictionaryObject, x.get_object()), fnt, emb) 

2521 if "/Annots" in obj: 

2522 for a in cast(ArrayObject, obj["/Annots"]): 

2523 _get_fonts_walk(cast(DictionaryObject, a.get_object()), fnt, emb) 

2524 if "/AP" in obj: 

2525 if ( 

2526 cast(DictionaryObject, cast(DictionaryObject, obj["/AP"])["/N"]).get( 

2527 "/Type" 

2528 ) 

2529 == "/XObject" 

2530 ): 

2531 _get_fonts_walk( 

2532 cast(DictionaryObject, cast(DictionaryObject, obj["/AP"])["/N"]), 

2533 fnt, 

2534 emb, 

2535 ) 

2536 else: 

2537 for a in cast(DictionaryObject, cast(DictionaryObject, obj["/AP"])["/N"]): 

2538 _get_fonts_walk(cast(DictionaryObject, a), fnt, emb) 

2539 return fnt, emb # return the sets for each page