modm_data.pdf

PDF Content Accessors

This module extends the pypdfium2 Python API with low-level accessors for characters and graphics. Note that these modules support read-only access to PDFs, since a lot of caching is used to speed up commonly accessed properties.

This module only contains formatting independent PDF access which is then specialized in the vendor-specific modm_data.pdf2html modules.

 1# Copyright 2022, Niklas Hauser
 2# SPDX-License-Identifier: MPL-2.0
 3
 4"""
 5# PDF Content Accessors
 6
 7This module extends the pypdfium2 Python API with low-level accessors for
 8characters and graphics. Note that these modules support read-only access to
 9PDFs, since a lot of caching is used to speed up commonly accessed properties.
10
11This module only contains formatting independent PDF access which is then
12specialized in the vendor-specific `modm_data.pdf2html` modules.
13"""
14
15from .document import Document
16from .page import Page
17from .character import Character
18from .link import ObjLink, WebLink
19from .path import Path
20from .image import Image
21from .render import annotate_debug_info
22from .structure import Structure
23
24__all__ = [
25    "annotate_debug_info",
26    "Document",
27    "Page",
28    "Character",
29    "Path",
30    "Image",
31    "ObjLink",
32    "WebLink",
33    "Structure",
34]
def annotate_debug_info( page: Page, new_doc: pypdfium2._helpers.document.PdfDocument = None, index: int = 0) -> pypdfium2._helpers.document.PdfDocument:
 52def annotate_debug_info(page: Page, new_doc: pp.PdfDocument = None, index: int = 0) -> pp.PdfDocument:
 53    """
 54    Copies each page into a new or existing PDF document and overlays the internal information on top of the content.
 55    - Renders the bounding boxes in RED and origins in BLACK of all characters.
 56    - Renders the bounding boxes of web links in BLUE GREEN.
 57    - Renders the bounding boxes of object links in YELLOW GREEN.
 58    - Renders all graphics paths in BLUE.
 59    - Renders the bounding boxes of computed graphics clusters in CYAN.
 60
 61    :param page: The page to be annotated.
 62    :param new_doc: The PDF document to copy the page to. If not provided, a new document is created.
 63    :param index: The index of the page in the new document.
 64    :return: The new document with the annotated page added.
 65    """
 66    _, height = page.width, page.height
 67
 68    if new_doc is None:
 69        new_doc = pp.raw.FPDF_CreateNewDocument()
 70    # copy page over to new doc
 71    assert pp.raw.FPDF_ImportPages(new_doc, page.pdf, str(page.number).encode("ascii"), index)
 72    new_page = pp.raw.FPDF_LoadPage(new_doc, index)
 73    rotation = page.rotation
 74
 75    for path in page.paths:
 76        p0 = path.points[0]
 77        if rotation:
 78            obj = pp.raw.FPDFPageObj_CreateNewPath(height - p0.y, p0.x)
 79        else:
 80            obj = pp.raw.FPDFPageObj_CreateNewPath(p0.x, p0.y)
 81        assert pp.raw.FPDFPageObj_SetStrokeColor(obj, 0, 0, 0xFF, 0xC0)
 82        assert pp.raw.FPDFPageObj_SetStrokeWidth(obj, 0.25)
 83        assert pp.raw.FPDFPageObj_SetLineJoin(obj, pp.raw.FPDF_LINEJOIN_ROUND)
 84        assert pp.raw.FPDFPageObj_SetLineCap(obj, pp.raw.FPDF_LINECAP_ROUND)
 85        assert pp.raw.FPDFPath_SetDrawMode(obj, 0, True)
 86        for point in path.points[1:]:
 87            if point.type == path.Type.MOVE:
 88                if rotation:
 89                    assert pp.raw.FPDFPath_MoveTo(obj, height - point.y, point.x)
 90                else:
 91                    assert pp.raw.FPDFPath_MoveTo(obj, point.x, point.y)
 92            else:
 93                if rotation:
 94                    assert pp.raw.FPDFPath_LineTo(obj, height - point.y, point.x)
 95                else:
 96                    assert pp.raw.FPDFPath_LineTo(obj, point.x, point.y)
 97        pp.raw.FPDFPage_InsertObject(new_page, obj)
 98
 99    for bbox, _ in page.graphic_clusters():
100        _rect(new_page, rotation, bbox, width=2, stroke=0x00FFFF)
101
102    for link in page.objlinks:
103        _rect(new_page, rotation, link.bbox, width=0.75, stroke=0x9ACD32)
104
105    for link in page.weblinks:
106        for bbox in link.bboxes:
107            _rect(new_page, rotation, bbox, width=0.75, stroke=0x00FF00)
108
109    for char in page.chars:
110        color = 0x0000FF
111        if char.bbox.width:
112            _rect(new_page, rotation, char.bbox, width=0.5, stroke=0xFF0000)
113            _vline(
114                new_page,
115                rotation,
116                char.bbox.midpoint.x,
117                char.bbox.midpoint.y - 1,
118                char.bbox.midpoint.y + 1,
119                width=0.25,
120                stroke=0xFF0000,
121            )
122            _hline(
123                new_page,
124                rotation,
125                char.bbox.midpoint.y,
126                char.bbox.midpoint.x - 1,
127                char.bbox.midpoint.x + 1,
128                width=0.25,
129                stroke=0xFF0000,
130            )
131            color = 0x000000
132        _vline(new_page, rotation, char.origin.x, char.origin.y - 1, char.origin.y + 1, width=0.25, stroke=color)
133        _hline(new_page, rotation, char.origin.y, char.origin.x - 1, char.origin.x + 1, width=0.25, stroke=color)
134
135    assert pp.raw.FPDFPage_GenerateContent(new_page)
136    pp.raw.FPDF_ClosePage(new_page)
137    return new_doc

Copies each page into a new or existing PDF document and overlays the internal information on top of the content.

  • Renders the bounding boxes in RED and origins in BLACK of all characters.
  • Renders the bounding boxes of web links in BLUE GREEN.
  • Renders the bounding boxes of object links in YELLOW GREEN.
  • Renders all graphics paths in BLUE.
  • Renders the bounding boxes of computed graphics clusters in CYAN.
Parameters
  • page: The page to be annotated.
  • new_doc: The PDF document to copy the page to. If not provided, a new document is created.
  • index: The index of the page in the new document.
Returns

The new document with the annotated page added.

class Document(pypdfium2._helpers.document.PdfDocument):
 33class Document(pp.PdfDocument):
 34    """
 35    The PDF document is the root of the entire data structure and provides
 36    access to PDF metadata, the table of contents, as well as individual
 37    pages.
 38
 39    You should extend from this class for a specific vendor to provide the
 40    correct page class from `page()` function.
 41
 42    This class is a convenience wrapper with caching around the high-level APIs
 43    of pypdfium.
 44    """
 45
 46    def __init__(self, path: Path, autoclose: bool = False):
 47        """
 48        :param path: Path to the PDF to open.
 49        """
 50        path = Path(path)
 51        self.name: str = path.stem
 52        """Stem of the document file name"""
 53        super().__init__(path, autoclose=autoclose)
 54        self._path = path
 55        self._glyph_offsets: dict[str, int] = {}
 56        _LOGGER.debug(f"Loading: {path}")
 57
 58    @cached_property
 59    def metadata(self) -> dict[str, str]:
 60        """The PDF metadata dictionary."""
 61        return self.get_metadata_dict()
 62
 63    @property
 64    def destinations(self) -> Iterator[tuple[int, str]]:
 65        """Yields (page 0-index, named destination) of the whole document."""
 66        for ii in range(pp.raw.FPDF_CountNamedDests(self)):
 67            clength = ctypes.c_long()
 68            pp.raw.FPDF_GetNamedDest(self, ii, None, clength)
 69            cbuffer = ctypes.create_string_buffer(clength.value)
 70            dest = pp.raw.FPDF_GetNamedDest(self, ii, cbuffer, clength)
 71            name = cbuffer.raw[: clength.value].decode("utf-16-le").rstrip("\x00")
 72            page = pp.raw.FPDFDest_GetDestPageIndex(self, dest)
 73            yield (page, name)
 74
 75    @cached_property
 76    def toc(self) -> list[_OutlineItem]:
 77        """
 78        The table of content as a sorted list of outline items ensuring item has
 79        a page index by reusing the last one.
 80        """
 81        tocs = set()
 82        # Sometimes the TOC contains duplicates so we must use a set
 83        last_page_index = 0
 84        for toc in self.get_toc():
 85            dest = toc.get_dest()
 86            last_page_index = (dest and dest.get_index()) or last_page_index
 87            tocs.add(_OutlineItem(toc.level, toc.get_title(), last_page_index))
 88        return list(sorted(list(tocs), key=lambda o: (o.page_index, o.level, o.title)))
 89
 90    @cached_property
 91    def identifier_permanent(self) -> str:
 92        """The permanent file identifier."""
 93        return self.get_identifier(pp.raw.FILEIDTYPE_PERMANENT)
 94
 95    @cached_property
 96    def identifier_changing(self) -> str:
 97        """The changing file identifier."""
 98        return self.get_identifier(pp.raw.FILEIDTYPE_CHANGING)
 99
100    @cached_property
101    def page_count(self) -> int:
102        """The number of pages in the document."""
103        return pp.raw.FPDF_GetPageCount(self)
104
105    @cache
106    def page(self, index: int) -> Page:
107        """
108        :param index: 0-indexed page number.
109        :return: the page object for the index.
110        """
111        assert index < self.page_count
112        return Page(self, index)
113
114    def pages(self, numbers: Iterable[int] = None) -> Iterator[Page]:
115        """
116        :param numbers: an iterable range of page numbers (0-indexed!).
117                        If `None`, then the whole page range is used.
118        :return: yields each page in the range.
119        """
120        if numbers is None:
121            numbers = range(self.page_count)
122        for ii in numbers:
123            if 0 <= ii < self.page_count:
124                yield self.page(ii)
125
126    def __repr__(self) -> str:
127        return f"Doc({self.name})"

The PDF document is the root of the entire data structure and provides access to PDF metadata, the table of contents, as well as individual pages.

You should extend from this class for a specific vendor to provide the correct page class from page() function.

This class is a convenience wrapper with caching around the high-level APIs of pypdfium.

Document(path: pathlib.Path, autoclose: bool = False)
46    def __init__(self, path: Path, autoclose: bool = False):
47        """
48        :param path: Path to the PDF to open.
49        """
50        path = Path(path)
51        self.name: str = path.stem
52        """Stem of the document file name"""
53        super().__init__(path, autoclose=autoclose)
54        self._path = path
55        self._glyph_offsets: dict[str, int] = {}
56        _LOGGER.debug(f"Loading: {path}")
Parameters
  • path: Path to the PDF to open.
name: str

Stem of the document file name

metadata: dict[str, str]
58    @cached_property
59    def metadata(self) -> dict[str, str]:
60        """The PDF metadata dictionary."""
61        return self.get_metadata_dict()

The PDF metadata dictionary.

destinations: Iterator[tuple[int, str]]
63    @property
64    def destinations(self) -> Iterator[tuple[int, str]]:
65        """Yields (page 0-index, named destination) of the whole document."""
66        for ii in range(pp.raw.FPDF_CountNamedDests(self)):
67            clength = ctypes.c_long()
68            pp.raw.FPDF_GetNamedDest(self, ii, None, clength)
69            cbuffer = ctypes.create_string_buffer(clength.value)
70            dest = pp.raw.FPDF_GetNamedDest(self, ii, cbuffer, clength)
71            name = cbuffer.raw[: clength.value].decode("utf-16-le").rstrip("\x00")
72            page = pp.raw.FPDFDest_GetDestPageIndex(self, dest)
73            yield (page, name)

Yields (page 0-index, named destination) of the whole document.

toc: list[modm_data.pdf.document._OutlineItem]
75    @cached_property
76    def toc(self) -> list[_OutlineItem]:
77        """
78        The table of content as a sorted list of outline items ensuring item has
79        a page index by reusing the last one.
80        """
81        tocs = set()
82        # Sometimes the TOC contains duplicates so we must use a set
83        last_page_index = 0
84        for toc in self.get_toc():
85            dest = toc.get_dest()
86            last_page_index = (dest and dest.get_index()) or last_page_index
87            tocs.add(_OutlineItem(toc.level, toc.get_title(), last_page_index))
88        return list(sorted(list(tocs), key=lambda o: (o.page_index, o.level, o.title)))

The table of content as a sorted list of outline items ensuring item has a page index by reusing the last one.

identifier_permanent: str
90    @cached_property
91    def identifier_permanent(self) -> str:
92        """The permanent file identifier."""
93        return self.get_identifier(pp.raw.FILEIDTYPE_PERMANENT)

The permanent file identifier.

identifier_changing: str
95    @cached_property
96    def identifier_changing(self) -> str:
97        """The changing file identifier."""
98        return self.get_identifier(pp.raw.FILEIDTYPE_CHANGING)

The changing file identifier.

page_count: int
100    @cached_property
101    def page_count(self) -> int:
102        """The number of pages in the document."""
103        return pp.raw.FPDF_GetPageCount(self)

The number of pages in the document.

@cache
def page(self, index: int) -> Page:
105    @cache
106    def page(self, index: int) -> Page:
107        """
108        :param index: 0-indexed page number.
109        :return: the page object for the index.
110        """
111        assert index < self.page_count
112        return Page(self, index)
Parameters
  • index: 0-indexed page number.
Returns

the page object for the index.

def pages(self, numbers: Iterable[int] = None) -> Iterator[Page]:
114    def pages(self, numbers: Iterable[int] = None) -> Iterator[Page]:
115        """
116        :param numbers: an iterable range of page numbers (0-indexed!).
117                        If `None`, then the whole page range is used.
118        :return: yields each page in the range.
119        """
120        if numbers is None:
121            numbers = range(self.page_count)
122        for ii in numbers:
123            if 0 <= ii < self.page_count:
124                yield self.page(ii)
Parameters
  • numbers: an iterable range of page numbers (0-indexed!). If None, then the whole page range is used.
Returns

yields each page in the range.

Inherited Members
pypdfium2._helpers.document.PdfDocument
parent
new
formenv
init_forms
close_forms
get_formtype
get_pagemode
is_tagged
save
get_identifier
get_version
get_metadata_value
METADATA_KEYS
get_metadata_dict
count_attachments
get_attachment
new_attachment
del_attachment
get_page
new_page
del_page
import_pages
get_page_size
get_page_label
page_as_xobject
get_toc
pypdfium2.internal.bases.AutoCloseable
close
class Page(pypdfium2._helpers.page.PdfPage):
 25class Page(pp.PdfPage):
 26    """
 27    This class provides low-level access to graphics and characters of the page
 28    and allows searching for characters in an area instead of just text.
 29    """
 30
 31    def __init__(self, document: "modm_data.pdf.Document", index: int):  # noqa: F821
 32        """
 33        :param document: a PDF document.
 34        :param index: 0-index page number.
 35        """
 36        self.index = index
 37        """0-index page number."""
 38        self.number = index + 1
 39        """1-index page number."""
 40
 41        super().__init__(pp.raw.FPDF_LoadPage(document, index), document, document.formenv)
 42        document._add_kid(self)
 43        self._links = None
 44        self._weblinks = None
 45        self._linked = False
 46
 47        _LOGGER.debug(f"Loading: {index}")
 48
 49        self._text = self.get_textpage()
 50        self._linkpage = pp.raw.FPDFLink_LoadWebLinks(self._text)
 51        self._structtree = pp.raw.FPDF_StructTree_GetForPage(self)
 52        # close them in reverse order
 53        weakref.finalize(self, pp.raw.FPDF_StructTree_Close, self._structtree)
 54        weakref.finalize(self, pp.raw.FPDFLink_CloseWebLinks, self._linkpage)
 55
 56    @cached_property
 57    def label(self) -> str:
 58        """The page label."""
 59        return self.pdf.get_page_label(self.index)
 60
 61    @cached_property
 62    def width(self) -> float:
 63        """The page width."""
 64        return self.get_width()
 65
 66    @cached_property
 67    def height(self) -> float:
 68        """The page height."""
 69        return self.get_height()
 70
 71    @cached_property
 72    def rotation(self) -> int:
 73        """The page rotation in degrees."""
 74        return self.get_rotation()
 75
 76    @cached_property
 77    def bbox(self) -> Rectangle:
 78        """The page bounding box."""
 79        return Rectangle(*self.get_bbox())
 80
 81    @cached_property
 82    def char_count(self) -> int:
 83        """The total count of characters."""
 84        return self._text.count_chars()
 85
 86    @cached_property
 87    def _chars(self) -> list[Character | None]:
 88        return [None] * self.char_count
 89
 90    @cached_property
 91    def _glyph_unicodes(self) -> dict[tuple[str, int], int]:
 92        return decode_glyphs(self)
 93
 94    def char(self, index: int) -> Character:
 95        """:return: The character at the 0-index."""
 96        # A list per page is smaller than a global `functools.cache`, which
 97        # also keeps every page alive after the document is closed.
 98        if (char := self._chars[index]) is None:
 99            char = self._chars[index] = Character(self, index)
100        return char
101
102    @property
103    def chars(self) -> Iterator[Character]:
104        """Yields all characters."""
105        for ii in range(self.char_count):
106            yield self.char(ii)
107
108    @cached_property
109    def objlinks(self) -> list[ObjLink]:
110        """All object links."""
111        links = []
112        pos = ctypes.c_int(0)
113        link = pp.raw.FPDF_LINK()
114        while pp.raw.FPDFLink_Enumerate(self, pos, link):
115            links.append(ObjLink(self, link))
116        return links
117
118    @cached_property
119    def weblinks(self) -> list[WebLink]:
120        """All web links."""
121        links = []
122        for ii in range(pp.raw.FPDFLink_CountWebLinks(self._linkpage)):
123            links.append(WebLink(self, ii))
124        return links
125
126    def chars_in_area(self, area: Rectangle) -> list[Character]:
127        """
128        :param area: area to search for character in.
129        :return: All characters found in the area.
130        """
131        found = []
132        # We perform binary searches of the lower and upper y-positions first
133        # lines are ordered by y-position
134        ypositions = list(self._charlines.keys())
135        y_bottom = bisect_left(ypositions, area.bottom)
136        y_top = bisect_right(ypositions, area.top, lo=y_bottom)
137
138        # Then for every line we do another binary search for left and right
139        for ypos in ypositions[y_bottom:y_top]:
140            chars = self._charlines[ypos]
141            x_left = bisect_left(chars, area.left, key=lambda c: c.bbox.midpoint.x)
142            x_right = bisect_right(chars, area.right, lo=x_left, key=lambda c: c.bbox.midpoint.x)
143            # Finally we add all these characters
144            found.extend(chars[x_left:x_right])
145        return found
146
147    def text_in_area(self, area: Rectangle) -> str:
148        """
149        :param area: area to search for text in.
150        :return: Only the text found in the area.
151        """
152        return self._text.get_text_bounded(area.left, area.bottom, area.right, area.top)
153
154    @property
155    def structures(self) -> Iterator[Structure]:
156        """The PDF/UA tags."""
157        count = pp.raw.FPDF_StructTree_CountChildren(self._structtree)
158        for ii in range(count):
159            child = pp.raw.FPDF_StructTree_GetChildAtIndex(self._structtree, ii)
160            yield Structure(self, child)
161
162    def find(self, string: str, case_sensitive: bool = True) -> Iterator[Character]:
163        """
164        Searches for a match string as whole, consecutive words and yields the
165        characters.
166
167        :param string: The search string.
168        :param case_sensitive: Ignore case if false.
169        :return: yields the characters found.
170        """
171        searcher = self._text.search(string, match_case=case_sensitive, match_whole_word=True, consecutive=True)
172        while idx := searcher.get_next():
173            chars = [self.char(ii) for ii in range(idx[0], idx[0] + idx[1])]
174            yield chars
175
176    @cached_property
177    def paths(self) -> list[Path]:
178        """All paths."""
179        return [Path(o) for o in self.get_objects([pp.raw.FPDF_PAGEOBJ_PATH])]
180
181    @cached_property
182    def images(self) -> list[Image]:
183        """All images."""
184        return [Image(o) for o in self.get_objects([pp.raw.FPDF_PAGEOBJ_IMAGE])]
185
186    def graphic_clusters(
187        self, predicate: Callable[[Path | Image], bool] = None, absolute_tolerance: float = None
188    ) -> list[tuple[Rectangle, list[Path]]]:
189        if absolute_tolerance is None:
190            absolute_tolerance = min(self.width, self.height) * 0.01
191
192        # First collect all vertical regions
193        filtered_paths = []
194        for path in self.paths:
195            if predicate is None or predicate(path):
196                filtered_paths.append(path)
197        for image in self.images:
198            if predicate is None or predicate(image):
199                filtered_paths.append(image)
200
201        regions = []
202        for path in sorted(filtered_paths, key=lambda path: path.bbox.y):
203            for reg in regions:
204                if reg.overlaps(path.bbox.bottom, path.bbox.top, absolute_tolerance):
205                    # They overlap, so merge them
206                    reg.v0 = min(reg.v0, path.bbox.bottom)
207                    reg.v1 = max(reg.v1, path.bbox.top)
208                    reg.objs.append(path)
209                    break
210            else:
211                regions.append(Region(path.bbox.bottom, path.bbox.top, path))
212
213        # Now collect horizontal region inside each vertical region
214        for yreg in regions:
215            for path in sorted(filtered_paths, key=lambda path: path.bbox.x):
216                # check if horizontal line is contained in vregion
217                if yreg.contains(path.bbox.y, absolute_tolerance):
218                    for xreg in yreg.subregions:
219                        if xreg.overlaps(path.bbox.left, path.bbox.right, absolute_tolerance):
220                            # They overlap so merge them
221                            xreg.v0 = min(xreg.v0, path.bbox.left)
222                            xreg.v1 = max(xreg.v1, path.bbox.right)
223                            xreg.objs.append(path)
224                            break
225                    else:
226                        yreg.subregions.append(Region(path.bbox.left, path.bbox.right, path))
227
228        clusters = []
229        for yreg in regions:
230            for xreg in yreg.subregions:
231                if len(yreg.subregions) > 1:
232                    # Strip down the height again for subregions
233                    y0, y1 = 1e9, 0
234                    for path in xreg.objs:
235                        y0 = min(y0, path.bbox.bottom)
236                        y1 = max(y1, path.bbox.top)
237                else:
238                    y0, y1 = yreg.v0, yreg.v1
239                bbox = Rectangle(xreg.v0, y0, xreg.v1, y1)
240                clusters.append((bbox, xreg.objs))
241
242        return sorted(clusters, key=lambda c: (-c[0].y, c[0].x))
243
244    def _link_characters(self):
245        if self._linked:
246            return
247        # The in-document links only gives us rectangles and we must find the
248        # linked chars ourselves
249        for link in self.objlinks:
250            for char in self.chars_in_area(link.bbox):
251                char.objlink = link
252        # The weblinks give you an explicit char range, very convenient
253        for link in self.weblinks:
254            for ii in range(*link.range):
255                self.char(ii).weblink = link
256        self._linked = True
257
258    @cached_property
259    def _charlines(self):
260        charlines = defaultdict(list)
261        for char in self.chars:
262            charlines[round(char.bbox.midpoint.y, 1)].append(char)
263
264        orderedchars = OrderedDict.fromkeys(sorted(charlines))
265        for ypos, chars in charlines.items():
266            orderedchars[ypos] = sorted(chars, key=lambda c: c.bbox.midpoint.x)
267
268        return orderedchars

This class provides low-level access to graphics and characters of the page and allows searching for characters in an area instead of just text.

Page(document: Document, index: int)
31    def __init__(self, document: "modm_data.pdf.Document", index: int):  # noqa: F821
32        """
33        :param document: a PDF document.
34        :param index: 0-index page number.
35        """
36        self.index = index
37        """0-index page number."""
38        self.number = index + 1
39        """1-index page number."""
40
41        super().__init__(pp.raw.FPDF_LoadPage(document, index), document, document.formenv)
42        document._add_kid(self)
43        self._links = None
44        self._weblinks = None
45        self._linked = False
46
47        _LOGGER.debug(f"Loading: {index}")
48
49        self._text = self.get_textpage()
50        self._linkpage = pp.raw.FPDFLink_LoadWebLinks(self._text)
51        self._structtree = pp.raw.FPDF_StructTree_GetForPage(self)
52        # close them in reverse order
53        weakref.finalize(self, pp.raw.FPDF_StructTree_Close, self._structtree)
54        weakref.finalize(self, pp.raw.FPDFLink_CloseWebLinks, self._linkpage)
Parameters
  • document: a PDF document.
  • index: 0-index page number.
index

0-index page number.

number

1-index page number.

label: str
56    @cached_property
57    def label(self) -> str:
58        """The page label."""
59        return self.pdf.get_page_label(self.index)

The page label.

width: float
61    @cached_property
62    def width(self) -> float:
63        """The page width."""
64        return self.get_width()

The page width.

height: float
66    @cached_property
67    def height(self) -> float:
68        """The page height."""
69        return self.get_height()

The page height.

rotation: int
71    @cached_property
72    def rotation(self) -> int:
73        """The page rotation in degrees."""
74        return self.get_rotation()

The page rotation in degrees.

bbox: modm_data.utils.Rectangle
76    @cached_property
77    def bbox(self) -> Rectangle:
78        """The page bounding box."""
79        return Rectangle(*self.get_bbox())

The page bounding box.

char_count: int
81    @cached_property
82    def char_count(self) -> int:
83        """The total count of characters."""
84        return self._text.count_chars()

The total count of characters.

def char(self, index: int) -> Character:
 94    def char(self, index: int) -> Character:
 95        """:return: The character at the 0-index."""
 96        # A list per page is smaller than a global `functools.cache`, which
 97        # also keeps every page alive after the document is closed.
 98        if (char := self._chars[index]) is None:
 99            char = self._chars[index] = Character(self, index)
100        return char
Returns

The character at the 0-index.

chars: Iterator[Character]
102    @property
103    def chars(self) -> Iterator[Character]:
104        """Yields all characters."""
105        for ii in range(self.char_count):
106            yield self.char(ii)

Yields all characters.

def chars_in_area( self, area: modm_data.utils.Rectangle) -> list[Character]:
126    def chars_in_area(self, area: Rectangle) -> list[Character]:
127        """
128        :param area: area to search for character in.
129        :return: All characters found in the area.
130        """
131        found = []
132        # We perform binary searches of the lower and upper y-positions first
133        # lines are ordered by y-position
134        ypositions = list(self._charlines.keys())
135        y_bottom = bisect_left(ypositions, area.bottom)
136        y_top = bisect_right(ypositions, area.top, lo=y_bottom)
137
138        # Then for every line we do another binary search for left and right
139        for ypos in ypositions[y_bottom:y_top]:
140            chars = self._charlines[ypos]
141            x_left = bisect_left(chars, area.left, key=lambda c: c.bbox.midpoint.x)
142            x_right = bisect_right(chars, area.right, lo=x_left, key=lambda c: c.bbox.midpoint.x)
143            # Finally we add all these characters
144            found.extend(chars[x_left:x_right])
145        return found
Parameters
  • area: area to search for character in.
Returns

All characters found in the area.

def text_in_area(self, area: modm_data.utils.Rectangle) -> str:
147    def text_in_area(self, area: Rectangle) -> str:
148        """
149        :param area: area to search for text in.
150        :return: Only the text found in the area.
151        """
152        return self._text.get_text_bounded(area.left, area.bottom, area.right, area.top)
Parameters
  • area: area to search for text in.
Returns

Only the text found in the area.

structures: Iterator[Structure]
154    @property
155    def structures(self) -> Iterator[Structure]:
156        """The PDF/UA tags."""
157        count = pp.raw.FPDF_StructTree_CountChildren(self._structtree)
158        for ii in range(count):
159            child = pp.raw.FPDF_StructTree_GetChildAtIndex(self._structtree, ii)
160            yield Structure(self, child)

The PDF/UA tags.

def find( self, string: str, case_sensitive: bool = True) -> Iterator[Character]:
162    def find(self, string: str, case_sensitive: bool = True) -> Iterator[Character]:
163        """
164        Searches for a match string as whole, consecutive words and yields the
165        characters.
166
167        :param string: The search string.
168        :param case_sensitive: Ignore case if false.
169        :return: yields the characters found.
170        """
171        searcher = self._text.search(string, match_case=case_sensitive, match_whole_word=True, consecutive=True)
172        while idx := searcher.get_next():
173            chars = [self.char(ii) for ii in range(idx[0], idx[0] + idx[1])]
174            yield chars

Searches for a match string as whole, consecutive words and yields the characters.

Parameters
  • string: The search string.
  • case_sensitive: Ignore case if false.
Returns

yields the characters found.

paths: list[Path]
176    @cached_property
177    def paths(self) -> list[Path]:
178        """All paths."""
179        return [Path(o) for o in self.get_objects([pp.raw.FPDF_PAGEOBJ_PATH])]

All paths.

images: list[Image]
181    @cached_property
182    def images(self) -> list[Image]:
183        """All images."""
184        return [Image(o) for o in self.get_objects([pp.raw.FPDF_PAGEOBJ_IMAGE])]

All images.

def graphic_clusters( self, predicate: Callable[[Path | Image], bool] = None, absolute_tolerance: float = None) -> list[tuple[modm_data.utils.Rectangle, list[Path]]]:
186    def graphic_clusters(
187        self, predicate: Callable[[Path | Image], bool] = None, absolute_tolerance: float = None
188    ) -> list[tuple[Rectangle, list[Path]]]:
189        if absolute_tolerance is None:
190            absolute_tolerance = min(self.width, self.height) * 0.01
191
192        # First collect all vertical regions
193        filtered_paths = []
194        for path in self.paths:
195            if predicate is None or predicate(path):
196                filtered_paths.append(path)
197        for image in self.images:
198            if predicate is None or predicate(image):
199                filtered_paths.append(image)
200
201        regions = []
202        for path in sorted(filtered_paths, key=lambda path: path.bbox.y):
203            for reg in regions:
204                if reg.overlaps(path.bbox.bottom, path.bbox.top, absolute_tolerance):
205                    # They overlap, so merge them
206                    reg.v0 = min(reg.v0, path.bbox.bottom)
207                    reg.v1 = max(reg.v1, path.bbox.top)
208                    reg.objs.append(path)
209                    break
210            else:
211                regions.append(Region(path.bbox.bottom, path.bbox.top, path))
212
213        # Now collect horizontal region inside each vertical region
214        for yreg in regions:
215            for path in sorted(filtered_paths, key=lambda path: path.bbox.x):
216                # check if horizontal line is contained in vregion
217                if yreg.contains(path.bbox.y, absolute_tolerance):
218                    for xreg in yreg.subregions:
219                        if xreg.overlaps(path.bbox.left, path.bbox.right, absolute_tolerance):
220                            # They overlap so merge them
221                            xreg.v0 = min(xreg.v0, path.bbox.left)
222                            xreg.v1 = max(xreg.v1, path.bbox.right)
223                            xreg.objs.append(path)
224                            break
225                    else:
226                        yreg.subregions.append(Region(path.bbox.left, path.bbox.right, path))
227
228        clusters = []
229        for yreg in regions:
230            for xreg in yreg.subregions:
231                if len(yreg.subregions) > 1:
232                    # Strip down the height again for subregions
233                    y0, y1 = 1e9, 0
234                    for path in xreg.objs:
235                        y0 = min(y0, path.bbox.bottom)
236                        y1 = max(y1, path.bbox.top)
237                else:
238                    y0, y1 = yreg.v0, yreg.v1
239                bbox = Rectangle(xreg.v0, y0, xreg.v1, y1)
240                clusters.append((bbox, xreg.objs))
241
242        return sorted(clusters, key=lambda c: (-c[0].y, c[0].x))
Inherited Members
pypdfium2._helpers.page.PdfPage
raw
pdf
formenv
parent
get_width
get_height
get_size
get_rotation
set_rotation
get_mediabox
set_mediabox
get_cropbox
set_cropbox
get_bleedbox
set_bleedbox
get_trimbox
set_trimbox
get_artbox
set_artbox
get_bbox
get_textpage
insert_obj
remove_obj
gen_content
get_objects
flatten
render
pypdfium2.internal.bases.AutoCloseable
close
class Character:
 37class Character:
 38    """
 39    Each character on the PDF page is represented by a character object,
 40    describing exactly where and how to render the associated glyph.
 41
 42    While there are font flags, PDF files typically use entirely different fonts
 43    to render normal, bold, and italic characters.
 44
 45    The character's loose bounding box may not always be available, since it
 46    must be explicitly provided by the font. The tight bounding box is only
 47    available as long as the glyph is renderable, so a space character may have
 48    a loose, but not a tight bounding box, or none at all.
 49    """
 50
 51    class RenderMode(Enum):
 52        """Tells the PDF viewer how to render this character glyph."""
 53
 54        UNKNOWN = -1
 55        FILL = 0
 56        STROKE = 1
 57        FILL_STROKE = 2
 58        INVISIBLE = 3
 59        FILL_CLIP = 4
 60        STROKE_CLIP = 5
 61        FILL_STROKE_CLIP = 6
 62        CLIP = 7
 63
 64    # A dictionary per character is the largest part of the memory usage
 65    __slots__ = (
 66        "_bbox",
 67        "_fill_",
 68        "_font",
 69        "_index",
 70        "_origin_",
 71        "_page",
 72        "_render_mode_",
 73        "_rotation",
 74        "_size_",
 75        "_stroke_",
 76        "_tbbox_",
 77        "_text",
 78        "_weight_",
 79        "objlink",
 80        "unicode",
 81        "weblink",
 82    )
 83
 84    def __init__(self, page: "modm_data.pdf.page.Page", index: int):  # noqa: F821
 85        """
 86        :param page: The page containing the character.
 87        :param index: The index of the character.
 88        """
 89        self._page = page
 90        self._text = page._text
 91        self._index = index
 92        self._font = None
 93        self._rotation = int(math.degrees(pp.raw.FPDFText_GetCharAngle(self._text, self._index)))
 94
 95        self.unicode: int = pp.raw.FPDFText_GetUnicode(self._text, self._index)
 96        """The unicode value of the character."""
 97        if pp.raw.FPDFText_HasUnicodeMapError(self._text, self._index):
 98            self.unicode = self._page._glyph_unicodes.get((self.font, self.unicode), self.unicode)
 99        if 0xD800 <= self.unicode < 0xDC00:
100            # Characters outside the BMP are split into two UTF-16 code units,
101            # so we combine them into the first and ignore the second one.
102            low = pp.raw.FPDFText_GetUnicode(self._text, self._index + 1)
103            if 0xDC00 <= low < 0xE000:
104                self.unicode = 0x10000 + ((self.unicode - 0xD800) << 10) + low - 0xDC00
105        elif 0xDC00 <= self.unicode < 0xE000:
106            self.unicode = 0
107        self.objlink: "modm_data.pdf.link.ObjLink" = None  # noqa: F821
108        """The object link of this character or `None`"""
109        self.weblink: "modm_data.pdf.link.WebLink" = None  # noqa: F821
110        """The web link of this character or `None`"""
111
112        bbox = Rectangle(*self._em_box())
113        if self._page.rotation:
114            bbox = Rectangle(bbox.p0.y, self._page.height - bbox.p1.x, bbox.p1.y, self._page.height - bbox.p0.x)
115        self._bbox = bbox
116
117    def _em_box(self) -> tuple[float, float, float, float]:
118        # The layout heuristics need the font size and the advance width of a
119        # character, not the extent of its glyph. We therefore compute our own
120        # box, which is exactly one font size tall, one advance width wide and
121        # placed by the text matrix, so that it also works for rotated text.
122        # The loose box of pdfium is not suitable, since its definition changed
123        # between versions and its height depends on the font metrics.
124        tight = self._text.get_charbox(self._index)
125        obj = pp.raw.FPDFText_GetTextObject(self._text, self._index)
126        size = pp.raw.FPDFText_GetFontSize(self._text, self._index)
127        if not obj or not size or pp.raw.FPDFText_IsGenerated(self._text, self._index):
128            return tight
129        font = pp.raw.FPDFTextObj_GetFont(obj)
130        ascent, descent, width = ctypes.c_float(), ctypes.c_float(), ctypes.c_float()
131        pp.raw.FPDFFont_GetAscent(font, size, ascent)
132        pp.raw.FPDFFont_GetDescent(font, size, descent)
133        if ascent.value == descent.value:
134            return tight
135
136        m = pp.raw.FS_MATRIX()
137        ox, oy = ctypes.c_double(), ctypes.c_double()
138        assert pp.raw.FPDFText_GetMatrix(self._text, self._index, m)
139        assert pp.raw.FPDFText_GetCharOrigin(self._text, self._index, ox, oy)
140        ox, oy = ox.value, oy.value
141
142        # Split the font size above and below the baseline like the font does
143        scale = size / (ascent.value - descent.value)
144        y0, y1 = descent.value * scale, ascent.value * scale
145        x1 = 0
146        if pp.raw.FPDFFont_GetGlyphWidth(font, self.unicode, size, width):
147            x1 = width.value
148        if not x1 and (norm := m.a * m.a + m.b * m.b):
149            # The width lookup fails for characters without a reverse unicode
150            # mapping, so we use the extent of the glyph along the baseline.
151            x1 = max(((x - ox) * m.a + (y - oy) * m.b) / norm for x in tight[::2] for y in tight[1::2])
152
153        xs = [ox + m.a * x + m.c * y for x in (0, x1) for y in (y0, y1)]
154        ys = [oy + m.b * x + m.d * y for x in (0, x1) for y in (y0, y1)]
155        return min(xs), min(ys), max(xs), max(ys)
156
157    def generated_space(self) -> "Character":
158        """
159        :return: A zero-sized space character located at the origin of this
160                 character, like the ones pdfium generates for visual gaps.
161        """
162        space = copy.copy(self)
163        space.unicode = 0x20
164        space._bbox = space.tbbox = Rectangle(self.origin.x, self.origin.y, self.origin.x, self.origin.y)
165        space.origin = self.origin
166        return space
167
168    def _font_flags(self) -> tuple[str, int]:
169        if self._font is None:
170            font = ctypes.create_string_buffer(255)
171            flags = ctypes.c_int()
172            pp.raw.FPDFText_GetFontInfo(self._text, self._index, font, 255, flags)
173            # Share one tuple per font instead of one per character
174            font = (font.value.decode("utf-8", errors="replace"), flags.value)
175            self._font = _FONTS.setdefault(font, font)
176        return self._font
177
178    @property
179    def char(self) -> str:
180        """The printable string of the unicode value."""
181        char = chr(self.unicode)
182        return char if char.isprintable() else ""
183
184    @_slot_cache
185    def origin(self) -> Point:
186        """The origin of the character."""
187        x, y = ctypes.c_double(), ctypes.c_double()
188        assert pp.raw.FPDFText_GetCharOrigin(self._text, self._index, x, y)
189        if self._page.rotation:
190            return Point(y.value, self._page.height - x.value)
191        return Point(x.value, y.value)
192
193    @property
194    def width(self) -> float:
195        """The width of the character's bounding box."""
196        if self.rotation:
197            return self.bbox.height
198        return self.bbox.width
199
200    @property
201    def height(self) -> float:
202        """The height of the character's bounding box."""
203        if self.rotation:
204            return self.bbox.width
205        return self.bbox.height
206
207    @_slot_cache
208    def tbbox(self) -> Rectangle:
209        """The tight bounding box of the character."""
210        tbbox = Rectangle(*self._text.get_charbox(self._index))
211        if self._page.rotation:
212            tbbox = Rectangle(tbbox.p0.y, self._page.height - tbbox.p1.x, tbbox.p1.y, self._page.height - tbbox.p0.x)
213        return tbbox
214
215    @property
216    def bbox(self) -> Rectangle:
217        """
218        The loose bounding box of the character.
219        .. note::
220            If the loose bounding box is not available, the tight bounding box
221            is used instead.
222        """
223        if not self._bbox.width or not self._bbox.height:
224            return self.tbbox
225        return self._bbox
226
227    @property
228    def twidth(self) -> float:
229        """The width of the character's tight bounding box."""
230        return self.tbbox.width
231
232    @property
233    def theight(self) -> float:
234        """The height of the character's tight bounding box."""
235        return self.tbbox.height
236
237    @_slot_cache
238    def render_mode(self) -> RenderMode:
239        """The render mode of the character."""
240        return Character.RenderMode(
241            pp.raw.FPDFTextObj_GetTextRenderMode(pp.raw.FPDFText_GetTextObject(self._text, self._index))
242        )
243
244    @property
245    def rotation(self) -> int:
246        """The rotation of the character in degrees modulo 360."""
247        # Special case for vertical text in rotated pages
248        if self._page.rotation == 90 and self._rotation == 0 and self.unicode not in {0x20, 0xA, 0xD}:
249            return 90
250        if self._page.rotation and self._rotation:
251            return (self._page.rotation + self._rotation) % 360
252        return self._rotation
253
254    @_slot_cache
255    def size(self) -> float:
256        """The font size of the character."""
257        return pp.raw.FPDFText_GetFontSize(self._text, self._index)
258
259    @_slot_cache
260    def weight(self) -> int:
261        """The font weight of the character."""
262        return pp.raw.FPDFText_GetFontWeight(self._text, self._index)
263
264    @_slot_cache
265    def fill(self) -> int:
266        """The fill color of the character."""
267        r, g, b, a = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint()
268        pp.raw.FPDFText_GetFillColor(self._text, self._index, r, g, b, a)
269        return r.value << 24 | g.value << 16 | b.value << 8 | a.value
270
271    @_slot_cache
272    def stroke(self) -> int:
273        """The stroke color of the character."""
274        r, g, b, a = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint()
275        pp.raw.FPDFText_GetStrokeColor(self._text, self._index, r, g, b, a)
276        return r.value << 24 | g.value << 16 | b.value << 8 | a.value
277
278    @property
279    def font(self) -> str:
280        """The font name of the character."""
281        return self._font_flags()[0]
282
283    @property
284    def flags(self) -> int:
285        """The font flags of the character."""
286        return self._font_flags()[1]
287
288    @property
289    def has_space_glyph(self) -> bool:
290        """
291        Whether the font of the character has a space with a width. pdfium
292        cannot generate spaces at the visual gaps of text in fonts without.
293        """
294        obj = pp.raw.FPDFText_GetTextObject(self._text, self._index)
295        if not obj:
296            return True
297        width = ctypes.c_float()
298        font = pp.raw.FPDFTextObj_GetFont(obj)
299        return not pp.raw.FPDFFont_GetGlyphWidth(font, 0x20, 1, width) or width.value > 0
300
301    def descr(self) -> str:
302        """Human-readable description of the character for debugging."""
303        char = chr(self.unicode)
304        if not char.isprintable():
305            char = hex(self.unicode)
306        return (
307            f"Chr({char}, {self.size}, {self.weight}, {self.rotation}, "
308            f"{self.render_mode}, {self.font}, {hex(self.flags)}, "
309            f"{self.fill}, {self.stroke}, {repr(self.bbox)})"
310        )
311
312    def __str__(self) -> str:
313        return self.char
314
315    def __repr__(self) -> str:
316        char = chr(self.unicode)
317        escape = {0xA: "\\n", 0xD: "\\r", 0x9: "\\t", 0x20: "␣"}
318        char = escape.get(self.unicode, char if char.isprintable() else hex(self.unicode))
319        return char

Each character on the PDF page is represented by a character object, describing exactly where and how to render the associated glyph.

While there are font flags, PDF files typically use entirely different fonts to render normal, bold, and italic characters.

The character's loose bounding box may not always be available, since it must be explicitly provided by the font. The tight bounding box is only available as long as the glyph is renderable, so a space character may have a loose, but not a tight bounding box, or none at all.

Character(page: Page, index: int)
 84    def __init__(self, page: "modm_data.pdf.page.Page", index: int):  # noqa: F821
 85        """
 86        :param page: The page containing the character.
 87        :param index: The index of the character.
 88        """
 89        self._page = page
 90        self._text = page._text
 91        self._index = index
 92        self._font = None
 93        self._rotation = int(math.degrees(pp.raw.FPDFText_GetCharAngle(self._text, self._index)))
 94
 95        self.unicode: int = pp.raw.FPDFText_GetUnicode(self._text, self._index)
 96        """The unicode value of the character."""
 97        if pp.raw.FPDFText_HasUnicodeMapError(self._text, self._index):
 98            self.unicode = self._page._glyph_unicodes.get((self.font, self.unicode), self.unicode)
 99        if 0xD800 <= self.unicode < 0xDC00:
100            # Characters outside the BMP are split into two UTF-16 code units,
101            # so we combine them into the first and ignore the second one.
102            low = pp.raw.FPDFText_GetUnicode(self._text, self._index + 1)
103            if 0xDC00 <= low < 0xE000:
104                self.unicode = 0x10000 + ((self.unicode - 0xD800) << 10) + low - 0xDC00
105        elif 0xDC00 <= self.unicode < 0xE000:
106            self.unicode = 0
107        self.objlink: "modm_data.pdf.link.ObjLink" = None  # noqa: F821
108        """The object link of this character or `None`"""
109        self.weblink: "modm_data.pdf.link.WebLink" = None  # noqa: F821
110        """The web link of this character or `None`"""
111
112        bbox = Rectangle(*self._em_box())
113        if self._page.rotation:
114            bbox = Rectangle(bbox.p0.y, self._page.height - bbox.p1.x, bbox.p1.y, self._page.height - bbox.p0.x)
115        self._bbox = bbox
Parameters
  • page: The page containing the character.
  • index: The index of the character.
unicode: int

The unicode value of the character.

def generated_space(self) -> Character:
157    def generated_space(self) -> "Character":
158        """
159        :return: A zero-sized space character located at the origin of this
160                 character, like the ones pdfium generates for visual gaps.
161        """
162        space = copy.copy(self)
163        space.unicode = 0x20
164        space._bbox = space.tbbox = Rectangle(self.origin.x, self.origin.y, self.origin.x, self.origin.y)
165        space.origin = self.origin
166        return space
Returns

A zero-sized space character located at the origin of this character, like the ones pdfium generates for visual gaps.

char: str
178    @property
179    def char(self) -> str:
180        """The printable string of the unicode value."""
181        char = chr(self.unicode)
182        return char if char.isprintable() else ""

The printable string of the unicode value.

origin

The origin of the character.

width: float
193    @property
194    def width(self) -> float:
195        """The width of the character's bounding box."""
196        if self.rotation:
197            return self.bbox.height
198        return self.bbox.width

The width of the character's bounding box.

height: float
200    @property
201    def height(self) -> float:
202        """The height of the character's bounding box."""
203        if self.rotation:
204            return self.bbox.width
205        return self.bbox.height

The height of the character's bounding box.

tbbox

The tight bounding box of the character.

bbox: modm_data.utils.Rectangle
215    @property
216    def bbox(self) -> Rectangle:
217        """
218        The loose bounding box of the character.
219        .. note::
220            If the loose bounding box is not available, the tight bounding box
221            is used instead.
222        """
223        if not self._bbox.width or not self._bbox.height:
224            return self.tbbox
225        return self._bbox

The loose bounding box of the character.

If the loose bounding box is not available, the tight bounding box is used instead.

twidth: float
227    @property
228    def twidth(self) -> float:
229        """The width of the character's tight bounding box."""
230        return self.tbbox.width

The width of the character's tight bounding box.

theight: float
232    @property
233    def theight(self) -> float:
234        """The height of the character's tight bounding box."""
235        return self.tbbox.height

The height of the character's tight bounding box.

render_mode

The render mode of the character.

rotation: int
244    @property
245    def rotation(self) -> int:
246        """The rotation of the character in degrees modulo 360."""
247        # Special case for vertical text in rotated pages
248        if self._page.rotation == 90 and self._rotation == 0 and self.unicode not in {0x20, 0xA, 0xD}:
249            return 90
250        if self._page.rotation and self._rotation:
251            return (self._page.rotation + self._rotation) % 360
252        return self._rotation

The rotation of the character in degrees modulo 360.

size

The font size of the character.

weight

The font weight of the character.

fill

The fill color of the character.

stroke

The stroke color of the character.

font: str
278    @property
279    def font(self) -> str:
280        """The font name of the character."""
281        return self._font_flags()[0]

The font name of the character.

flags: int
283    @property
284    def flags(self) -> int:
285        """The font flags of the character."""
286        return self._font_flags()[1]

The font flags of the character.

has_space_glyph: bool
288    @property
289    def has_space_glyph(self) -> bool:
290        """
291        Whether the font of the character has a space with a width. pdfium
292        cannot generate spaces at the visual gaps of text in fonts without.
293        """
294        obj = pp.raw.FPDFText_GetTextObject(self._text, self._index)
295        if not obj:
296            return True
297        width = ctypes.c_float()
298        font = pp.raw.FPDFTextObj_GetFont(obj)
299        return not pp.raw.FPDFFont_GetGlyphWidth(font, 0x20, 1, width) or width.value > 0

Whether the font of the character has a space with a width. pdfium cannot generate spaces at the visual gaps of text in fonts without.

def descr(self) -> str:
301    def descr(self) -> str:
302        """Human-readable description of the character for debugging."""
303        char = chr(self.unicode)
304        if not char.isprintable():
305            char = hex(self.unicode)
306        return (
307            f"Chr({char}, {self.size}, {self.weight}, {self.rotation}, "
308            f"{self.render_mode}, {self.font}, {hex(self.flags)}, "
309            f"{self.fill}, {self.stroke}, {repr(self.bbox)})"
310        )

Human-readable description of the character for debugging.

class Character.RenderMode(enum.Enum):
51    class RenderMode(Enum):
52        """Tells the PDF viewer how to render this character glyph."""
53
54        UNKNOWN = -1
55        FILL = 0
56        STROKE = 1
57        FILL_STROKE = 2
58        INVISIBLE = 3
59        FILL_CLIP = 4
60        STROKE_CLIP = 5
61        FILL_STROKE_CLIP = 6
62        CLIP = 7

Tells the PDF viewer how to render this character glyph.

UNKNOWN = <RenderMode.UNKNOWN: -1>
FILL = <RenderMode.FILL: 0>
STROKE = <RenderMode.STROKE: 1>
FILL_STROKE = <RenderMode.FILL_STROKE: 2>
INVISIBLE = <RenderMode.INVISIBLE: 3>
FILL_CLIP = <RenderMode.FILL_CLIP: 4>
STROKE_CLIP = <RenderMode.STROKE_CLIP: 5>
FILL_STROKE_CLIP = <RenderMode.FILL_STROKE_CLIP: 6>
CLIP = <RenderMode.CLIP: 7>
Inherited Members
enum.Enum
name
value
class Path(pypdfium2._helpers.pageobjects.PdfObject):
 12class Path(pp.PdfObject):
 13    """
 14    PDF uses a subset of the PostScript graphics language, which draws vector
 15    paths with various rendering options. We are only interested in the basic
 16    properties, in particular, for recognizing table cell borders.
 17
 18    This class specializes `pypdfium2.PdfObject` to add accessors for  graphics
 19    containing vector paths of various configurations.
 20
 21    You must construct the paths by calling `modm_data.pdf.page.Page.paths`.
 22    """
 23
 24    class Type(Enum):
 25        """Path Type"""
 26
 27        LINE = 0
 28        BEZIER = 1
 29        MOVE = 2
 30
 31    class Cap(Enum):
 32        """Path Cap Type"""
 33
 34        BUTT = 0
 35        ROUND = 1
 36        PROJECTING_SQUARE = 2
 37
 38    class Join(Enum):
 39        """Path Join Type"""
 40
 41        MITER = 0
 42        ROUND = 1
 43        BEVEL = 2
 44
 45    # Overwrite the PdfPageObject.__new__ function
 46    def __new__(cls, *args, **kwargs):
 47        return object.__new__(cls)
 48
 49    def __init__(self, obj):
 50        """
 51        :param obj: PDF object of the path.
 52        """
 53        super().__init__(obj.raw, obj.page, obj.pdf, obj.container, obj.level)
 54        assert pp.raw.FPDFPageObj_GetType(obj.raw) == pp.raw.FPDF_PAGEOBJ_PATH
 55        self.type = pp.raw.FPDF_PAGEOBJ_PATH
 56
 57    @cached_property
 58    def matrix(self) -> pp.PdfMatrix:
 59        """The transformation matrix."""
 60        return self.get_matrix()
 61
 62    @cached_property
 63    def count(self) -> int:
 64        """Number of segments in this path."""
 65        return pp.raw.FPDFPath_CountSegments(self)
 66
 67    @cached_property
 68    def fill(self) -> int:
 69        """The fill color encoded as 32-bit RGBA."""
 70        r, g, b, a = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint()
 71        assert pp.raw.FPDFPageObj_GetFillColor(self, r, g, b, a)
 72        return r.value << 24 | g.value << 16 | b.value << 8 | a.value
 73
 74    @cached_property
 75    def stroke(self) -> int:
 76        """The stroke color encoded as 32-bit RGBA."""
 77        r, g, b, a = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint()
 78        assert pp.raw.FPDFPageObj_GetStrokeColor(self, r, g, b, a)
 79        return r.value << 24 | g.value << 16 | b.value << 8 | a.value
 80
 81    @cached_property
 82    def width(self) -> float:
 83        """The stroke width."""
 84        width = ctypes.c_float()
 85        assert pp.raw.FPDFPageObj_GetStrokeWidth(self, width)
 86        return width.value
 87
 88    @cached_property
 89    def draw_mode(self) -> tuple[int, bool]:
 90        """
 91        The fill mode (0: not filled, 1: even-odd rule, 2: non-zero winding
 92        rule) and whether the path is stroked.
 93        """
 94        fill, stroke = ctypes.c_int(), ctypes.c_int()
 95        assert pp.raw.FPDFPath_GetDrawMode(self, fill, stroke)
 96        return fill.value, bool(stroke.value)
 97
 98    @cached_property
 99    def dashes(self) -> list[float]:
100        """The stroke dash pattern or an empty list for a solid stroke."""
101        count = pp.raw.FPDFPageObj_GetDashCount(self)
102        if count <= 0:
103            return []
104        dashes = (ctypes.c_float * count)()
105        assert pp.raw.FPDFPageObj_GetDashArray(self, dashes, count)
106        return list(dashes)
107
108    @cached_property
109    def cap(self) -> Cap:
110        """Line cap type."""
111        return Path.Cap(pp.raw.FPDFPageObj_GetLineCap(self))
112
113    @cached_property
114    def join(self) -> Join:
115        """Line join type."""
116        return Path.Join(pp.raw.FPDFPageObj_GetLineJoin(self))
117
118    @cached_property
119    def bbox(self) -> Rectangle:
120        """
121        Bounding box of the path.
122        .. warning::
123            The bounding is only approximated using the control points!
124            Therefore bezier curves will likely have a larger bounding box.
125        """
126        left, bottom = ctypes.c_float(), ctypes.c_float()
127        right, top = ctypes.c_float(), ctypes.c_float()
128        assert pp.raw.FPDFPageObj_GetBounds(self, left, bottom, right, top)
129        bbox = Rectangle(left.value, bottom.value, right.value, top.value)
130        if self.page.rotation:
131            bbox = Rectangle(bbox.p0.y, self.page.height - bbox.p1.x, bbox.p1.y, self.page.height - bbox.p0.x)
132        return bbox
133
134    @cached_property
135    def points(self) -> list[Point]:
136        """
137        List of points of the path. If the path is closed, the first point is
138        added to the end of the list.
139        """
140        points = []
141        for ii in range(self.count):
142            seg = pp.raw.FPDFPath_GetPathSegment(self, ii)
143            ptype = Path.Type(pp.raw.FPDFPathSegment_GetType(seg))
144            # The first point should always be MOVETO
145            assert ii or ptype == Path.Type.MOVE
146
147            x, y = ctypes.c_float(), ctypes.c_float()
148            assert pp.raw.FPDFPathSegment_GetPoint(seg, x, y)
149            x, y = self.matrix.on_point(x.value, y.value)
150            points.append(Point(x, y, type=ptype))
151
152            if ptype == Path.Type.MOVE:
153                start = points[-1]
154            if pp.raw.FPDFPathSegment_GetClose(seg):
155                # Closing a path draws a line to the start of the current subpath
156                points.append(Point(start.x, start.y, type=Path.Type.LINE))
157
158        if self.page.rotation:
159            points = [Point(y, self.page.height - x, type=p.type) for p in points]
160        return points
161
162    @cached_property
163    def lines(self) -> list[Line]:
164        """List of lines between the path points."""
165        points = self.points
166        return [
167            Line(points[ii], points[ii + 1], width=self.width, type=points[ii + 1].type)
168            for ii in range(len(points) - 1)
169        ]
170
171    def __repr__(self) -> str:
172        points = ",".join(repr(p) for p in self.points)
173        return f"P{self.count}={points}"

PDF uses a subset of the PostScript graphics language, which draws vector paths with various rendering options. We are only interested in the basic properties, in particular, for recognizing table cell borders.

This class specializes pypdfium2.PdfObject to add accessors for graphics containing vector paths of various configurations.

You must construct the paths by calling modm_data.pdf.page.Page.paths.

Path(obj)
49    def __init__(self, obj):
50        """
51        :param obj: PDF object of the path.
52        """
53        super().__init__(obj.raw, obj.page, obj.pdf, obj.container, obj.level)
54        assert pp.raw.FPDFPageObj_GetType(obj.raw) == pp.raw.FPDF_PAGEOBJ_PATH
55        self.type = pp.raw.FPDF_PAGEOBJ_PATH
Parameters
  • obj: PDF object of the path.
type
matrix: pypdfium2._helpers.matrix.PdfMatrix
57    @cached_property
58    def matrix(self) -> pp.PdfMatrix:
59        """The transformation matrix."""
60        return self.get_matrix()

The transformation matrix.

count: int
62    @cached_property
63    def count(self) -> int:
64        """Number of segments in this path."""
65        return pp.raw.FPDFPath_CountSegments(self)

Number of segments in this path.

fill: int
67    @cached_property
68    def fill(self) -> int:
69        """The fill color encoded as 32-bit RGBA."""
70        r, g, b, a = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint()
71        assert pp.raw.FPDFPageObj_GetFillColor(self, r, g, b, a)
72        return r.value << 24 | g.value << 16 | b.value << 8 | a.value

The fill color encoded as 32-bit RGBA.

stroke: int
74    @cached_property
75    def stroke(self) -> int:
76        """The stroke color encoded as 32-bit RGBA."""
77        r, g, b, a = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint()
78        assert pp.raw.FPDFPageObj_GetStrokeColor(self, r, g, b, a)
79        return r.value << 24 | g.value << 16 | b.value << 8 | a.value

The stroke color encoded as 32-bit RGBA.

width: float
81    @cached_property
82    def width(self) -> float:
83        """The stroke width."""
84        width = ctypes.c_float()
85        assert pp.raw.FPDFPageObj_GetStrokeWidth(self, width)
86        return width.value

The stroke width.

draw_mode: tuple[int, bool]
88    @cached_property
89    def draw_mode(self) -> tuple[int, bool]:
90        """
91        The fill mode (0: not filled, 1: even-odd rule, 2: non-zero winding
92        rule) and whether the path is stroked.
93        """
94        fill, stroke = ctypes.c_int(), ctypes.c_int()
95        assert pp.raw.FPDFPath_GetDrawMode(self, fill, stroke)
96        return fill.value, bool(stroke.value)

The fill mode (0: not filled, 1: even-odd rule, 2: non-zero winding rule) and whether the path is stroked.

dashes: list[float]
 98    @cached_property
 99    def dashes(self) -> list[float]:
100        """The stroke dash pattern or an empty list for a solid stroke."""
101        count = pp.raw.FPDFPageObj_GetDashCount(self)
102        if count <= 0:
103            return []
104        dashes = (ctypes.c_float * count)()
105        assert pp.raw.FPDFPageObj_GetDashArray(self, dashes, count)
106        return list(dashes)

The stroke dash pattern or an empty list for a solid stroke.

cap: Path.Cap
108    @cached_property
109    def cap(self) -> Cap:
110        """Line cap type."""
111        return Path.Cap(pp.raw.FPDFPageObj_GetLineCap(self))

Line cap type.

join: Path.Join
113    @cached_property
114    def join(self) -> Join:
115        """Line join type."""
116        return Path.Join(pp.raw.FPDFPageObj_GetLineJoin(self))

Line join type.

bbox: modm_data.utils.Rectangle
118    @cached_property
119    def bbox(self) -> Rectangle:
120        """
121        Bounding box of the path.
122        .. warning::
123            The bounding is only approximated using the control points!
124            Therefore bezier curves will likely have a larger bounding box.
125        """
126        left, bottom = ctypes.c_float(), ctypes.c_float()
127        right, top = ctypes.c_float(), ctypes.c_float()
128        assert pp.raw.FPDFPageObj_GetBounds(self, left, bottom, right, top)
129        bbox = Rectangle(left.value, bottom.value, right.value, top.value)
130        if self.page.rotation:
131            bbox = Rectangle(bbox.p0.y, self.page.height - bbox.p1.x, bbox.p1.y, self.page.height - bbox.p0.x)
132        return bbox

Bounding box of the path.

The bounding is only approximated using the control points! Therefore bezier curves will likely have a larger bounding box.

points: list[modm_data.utils.Point]
134    @cached_property
135    def points(self) -> list[Point]:
136        """
137        List of points of the path. If the path is closed, the first point is
138        added to the end of the list.
139        """
140        points = []
141        for ii in range(self.count):
142            seg = pp.raw.FPDFPath_GetPathSegment(self, ii)
143            ptype = Path.Type(pp.raw.FPDFPathSegment_GetType(seg))
144            # The first point should always be MOVETO
145            assert ii or ptype == Path.Type.MOVE
146
147            x, y = ctypes.c_float(), ctypes.c_float()
148            assert pp.raw.FPDFPathSegment_GetPoint(seg, x, y)
149            x, y = self.matrix.on_point(x.value, y.value)
150            points.append(Point(x, y, type=ptype))
151
152            if ptype == Path.Type.MOVE:
153                start = points[-1]
154            if pp.raw.FPDFPathSegment_GetClose(seg):
155                # Closing a path draws a line to the start of the current subpath
156                points.append(Point(start.x, start.y, type=Path.Type.LINE))
157
158        if self.page.rotation:
159            points = [Point(y, self.page.height - x, type=p.type) for p in points]
160        return points

List of points of the path. If the path is closed, the first point is added to the end of the list.

lines: list[modm_data.utils.Line]
162    @cached_property
163    def lines(self) -> list[Line]:
164        """List of lines between the path points."""
165        points = self.points
166        return [
167            Line(points[ii], points[ii + 1], width=self.width, type=points[ii + 1].type)
168            for ii in range(len(points) - 1)
169        ]

List of lines between the path points.

Inherited Members
pypdfium2._helpers.pageobjects.PdfObject
raw
page
pdf
container
level
parent
get_bounds
get_quad_points
get_matrix
set_matrix
transform
pypdfium2.internal.bases.AutoCloseable
close
class Path.Type(enum.Enum):
24    class Type(Enum):
25        """Path Type"""
26
27        LINE = 0
28        BEZIER = 1
29        MOVE = 2

Path Type

LINE = <Type.LINE: 0>
BEZIER = <Type.BEZIER: 1>
MOVE = <Type.MOVE: 2>
Inherited Members
enum.Enum
name
value
class Path.Cap(enum.Enum):
31    class Cap(Enum):
32        """Path Cap Type"""
33
34        BUTT = 0
35        ROUND = 1
36        PROJECTING_SQUARE = 2

Path Cap Type

BUTT = <Cap.BUTT: 0>
ROUND = <Cap.ROUND: 1>
PROJECTING_SQUARE = <Cap.PROJECTING_SQUARE: 2>
Inherited Members
enum.Enum
name
value
class Path.Join(enum.Enum):
38    class Join(Enum):
39        """Path Join Type"""
40
41        MITER = 0
42        ROUND = 1
43        BEVEL = 2

Path Join Type

MITER = <Join.MITER: 0>
ROUND = <Join.ROUND: 1>
BEVEL = <Join.BEVEL: 2>
Inherited Members
enum.Enum
name
value
class Image(pypdfium2._helpers.pageobjects.PdfImage):
 12class Image(pp.PdfImage):
 13    """
 14    This class extends `pypdfium2.PdfImage` to align it with the interface of
 15    the `Path` class so that it can be used in the same
 16    algorithms without filtering.
 17
 18    You must construct the images by calling `modm_data.pdf.page.Page.images`.
 19
 20    .. note:: Images are currently ignored.
 21    """
 22
 23    # Overwrite the PdfPageObject.__new__ function
 24    def __new__(cls, *args, **kwargs):
 25        return object.__new__(cls)
 26
 27    def __init__(self, obj):
 28        """
 29        :param obj: Page object of the image.
 30        """
 31        super().__init__(obj.raw, obj.page, obj.pdf, obj.container, obj.level)
 32        assert pp.raw.FPDFPageObj_GetType(obj.raw) == pp.raw.FPDF_PAGEOBJ_IMAGE
 33        self.type = pp.raw.FPDF_PAGEOBJ_IMAGE
 34
 35        self.count: int = 4
 36        """Number of segments. Always 4 due to rectangular image form.
 37           (For compatibility with `Path.count`.)"""
 38        self.stroke: int = 0
 39        """The border stroke color. Always 0.
 40           (For compatibility with `Path.stroke`.)"""
 41        self.fill: int = 0
 42        """The image fill color. Always 0.
 43           (For compatibility with `Path.fill`.)"""
 44        self.width: float = 0
 45        """The border line width. Always 0.
 46           (For compatibility with `Path.width`.)"""
 47
 48    @cached_property
 49    def matrix(self) -> pp.PdfMatrix:
 50        """The transformation matrix."""
 51        return self.get_matrix()
 52
 53    @cached_property
 54    def bbox(self) -> Rectangle:
 55        """The bounding box of the image."""
 56        bbox = Rectangle(*self.get_bounds())
 57        if self.page.rotation:
 58            bbox = Rectangle(bbox.p0.y, self.page.height - bbox.p1.x, bbox.p1.y, self.page.height - bbox.p0.x)
 59        return bbox
 60
 61    @cached_property
 62    def points(self) -> list[Point]:
 63        """
 64        The 4 points of the bounding box.
 65        (For compatibility with `Path.points`.)
 66        """
 67        points = self.bbox.points
 68        if self.page.rotation:
 69            points = [Point(p.y, self.page.height - p.x, p.type) for p in points]
 70        return points
 71
 72    @cached_property
 73    def lines(self) -> list[Line]:
 74        """
 75        The 4 lines of the bounding box.
 76        (For compatibility with `Path.lines`.)
 77        """
 78        p = self.points
 79        return [
 80            Line(p[0], p[1], p[1].type, 0),
 81            Line(p[1], p[2], p[2].type, 0),
 82            Line(p[2], p[3], p[3].type, 0),
 83            Line(p[3], p[0], p[0].type, 0),
 84        ]
 85
 86    def encode(self) -> tuple[str, bytes]:
 87        """
 88        Encodes the image in its embedded format (JPEG) without
 89        converting it. All other formats are encoded as lossless PNG.
 90
 91        :return: The file suffix and content of the image file.
 92        """
 93        filters = self.get_filters(skip_simple=True)
 94        if filters == ["DCTDecode"]:
 95            return ".jpg", bytes(self.get_data(decode_simple=True))
 96
 97        bitmap = self.get_bitmap()
 98        channels, width, height = bitmap.n_channels, bitmap.width, bitmap.height
 99        # PNG color types: grayscale, RGB, RGBA
100        color_type = {1: 0, 3: 2, 4: 6}[channels]
101        buffer = bytes(bitmap.buffer)
102        rows = bytearray()
103        for yy in range(height):
104            row = bytearray(buffer[yy * bitmap.stride : yy * bitmap.stride + width * channels])
105            if channels >= 3:
106                # The bitmap is BGR(A) or BGRx
107                row[0::channels], row[2::channels] = row[2::channels], row[0::channels]
108                if bitmap.format == pp.raw.FPDFBitmap_BGRx:
109                    row[3::4] = b"\xff" * width
110            rows += b"\0" + row
111
112        def _chunk(name: bytes, data: bytes) -> bytes:
113            return struct.pack(">I", len(data)) + name + data + struct.pack(">I", zlib.crc32(name + data))
114
115        return ".png", (
116            b"\x89PNG\r\n\x1a\n"
117            + _chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, color_type, 0, 0, 0))
118            + _chunk(b"IDAT", zlib.compress(bytes(rows), 9))
119            + _chunk(b"IEND", b"")
120        )
121
122    def __repr__(self) -> str:
123        return f"I{self.bbox}"

This class extends pypdfium2.PdfImage to align it with the interface of the Path class so that it can be used in the same algorithms without filtering.

You must construct the images by calling modm_data.pdf.page.Page.images.

Images are currently ignored.
Image(obj)
27    def __init__(self, obj):
28        """
29        :param obj: Page object of the image.
30        """
31        super().__init__(obj.raw, obj.page, obj.pdf, obj.container, obj.level)
32        assert pp.raw.FPDFPageObj_GetType(obj.raw) == pp.raw.FPDF_PAGEOBJ_IMAGE
33        self.type = pp.raw.FPDF_PAGEOBJ_IMAGE
34
35        self.count: int = 4
36        """Number of segments. Always 4 due to rectangular image form.
37           (For compatibility with `Path.count`.)"""
38        self.stroke: int = 0
39        """The border stroke color. Always 0.
40           (For compatibility with `Path.stroke`.)"""
41        self.fill: int = 0
42        """The image fill color. Always 0.
43           (For compatibility with `Path.fill`.)"""
44        self.width: float = 0
45        """The border line width. Always 0.
46           (For compatibility with `Path.width`.)"""
Parameters
  • obj: Page object of the image.
type
count: int

Number of segments. Always 4 due to rectangular image form. (For compatibility with Path.count.)

stroke: int

The border stroke color. Always 0. (For compatibility with Path.stroke.)

fill: int

The image fill color. Always 0. (For compatibility with Path.fill.)

width: float

The border line width. Always 0. (For compatibility with Path.width.)

matrix: pypdfium2._helpers.matrix.PdfMatrix
48    @cached_property
49    def matrix(self) -> pp.PdfMatrix:
50        """The transformation matrix."""
51        return self.get_matrix()

The transformation matrix.

bbox: modm_data.utils.Rectangle
53    @cached_property
54    def bbox(self) -> Rectangle:
55        """The bounding box of the image."""
56        bbox = Rectangle(*self.get_bounds())
57        if self.page.rotation:
58            bbox = Rectangle(bbox.p0.y, self.page.height - bbox.p1.x, bbox.p1.y, self.page.height - bbox.p0.x)
59        return bbox

The bounding box of the image.

points: list[modm_data.utils.Point]
61    @cached_property
62    def points(self) -> list[Point]:
63        """
64        The 4 points of the bounding box.
65        (For compatibility with `Path.points`.)
66        """
67        points = self.bbox.points
68        if self.page.rotation:
69            points = [Point(p.y, self.page.height - p.x, p.type) for p in points]
70        return points

The 4 points of the bounding box. (For compatibility with Path.points.)

lines: list[modm_data.utils.Line]
72    @cached_property
73    def lines(self) -> list[Line]:
74        """
75        The 4 lines of the bounding box.
76        (For compatibility with `Path.lines`.)
77        """
78        p = self.points
79        return [
80            Line(p[0], p[1], p[1].type, 0),
81            Line(p[1], p[2], p[2].type, 0),
82            Line(p[2], p[3], p[3].type, 0),
83            Line(p[3], p[0], p[0].type, 0),
84        ]

The 4 lines of the bounding box. (For compatibility with Path.lines.)

def encode(self) -> tuple[str, bytes]:
 86    def encode(self) -> tuple[str, bytes]:
 87        """
 88        Encodes the image in its embedded format (JPEG) without
 89        converting it. All other formats are encoded as lossless PNG.
 90
 91        :return: The file suffix and content of the image file.
 92        """
 93        filters = self.get_filters(skip_simple=True)
 94        if filters == ["DCTDecode"]:
 95            return ".jpg", bytes(self.get_data(decode_simple=True))
 96
 97        bitmap = self.get_bitmap()
 98        channels, width, height = bitmap.n_channels, bitmap.width, bitmap.height
 99        # PNG color types: grayscale, RGB, RGBA
100        color_type = {1: 0, 3: 2, 4: 6}[channels]
101        buffer = bytes(bitmap.buffer)
102        rows = bytearray()
103        for yy in range(height):
104            row = bytearray(buffer[yy * bitmap.stride : yy * bitmap.stride + width * channels])
105            if channels >= 3:
106                # The bitmap is BGR(A) or BGRx
107                row[0::channels], row[2::channels] = row[2::channels], row[0::channels]
108                if bitmap.format == pp.raw.FPDFBitmap_BGRx:
109                    row[3::4] = b"\xff" * width
110            rows += b"\0" + row
111
112        def _chunk(name: bytes, data: bytes) -> bytes:
113            return struct.pack(">I", len(data)) + name + data + struct.pack(">I", zlib.crc32(name + data))
114
115        return ".png", (
116            b"\x89PNG\r\n\x1a\n"
117            + _chunk(b"IHDR", struct.pack(">IIBBBBB", width, height, 8, color_type, 0, 0, 0))
118            + _chunk(b"IDAT", zlib.compress(bytes(rows), 9))
119            + _chunk(b"IEND", b"")
120        )

Encodes the image in its embedded format (JPEG) without converting it. All other formats are encoded as lossless PNG.

Returns

The file suffix and content of the image file.

Inherited Members
pypdfium2._helpers.pageobjects.PdfImage
SIMPLE_FILTERS
new
get_metadata
get_px_size
load_jpeg
set_bitmap
get_bitmap
get_data
get_filters
extract
pypdfium2._helpers.pageobjects.PdfObject
raw
page
pdf
container
level
parent
get_bounds
get_quad_points
get_matrix
set_matrix
transform
pypdfium2.internal.bases.AutoCloseable
close
class Structure:
 11class Structure:
 12    """
 13    A tagged PDF/UA (Universal Accessibility) contains the structure of content
 14    as a tree data structure with similar semantics to HTML. Sadly, the quality
 15    of the tags depends heavily on the PDF creation software. See
 16    [Overview of PDF tags](https://accessible-pdf.info/en/basics/general/overview-of-the-pdf-tags/).
 17
 18    An example of an accessible pdf that can be inspected via these classes:
 19    [Rock On, D.C. Music Festival](https://commonlook.com/wp-content/uploads/2020/04/accessible-pdf-example.pdf).
 20
 21    This class is a convenience wrapper around [the pdfium structtree methods](
 22    https://pdfium.googlesource.com/pdfium/+/main/public/fpdf_structtree.h).
 23    """
 24
 25    def __init__(self, page: "modm_data.pdf.page.Page", element: pp.raw.FPDF_STRUCTELEMENT, parent: "Structure" = None):  # noqa: F821
 26        self._page = page
 27        self._element = element
 28        self.parent: Structure = weakref.ref(parent) if parent else None
 29        """The parent node."""
 30
 31    def _get_string(self, function) -> str:
 32        length = function(self._element, 0, 0)
 33        clength = ctypes.c_ulong(length)
 34        cbuffer = ctypes.create_string_buffer(length)
 35        function(self._element, cbuffer, clength)
 36        return bytes(cbuffer).decode("utf-16-le", errors="ignore")
 37
 38    @cached_property
 39    def title(self) -> str:
 40        """Title `/T`"""
 41        return self._get_string(pp.raw.FPDF_StructElement_GetTitle)
 42
 43    @cached_property
 44    def actual_text(self) -> str:
 45        """The actual text."""
 46        return self._get_string(pp.raw.FPDF_StructElement_GetActualText)
 47
 48    @cached_property
 49    def alt_text(self) -> str:
 50        """Alternate Text"""
 51        return self._get_string(pp.raw.FPDF_StructElement_GetAltText)
 52
 53    @cached_property
 54    def type(self) -> str:
 55        """Type `/S`"""
 56        return self._get_string(pp.raw.FPDF_StructElement_GetType)
 57
 58    @cached_property
 59    def obj_type(self) -> str:
 60        """Object Type `/Type`"""
 61        return self._get_string(pp.raw.FPDF_StructElement_GetObjType)
 62
 63    @cached_property
 64    def language(self) -> str:
 65        """The case-insensitive IETF BCP 47 language code."""
 66        return self._get_string(pp.raw.FPDF_StructElement_GetLang)
 67
 68    @cached_property
 69    def id(self) -> str:
 70        """Identifier"""
 71        return self._get_string(pp.raw.FPDF_StructElement_GetID)
 72
 73    @cached_property
 74    def marked_ids(self) -> list[int]:
 75        """List of marked content identifiers"""
 76        ids = []
 77        for index in range(pp.raw.FPDF_StructElement_GetMarkedContentIdCount(self._element)):
 78            if (mcid := pp.raw.FPDF_StructElement_GetMarkedContentIdAtIndex(self._element, index)) != -1:
 79                ids.append(mcid)
 80        return ids
 81
 82    @cached_property
 83    def attributes(self) -> dict[str, str | bool | float]:
 84        """
 85        All attributes of this structure element as a dictionary.
 86
 87        .. note::
 88            Due to limitations of the pdfium API, attribute arrays cannot be
 89            extracted! The values are marked as `[?]` in the dictionary.
 90        """
 91        kv = {}
 92        for eindex in range(pp.raw.FPDF_StructElement_GetAttributeCount(self._element)):
 93            attr = pp.raw.FPDF_StructElement_GetAttributeAtIndex(self._element, eindex)
 94            for aindex in range(pp.raw.FPDF_StructElement_Attr_GetCount(attr)):
 95                # Get the name
 96                clength = ctypes.c_ulong(0)
 97                cname = ctypes.create_string_buffer(1)  # workaround to get length
 98                assert pp.raw.FPDF_StructElement_Attr_GetName(attr, aindex, cname, 0, clength)
 99                cname = ctypes.create_string_buffer(clength.value)
100                assert pp.raw.FPDF_StructElement_Attr_GetName(attr, aindex, cname, clength, clength)
101                name = cname.raw.decode("utf-8", errors="ignore")
102
103                # Get the type
104                atype = pp.raw.FPDF_StructElement_Attr_GetType(attr, cname)
105                assert atype != pp.raw.FPDF_OBJECT_UNKNOWN
106
107                # Then get each type individually
108                match atype:
109                    case pp.raw.FPDF_OBJECT_BOOLEAN:
110                        cbool = ctypes.bool()
111                        assert pp.raw.FPDF_StructElement_Attr_GetBooleanValue(attr, cname, cbool)
112                        kv[name] = cbool.value
113
114                    case pp.raw.FPDF_OBJECT_NUMBER:
115                        cfloat = ctypes.c_float()
116                        assert pp.raw.FPDF_StructElement_Attr_GetNumberValue(attr, cname, cfloat)
117                        kv[name] = cfloat.value
118
119                    case pp.raw.FPDF_OBJECT_STRING | pp.raw.FPDF_OBJECT_NAME:
120                        assert pp.raw.FPDF_StructElement_Attr_GetStringValue(attr, cname, 0, 0, clength)
121                        cattrname = ctypes.create_string_buffer(clength.value * 2)
122                        assert pp.raw.FPDF_StructElement_Attr_GetStringValue(attr, cname, cattrname, clength, clength)
123                        kv[name] = cattrname.raw.decode("utf-16-le", errors="ignore")[: clength.value - 1]
124
125                    # FIXME: FPDF_OBJECT_ARRAY is not a blob, but no other APIs are exposed?
126                    # case pp.raw.FPDF_OBJECT_ARRAY:
127                    #     assert pp.raw.FPDF_StructElement_Attr_GetBlobValue(attr, cname, 0, 0, clength)
128                    #     cblob = ctypes.create_string_buffer(clength.value)
129                    #     assert pp.raw.FPDF_StructElement_Attr_GetBlobValue(attr, cname, cblob, clength, clength)
130                    #     kv[name] = cblob.raw
131
132                    case pp.raw.FPDF_OBJECT_ARRAY:
133                        kv[name] = "[?]"
134
135                    case _:
136                        kv[name] = f"[unknown={atype}?]"
137        return kv
138
139    @cache
140    def child(self, index: int) -> "Structure":
141        """
142        :param index: 0-index of child.
143        :return: Child structure.
144        """
145        index = pp.raw.FPDF_StructElement_GetChildAtIndex(self._element, index)
146        return Structure(self._page, index, self)
147
148    @property
149    def children(self) -> list:
150        """All child structures."""
151        count = pp.raw.FPDF_StructElement_CountChildren(self._element)
152        for ii in range(count):
153            yield self.child(ii)
154
155    def descr(self, indent=0) -> str:
156        """Description including all children via indentation."""
157        string = " " * indent + repr(self) + "\n"
158        for child in self.children:
159            string += child.descr(indent + 4)
160        return string
161
162    def __repr__(self) -> str:
163        values = []
164        if self.type:
165            values.append(f"type={self.type}")
166        if self.title:
167            values.append(f"title={self.title}")
168        if self.actual_text:
169            values.append(f"act_text={self.actual_text}")
170        if self.alt_text:
171            values.append(f"alt_text={self.alt_text}")
172        if self.id:
173            values.append(f"id={self.id}")
174        values += [f"mid={i}" for i in self.marked_ids]
175        values += [f"{k}={v}" for k, v in self.attributes.items()]
176        return f"S({','.join(map(str, values))})"

A tagged PDF/UA (Universal Accessibility) contains the structure of content as a tree data structure with similar semantics to HTML. Sadly, the quality of the tags depends heavily on the PDF creation software. See Overview of PDF tags.

An example of an accessible pdf that can be inspected via these classes: Rock On, D.C. Music Festival.

This class is a convenience wrapper around the pdfium structtree methods.

Structure( page: Page, element: pypdfium2_raw.bindings.LP_struct_fpdf_structelement_t__, parent: Structure = None)
25    def __init__(self, page: "modm_data.pdf.page.Page", element: pp.raw.FPDF_STRUCTELEMENT, parent: "Structure" = None):  # noqa: F821
26        self._page = page
27        self._element = element
28        self.parent: Structure = weakref.ref(parent) if parent else None
29        """The parent node."""
parent: Structure

The parent node.

title: str
38    @cached_property
39    def title(self) -> str:
40        """Title `/T`"""
41        return self._get_string(pp.raw.FPDF_StructElement_GetTitle)

Title /T

actual_text: str
43    @cached_property
44    def actual_text(self) -> str:
45        """The actual text."""
46        return self._get_string(pp.raw.FPDF_StructElement_GetActualText)

The actual text.

alt_text: str
48    @cached_property
49    def alt_text(self) -> str:
50        """Alternate Text"""
51        return self._get_string(pp.raw.FPDF_StructElement_GetAltText)

Alternate Text

type: str
53    @cached_property
54    def type(self) -> str:
55        """Type `/S`"""
56        return self._get_string(pp.raw.FPDF_StructElement_GetType)

Type /S

obj_type: str
58    @cached_property
59    def obj_type(self) -> str:
60        """Object Type `/Type`"""
61        return self._get_string(pp.raw.FPDF_StructElement_GetObjType)

Object Type /Type

language: str
63    @cached_property
64    def language(self) -> str:
65        """The case-insensitive IETF BCP 47 language code."""
66        return self._get_string(pp.raw.FPDF_StructElement_GetLang)

The case-insensitive IETF BCP 47 language code.

id: str
68    @cached_property
69    def id(self) -> str:
70        """Identifier"""
71        return self._get_string(pp.raw.FPDF_StructElement_GetID)

Identifier

marked_ids: list[int]
73    @cached_property
74    def marked_ids(self) -> list[int]:
75        """List of marked content identifiers"""
76        ids = []
77        for index in range(pp.raw.FPDF_StructElement_GetMarkedContentIdCount(self._element)):
78            if (mcid := pp.raw.FPDF_StructElement_GetMarkedContentIdAtIndex(self._element, index)) != -1:
79                ids.append(mcid)
80        return ids

List of marked content identifiers

attributes: dict[str, str | bool | float]
 82    @cached_property
 83    def attributes(self) -> dict[str, str | bool | float]:
 84        """
 85        All attributes of this structure element as a dictionary.
 86
 87        .. note::
 88            Due to limitations of the pdfium API, attribute arrays cannot be
 89            extracted! The values are marked as `[?]` in the dictionary.
 90        """
 91        kv = {}
 92        for eindex in range(pp.raw.FPDF_StructElement_GetAttributeCount(self._element)):
 93            attr = pp.raw.FPDF_StructElement_GetAttributeAtIndex(self._element, eindex)
 94            for aindex in range(pp.raw.FPDF_StructElement_Attr_GetCount(attr)):
 95                # Get the name
 96                clength = ctypes.c_ulong(0)
 97                cname = ctypes.create_string_buffer(1)  # workaround to get length
 98                assert pp.raw.FPDF_StructElement_Attr_GetName(attr, aindex, cname, 0, clength)
 99                cname = ctypes.create_string_buffer(clength.value)
100                assert pp.raw.FPDF_StructElement_Attr_GetName(attr, aindex, cname, clength, clength)
101                name = cname.raw.decode("utf-8", errors="ignore")
102
103                # Get the type
104                atype = pp.raw.FPDF_StructElement_Attr_GetType(attr, cname)
105                assert atype != pp.raw.FPDF_OBJECT_UNKNOWN
106
107                # Then get each type individually
108                match atype:
109                    case pp.raw.FPDF_OBJECT_BOOLEAN:
110                        cbool = ctypes.bool()
111                        assert pp.raw.FPDF_StructElement_Attr_GetBooleanValue(attr, cname, cbool)
112                        kv[name] = cbool.value
113
114                    case pp.raw.FPDF_OBJECT_NUMBER:
115                        cfloat = ctypes.c_float()
116                        assert pp.raw.FPDF_StructElement_Attr_GetNumberValue(attr, cname, cfloat)
117                        kv[name] = cfloat.value
118
119                    case pp.raw.FPDF_OBJECT_STRING | pp.raw.FPDF_OBJECT_NAME:
120                        assert pp.raw.FPDF_StructElement_Attr_GetStringValue(attr, cname, 0, 0, clength)
121                        cattrname = ctypes.create_string_buffer(clength.value * 2)
122                        assert pp.raw.FPDF_StructElement_Attr_GetStringValue(attr, cname, cattrname, clength, clength)
123                        kv[name] = cattrname.raw.decode("utf-16-le", errors="ignore")[: clength.value - 1]
124
125                    # FIXME: FPDF_OBJECT_ARRAY is not a blob, but no other APIs are exposed?
126                    # case pp.raw.FPDF_OBJECT_ARRAY:
127                    #     assert pp.raw.FPDF_StructElement_Attr_GetBlobValue(attr, cname, 0, 0, clength)
128                    #     cblob = ctypes.create_string_buffer(clength.value)
129                    #     assert pp.raw.FPDF_StructElement_Attr_GetBlobValue(attr, cname, cblob, clength, clength)
130                    #     kv[name] = cblob.raw
131
132                    case pp.raw.FPDF_OBJECT_ARRAY:
133                        kv[name] = "[?]"
134
135                    case _:
136                        kv[name] = f"[unknown={atype}?]"
137        return kv

All attributes of this structure element as a dictionary.

Due to limitations of the pdfium API, attribute arrays cannot be extracted! The values are marked as [?] in the dictionary.

@cache
def child(self, index: int) -> Structure:
139    @cache
140    def child(self, index: int) -> "Structure":
141        """
142        :param index: 0-index of child.
143        :return: Child structure.
144        """
145        index = pp.raw.FPDF_StructElement_GetChildAtIndex(self._element, index)
146        return Structure(self._page, index, self)
Parameters
  • index: 0-index of child.
Returns

Child structure.

children: list
148    @property
149    def children(self) -> list:
150        """All child structures."""
151        count = pp.raw.FPDF_StructElement_CountChildren(self._element)
152        for ii in range(count):
153            yield self.child(ii)

All child structures.

def descr(self, indent=0) -> str:
155    def descr(self, indent=0) -> str:
156        """Description including all children via indentation."""
157        string = " " * indent + repr(self) + "\n"
158        for child in self.children:
159            string += child.descr(indent + 4)
160        return string

Description including all children via indentation.