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

The PDF metadata dictionary.

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

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

toc: list[modm_data.pdf.document._OutlineItem]
76    @cached_property
77    def toc(self) -> list[_OutlineItem]:
78        """
79        The table of content as a sorted list of outline items ensuring item has
80        a page index by reusing the last one.
81        """
82        tocs = set()
83        # Sometimes the TOC contains duplicates so we must use a set
84        last_page_index = 0
85        for toc in self.get_toc():
86            dest = toc.get_dest()
87            last_page_index = (dest and dest.get_index()) or last_page_index
88            tocs.add(_OutlineItem(toc.level, toc.get_title(), last_page_index))
89        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
91    @cached_property
92    def identifier_permanent(self) -> str:
93        """The permanent file identifier."""
94        return self.get_identifier(pp.raw.FILEIDTYPE_PERMANENT)

The permanent file identifier.

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

The changing file identifier.

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

The number of pages in the document.

@cache
def page(self, index: int) -> Page:
106    @cache
107    def page(self, index: int) -> Page:
108        """
109        :param index: 0-indexed page number.
110        :return: the page object for the index.
111        """
112        assert index < self.page_count
113        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]:
115    def pages(self, numbers: Iterable[int] = None) -> Iterator[Page]:
116        """
117        :param numbers: an iterable range of page numbers (0-indexed!).
118                        If `None`, then the whole page range is used.
119        :return: yields each page in the range.
120        """
121        if numbers is None:
122            numbers = range(self.page_count)
123        for ii in numbers:
124            if 0 <= ii < self.page_count:
125                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):
 24class Page(pp.PdfPage):
 25    """
 26    This class provides low-level access to graphics and characters of the page.
 27    It also fixes missing bounding boxes for rotates characters on page load,
 28    as well as allow 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        self._fix_bboxes()
 57
 58    @cached_property
 59    def label(self) -> str:
 60        """The page label."""
 61        return self.pdf.get_page_label(self.index)
 62
 63    @cached_property
 64    def width(self) -> float:
 65        """The page width."""
 66        return self.get_width()
 67
 68    @cached_property
 69    def height(self) -> float:
 70        """The page height."""
 71        return self.get_height()
 72
 73    @cached_property
 74    def rotation(self) -> int:
 75        """The page rotation in degrees."""
 76        return self.get_rotation()
 77
 78    @cached_property
 79    def bbox(self) -> Rectangle:
 80        """The page bounding box."""
 81        return Rectangle(*self.get_bbox())
 82
 83    @cached_property
 84    def char_count(self) -> int:
 85        """The total count of characters."""
 86        return self._text.count_chars()
 87
 88    @cache
 89    def char(self, index: int) -> Character:
 90        """:return: The character at the 0-index."""
 91        return Character(self, index)
 92
 93    @property
 94    def chars(self) -> Iterator[Character]:
 95        """Yields all characters."""
 96        for ii in range(self.char_count):
 97            yield self.char(ii)
 98
 99    @cached_property
100    def objlinks(self) -> list[ObjLink]:
101        """All object links."""
102        links = []
103        pos = ctypes.c_int(0)
104        link = pp.raw.FPDF_LINK()
105        while pp.raw.FPDFLink_Enumerate(self, pos, link):
106            links.append(ObjLink(self, link))
107        return links
108
109    @cached_property
110    def weblinks(self) -> list[WebLink]:
111        """All web links."""
112        links = []
113        for ii in range(pp.raw.FPDFLink_CountWebLinks(self._linkpage)):
114            links.append(WebLink(self, ii))
115        return links
116
117    def chars_in_area(self, area: Rectangle) -> list[Character]:
118        """
119        :param area: area to search for character in.
120        :return: All characters found in the area.
121        """
122        found = []
123        # We perform binary searches of the lower and upper y-positions first
124        # lines are ordered by y-position
125        ypositions = list(self._charlines.keys())
126        y_bottom = bisect_left(ypositions, area.bottom)
127        y_top = bisect_right(ypositions, area.top, lo=y_bottom)
128
129        # Then for every line we do another binary search for left and right
130        for ypos in ypositions[y_bottom:y_top]:
131            chars = self._charlines[ypos]
132            x_left = bisect_left(chars, area.left, key=lambda c: c.bbox.midpoint.x)
133            x_right = bisect_right(chars, area.right, lo=x_left, key=lambda c: c.bbox.midpoint.x)
134            # Finally we add all these characters
135            found.extend(chars[x_left:x_right])
136        return found
137
138    def text_in_area(self, area: Rectangle) -> str:
139        """
140        :param area: area to search for text in.
141        :return: Only the text found in the area.
142        """
143        return self._text.get_text_bounded(area.left, area.bottom, area.right, area.top)
144
145    @property
146    def structures(self) -> Iterator[Structure]:
147        """The PDF/UA tags."""
148        count = pp.raw.FPDF_StructTree_CountChildren(self._structtree)
149        for ii in range(count):
150            child = pp.raw.FPDF_StructTree_GetChildAtIndex(self._structtree, ii)
151            yield Structure(self, child)
152
153    def find(self, string: str, case_sensitive: bool = True) -> Iterator[Character]:
154        """
155        Searches for a match string as whole, consecutive words and yields the
156        characters.
157
158        :param string: The search string.
159        :param case_sensitive: Ignore case if false.
160        :return: yields the characters found.
161        """
162        searcher = self._text.search(string, match_case=case_sensitive, match_whole_word=True, consecutive=True)
163        while idx := searcher.get_next():
164            chars = [self.char(ii) for ii in range(idx[0], idx[0] + idx[1])]
165            yield chars
166
167    @cached_property
168    def paths(self) -> list[Path]:
169        """All paths."""
170        return [Path(o) for o in self.get_objects([pp.raw.FPDF_PAGEOBJ_PATH])]
171
172    @cached_property
173    def images(self) -> list[Image]:
174        """All images."""
175        return [Image(o) for o in self.get_objects([pp.raw.FPDF_PAGEOBJ_IMAGE])]
176
177    def graphic_clusters(
178        self, predicate: Callable[[Path | Image], bool] = None, absolute_tolerance: float = None
179    ) -> list[tuple[Rectangle, list[Path]]]:
180        if absolute_tolerance is None:
181            absolute_tolerance = min(self.width, self.height) * 0.01
182
183        # First collect all vertical regions
184        filtered_paths = []
185        for path in self.paths:
186            if predicate is None or predicate(path):
187                filtered_paths.append(path)
188        for image in self.images:
189            if predicate is None or predicate(image):
190                filtered_paths.append(image)
191
192        regions = []
193        for path in sorted(filtered_paths, key=lambda path: path.bbox.y):
194            for reg in regions:
195                if reg.overlaps(path.bbox.bottom, path.bbox.top, absolute_tolerance):
196                    # They overlap, so merge them
197                    reg.v0 = min(reg.v0, path.bbox.bottom)
198                    reg.v1 = max(reg.v1, path.bbox.top)
199                    reg.objs.append(path)
200                    break
201            else:
202                regions.append(Region(path.bbox.bottom, path.bbox.top, path))
203
204        # Now collect horizontal region inside each vertical region
205        for yreg in regions:
206            for path in sorted(filtered_paths, key=lambda path: path.bbox.x):
207                # check if horizontal line is contained in vregion
208                if yreg.contains(path.bbox.y, absolute_tolerance):
209                    for xreg in yreg.subregions:
210                        if xreg.overlaps(path.bbox.left, path.bbox.right, absolute_tolerance):
211                            # They overlap so merge them
212                            xreg.v0 = min(xreg.v0, path.bbox.left)
213                            xreg.v1 = max(xreg.v1, path.bbox.right)
214                            xreg.objs.append(path)
215                            break
216                    else:
217                        yreg.subregions.append(Region(path.bbox.left, path.bbox.right, path))
218
219        clusters = []
220        for yreg in regions:
221            for xreg in yreg.subregions:
222                if len(yreg.subregions) > 1:
223                    # Strip down the height again for subregions
224                    y0, y1 = 1e9, 0
225                    for path in xreg.objs:
226                        y0 = min(y0, path.bbox.bottom)
227                        y1 = max(y1, path.bbox.top)
228                else:
229                    y0, y1 = yreg.v0, yreg.v1
230                bbox = Rectangle(xreg.v0, y0, xreg.v1, y1)
231                clusters.append((bbox, xreg.objs))
232
233        return sorted(clusters, key=lambda c: (-c[0].y, c[0].x))
234
235    def _link_characters(self):
236        if self._linked:
237            return
238        # The in-document links only gives us rectangles and we must find the
239        # linked chars ourselves
240        for link in self.objlinks:
241            for char in self.chars_in_area(link.bbox):
242                char.objlink = link
243        # The weblinks give you an explicit char range, very convenient
244        for link in self.weblinks:
245            for ii in range(*link.range):
246                self.char(ii).weblink = link
247        self._linked = True
248
249    @cached_property
250    def _charlines(self):
251        charlines = defaultdict(list)
252        for char in self.chars:
253            charlines[round(char.bbox.midpoint.y, 1)].append(char)
254
255        orderedchars = OrderedDict.fromkeys(sorted(charlines))
256        for ypos, chars in charlines.items():
257            orderedchars[ypos] = sorted(chars, key=lambda c: c.bbox.midpoint.x)
258
259        return orderedchars
260
261    def _fix_bboxes(self):
262        def _key(char):
263            height = round(char.tbbox.height, 1)
264            width = round(char.tbbox.width, 1)
265            return f"{char.font} {char.unicode} {height} {width}"
266
267        fix_chars = []
268        for char in self.chars:
269            if not char._bbox.width or not char._bbox.height:
270                if char._rotation:
271                    fix_chars.append(char)
272                elif char.unicode not in {0xA, 0xD}:
273                    fix_chars.append(char)
274            elif char.unicode not in {0xA, 0xD} and not char._rotation and _key(char) not in self.pdf._bbox_cache:
275                bbox = char._bbox.translated(-char.origin).rotated(self.rotation + char._rotation)
276                self.pdf._bbox_cache[_key(char)] = (char, bbox)
277                # print("->", _key(char), char.descr(), char.height, char.rotation, char._rotation, self.rotation)
278        for char in fix_chars:
279            bbox = self.pdf._bbox_cache.get(_key(char))
280            if bbox is not None:
281                # print("<-", char.descr(), char._rotation, char.rotation, char.height)
282                _, bbox = bbox
283                bbox = bbox.rotated(-self.rotation - char._rotation).translated(char.origin)
284                char._bbox = bbox
285            elif char.unicode not in {0x20, 0xA, 0xD}:
286                _LOGGER.debug(f"Unable to fix bbox for {char.descr()}!")

This class provides low-level access to graphics and characters of the page. It also fixes missing bounding boxes for rotates characters on page load, as well as allow 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)
55
56        self._fix_bboxes()
Parameters
  • document: a PDF document.
  • index: 0-index page number.
index

0-index page number.

number

1-index page number.

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

The page label.

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

The page width.

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

The page height.

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

The page rotation in degrees.

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

The page bounding box.

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

The total count of characters.

@cache
def char(self, index: int) -> Character:
88    @cache
89    def char(self, index: int) -> Character:
90        """:return: The character at the 0-index."""
91        return Character(self, index)
Returns

The character at the 0-index.

chars: Iterator[Character]
93    @property
94    def chars(self) -> Iterator[Character]:
95        """Yields all characters."""
96        for ii in range(self.char_count):
97            yield self.char(ii)

Yields all characters.

def chars_in_area( self, area: modm_data.utils.Rectangle) -> list[Character]:
117    def chars_in_area(self, area: Rectangle) -> list[Character]:
118        """
119        :param area: area to search for character in.
120        :return: All characters found in the area.
121        """
122        found = []
123        # We perform binary searches of the lower and upper y-positions first
124        # lines are ordered by y-position
125        ypositions = list(self._charlines.keys())
126        y_bottom = bisect_left(ypositions, area.bottom)
127        y_top = bisect_right(ypositions, area.top, lo=y_bottom)
128
129        # Then for every line we do another binary search for left and right
130        for ypos in ypositions[y_bottom:y_top]:
131            chars = self._charlines[ypos]
132            x_left = bisect_left(chars, area.left, key=lambda c: c.bbox.midpoint.x)
133            x_right = bisect_right(chars, area.right, lo=x_left, key=lambda c: c.bbox.midpoint.x)
134            # Finally we add all these characters
135            found.extend(chars[x_left:x_right])
136        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:
138    def text_in_area(self, area: Rectangle) -> str:
139        """
140        :param area: area to search for text in.
141        :return: Only the text found in the area.
142        """
143        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]
145    @property
146    def structures(self) -> Iterator[Structure]:
147        """The PDF/UA tags."""
148        count = pp.raw.FPDF_StructTree_CountChildren(self._structtree)
149        for ii in range(count):
150            child = pp.raw.FPDF_StructTree_GetChildAtIndex(self._structtree, ii)
151            yield Structure(self, child)

The PDF/UA tags.

def find( self, string: str, case_sensitive: bool = True) -> Iterator[Character]:
153    def find(self, string: str, case_sensitive: bool = True) -> Iterator[Character]:
154        """
155        Searches for a match string as whole, consecutive words and yields the
156        characters.
157
158        :param string: The search string.
159        :param case_sensitive: Ignore case if false.
160        :return: yields the characters found.
161        """
162        searcher = self._text.search(string, match_case=case_sensitive, match_whole_word=True, consecutive=True)
163        while idx := searcher.get_next():
164            chars = [self.char(ii) for ii in range(idx[0], idx[0] + idx[1])]
165            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]
167    @cached_property
168    def paths(self) -> list[Path]:
169        """All paths."""
170        return [Path(o) for o in self.get_objects([pp.raw.FPDF_PAGEOBJ_PATH])]

All paths.

images: list[Image]
172    @cached_property
173    def images(self) -> list[Image]:
174        """All images."""
175        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]]]:
177    def graphic_clusters(
178        self, predicate: Callable[[Path | Image], bool] = None, absolute_tolerance: float = None
179    ) -> list[tuple[Rectangle, list[Path]]]:
180        if absolute_tolerance is None:
181            absolute_tolerance = min(self.width, self.height) * 0.01
182
183        # First collect all vertical regions
184        filtered_paths = []
185        for path in self.paths:
186            if predicate is None or predicate(path):
187                filtered_paths.append(path)
188        for image in self.images:
189            if predicate is None or predicate(image):
190                filtered_paths.append(image)
191
192        regions = []
193        for path in sorted(filtered_paths, key=lambda path: path.bbox.y):
194            for reg in regions:
195                if reg.overlaps(path.bbox.bottom, path.bbox.top, absolute_tolerance):
196                    # They overlap, so merge them
197                    reg.v0 = min(reg.v0, path.bbox.bottom)
198                    reg.v1 = max(reg.v1, path.bbox.top)
199                    reg.objs.append(path)
200                    break
201            else:
202                regions.append(Region(path.bbox.bottom, path.bbox.top, path))
203
204        # Now collect horizontal region inside each vertical region
205        for yreg in regions:
206            for path in sorted(filtered_paths, key=lambda path: path.bbox.x):
207                # check if horizontal line is contained in vregion
208                if yreg.contains(path.bbox.y, absolute_tolerance):
209                    for xreg in yreg.subregions:
210                        if xreg.overlaps(path.bbox.left, path.bbox.right, absolute_tolerance):
211                            # They overlap so merge them
212                            xreg.v0 = min(xreg.v0, path.bbox.left)
213                            xreg.v1 = max(xreg.v1, path.bbox.right)
214                            xreg.objs.append(path)
215                            break
216                    else:
217                        yreg.subregions.append(Region(path.bbox.left, path.bbox.right, path))
218
219        clusters = []
220        for yreg in regions:
221            for xreg in yreg.subregions:
222                if len(yreg.subregions) > 1:
223                    # Strip down the height again for subregions
224                    y0, y1 = 1e9, 0
225                    for path in xreg.objs:
226                        y0 = min(y0, path.bbox.bottom)
227                        y1 = max(y1, path.bbox.top)
228                else:
229                    y0, y1 = yreg.v0, yreg.v1
230                bbox = Rectangle(xreg.v0, y0, xreg.v1, y1)
231                clusters.append((bbox, xreg.objs))
232
233        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:
 14class Character:
 15    """
 16    Each character on the PDF page is represented by a character object,
 17    describing exactly where and how to render the associated glyph.
 18
 19    While there are font flags, PDF files typically use entirely different fonts
 20    to render normal, bold, and italic characters.
 21
 22    The character's loose bounding box may not always be available, since it
 23    must be explicitly provided by the font. The tight bounding box is only
 24    available as long as the glyph is renderable, so a space character may have
 25    a loose, but not a tight bounding box, or none at all.
 26    """
 27
 28    class RenderMode(Enum):
 29        """Tells the PDF viewer how to render this character glyph."""
 30
 31        UNKNOWN = -1
 32        FILL = 0
 33        STROKE = 1
 34        FILL_STROKE = 2
 35        INVISIBLE = 3
 36        FILL_CLIP = 4
 37        STROKE_CLIP = 5
 38        FILL_STROKE_CLIP = 6
 39        CLIP = 7
 40
 41    def __init__(self, page: "modm_data.pdf.page.Page", index: int):  # noqa: F821
 42        """
 43        :param page: The page containing the character.
 44        :param index: The index of the character.
 45        """
 46        self._page = page
 47        self._text = page._text
 48        self._index = index
 49        self._font = None
 50        self._rotation = int(math.degrees(pp.raw.FPDFText_GetCharAngle(self._text, self._index)))
 51
 52        self.unicode: int = pp.raw.FPDFText_GetUnicode(self._text, self._index)
 53        """The unicode value of the character."""
 54        self.objlink: "modm_data.pdf.link.ObjLink" = None  # noqa: F821
 55        """The object link of this character or `None`"""
 56        self.weblink: "modm_data.pdf.link.WebLink" = None  # noqa: F821
 57        """The web link of this character or `None`"""
 58
 59        bbox = Rectangle(*self._loose_charbox())
 60        if self._page.rotation:
 61            bbox = Rectangle(bbox.p0.y, self._page.height - bbox.p1.x, bbox.p1.y, self._page.height - bbox.p0.x)
 62        self._bbox = bbox
 63
 64    def _loose_charbox(self) -> tuple[float, float, float, float]:
 65        # Newer pdfium computes the loose box from the real font ascent and
 66        # descent, which makes it ~10% taller than the font size. All layout
 67        # heuristics in pdf2html are tuned to the previous definition, where
 68        # the box is exactly one font size tall and one advance width wide, and
 69        # collapses for rotated characters (see `Page._fix_bboxes()`).
 70        # The single precision rounding is part of that definition, since line
 71        # heights are compared for equality when merging lines.
 72        left, bottom, right, top = self._text.get_charbox(self._index, loose=True)
 73        obj = pp.raw.FPDFText_GetTextObject(self._text, self._index)
 74        size = pp.raw.FPDFText_GetFontSize(self._text, self._index)
 75        if not obj or not size:
 76            return left, bottom, right, top
 77        font = pp.raw.FPDFTextObj_GetFont(obj)
 78        ascent, descent, width = ctypes.c_float(), ctypes.c_float(), ctypes.c_float()
 79        pp.raw.FPDFFont_GetAscent(font, 1000, ascent)
 80        pp.raw.FPDFFont_GetDescent(font, 1000, descent)
 81        if ascent.value == descent.value:
 82            return left, bottom, right, top
 83
 84        def _f32(value: float) -> float:
 85            return ctypes.c_float(value).value
 86
 87        matrix = pp.raw.FS_MATRIX()
 88        x, y = ctypes.c_double(), ctypes.c_double()
 89        assert pp.raw.FPDFText_GetMatrix(self._text, self._index, matrix)
 90        assert pp.raw.FPDFText_GetCharOrigin(self._text, self._index, x, y)
 91        scale = _f32(_f32(matrix.a * size) / (ascent.value - descent.value))
 92        bottom = _f32(y.value + _f32(descent.value * scale))
 93        top = _f32(y.value + _f32(ascent.value * scale))
 94        if pp.raw.FPDFText_IsGenerated(self._text, self._index):
 95            return x.value, bottom, x.value, top
 96        # The width lookup fails for characters without a reverse unicode
 97        # mapping, in which case the new left and right bounds are close enough.
 98        if pp.raw.FPDFFont_GetGlyphWidth(font, self.unicode, size, width) and width.value:
 99            left, right = x.value, _f32(x.value + _f32(matrix.a * width.value))
100        return left, bottom, right, top
101
102    def generated_space(self) -> "Character":
103        """
104        :return: A zero-sized space character located at the origin of this
105                 character, like the ones pdfium generates for visual gaps.
106        """
107        space = copy.copy(self)
108        space.__dict__ = {k: v for k, v in self.__dict__.items() if k.startswith("_") or k.endswith("link")}
109        space.unicode = 0x20
110        space._bbox = space.tbbox = Rectangle(self.origin.x, self.origin.y, self.origin.x, self.origin.y)
111        space.origin = self.origin
112        return space
113
114    def _font_flags(self) -> tuple[str, int]:
115        if self._font is None:
116            font = ctypes.create_string_buffer(255)
117            flags = ctypes.c_int()
118            pp.raw.FPDFText_GetFontInfo(self._text, self._index, font, 255, flags)
119            self._font = (font.value.decode("utf-8"), flags.value)
120        return self._font
121
122    @property
123    def char(self) -> str:
124        """The printable string of the unicode value."""
125        char = chr(self.unicode)
126        return char if char.isprintable() else ""
127
128    @cached_property
129    def origin(self) -> Point:
130        """The origin of the character."""
131        x, y = ctypes.c_double(), ctypes.c_double()
132        assert pp.raw.FPDFText_GetCharOrigin(self._text, self._index, x, y)
133        if self._page.rotation:
134            return Point(y.value, self._page.height - x.value)
135        return Point(x.value, y.value)
136
137    @cached_property
138    def width(self) -> float:
139        """The width of the character's bounding box."""
140        if self.rotation:
141            return self.bbox.height
142        return self.bbox.width
143
144    @cached_property
145    def height(self) -> float:
146        """The height of the character's bounding box."""
147        if self.rotation:
148            return self.bbox.width
149        return self.bbox.height
150
151    @cached_property
152    def tbbox(self) -> Rectangle:
153        """The tight bounding box of the character."""
154        tbbox = Rectangle(*self._text.get_charbox(self._index))
155        if self._page.rotation:
156            tbbox = Rectangle(tbbox.p0.y, self._page.height - tbbox.p1.x, tbbox.p1.y, self._page.height - tbbox.p0.x)
157        return tbbox
158
159    @property
160    def bbox(self) -> Rectangle:
161        """
162        The loose bounding box of the character.
163        .. note::
164            If the loose bounding box is not available, the tight bounding box
165            is used instead.
166        """
167        if not self._bbox.width or not self._bbox.height:
168            return self.tbbox
169        return self._bbox
170
171    @cached_property
172    def twidth(self) -> float:
173        """The width of the character's tight bounding box."""
174        return self.tbbox.width
175
176    @cached_property
177    def theight(self) -> float:
178        """The height of the character's tight bounding box."""
179        return self.tbbox.height
180
181    @cached_property
182    def render_mode(self) -> RenderMode:
183        """The render mode of the character."""
184        return Character.RenderMode(
185            pp.raw.FPDFTextObj_GetTextRenderMode(pp.raw.FPDFText_GetTextObject(self._text, self._index))
186        )
187
188    @cached_property
189    def rotation(self) -> int:
190        """The rotation of the character in degrees modulo 360."""
191        # Special case for vertical text in rotated pages
192        if self._page.rotation == 90 and self._rotation == 0 and self.unicode not in {0x20, 0xA, 0xD}:
193            return 90
194        if self._page.rotation and self._rotation:
195            return (self._page.rotation + self._rotation) % 360
196        return self._rotation
197
198    @cached_property
199    def size(self) -> float:
200        """The font size of the character."""
201        return pp.raw.FPDFText_GetFontSize(self._text, self._index)
202
203    @cached_property
204    def weight(self) -> int:
205        """The font weight of the character."""
206        return pp.raw.FPDFText_GetFontWeight(self._text, self._index)
207
208    @cached_property
209    def fill(self) -> int:
210        """The fill color of the character."""
211        r, g, b, a = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint()
212        pp.raw.FPDFText_GetFillColor(self._text, self._index, r, g, b, a)
213        return r.value << 24 | g.value << 16 | b.value << 8 | a.value
214
215    @cached_property
216    def stroke(self) -> int:
217        """The stroke color of the character."""
218        r, g, b, a = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint()
219        pp.raw.FPDFText_GetStrokeColor(self._text, self._index, r, g, b, a)
220        return r.value << 24 | g.value << 16 | b.value << 8 | a.value
221
222    @cached_property
223    def font(self) -> str:
224        """The font name of the character."""
225        return self._font_flags()[0]
226
227    @cached_property
228    def flags(self) -> int:
229        """The font flags of the character."""
230        return self._font_flags()[1]
231
232    def descr(self) -> str:
233        """Human-readable description of the character for debugging."""
234        char = chr(self.unicode)
235        if not char.isprintable():
236            char = hex(self.unicode)
237        return (
238            f"Chr({char}, {self.size}, {self.weight}, {self.rotation}, "
239            f"{self.render_mode}, {self.font}, {hex(self.flags)}, "
240            f"{self.fill}, {self.stroke}, {repr(self.bbox)})"
241        )
242
243    def __str__(self) -> str:
244        return self.char
245
246    def __repr__(self) -> str:
247        char = chr(self.unicode)
248        escape = {0xA: "\\n", 0xD: "\\r", 0x9: "\\t", 0x20: "␣"}
249        char = escape.get(self.unicode, char if char.isprintable() else hex(self.unicode))
250        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)
41    def __init__(self, page: "modm_data.pdf.page.Page", index: int):  # noqa: F821
42        """
43        :param page: The page containing the character.
44        :param index: The index of the character.
45        """
46        self._page = page
47        self._text = page._text
48        self._index = index
49        self._font = None
50        self._rotation = int(math.degrees(pp.raw.FPDFText_GetCharAngle(self._text, self._index)))
51
52        self.unicode: int = pp.raw.FPDFText_GetUnicode(self._text, self._index)
53        """The unicode value of the character."""
54        self.objlink: "modm_data.pdf.link.ObjLink" = None  # noqa: F821
55        """The object link of this character or `None`"""
56        self.weblink: "modm_data.pdf.link.WebLink" = None  # noqa: F821
57        """The web link of this character or `None`"""
58
59        bbox = Rectangle(*self._loose_charbox())
60        if self._page.rotation:
61            bbox = Rectangle(bbox.p0.y, self._page.height - bbox.p1.x, bbox.p1.y, self._page.height - bbox.p0.x)
62        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:
102    def generated_space(self) -> "Character":
103        """
104        :return: A zero-sized space character located at the origin of this
105                 character, like the ones pdfium generates for visual gaps.
106        """
107        space = copy.copy(self)
108        space.__dict__ = {k: v for k, v in self.__dict__.items() if k.startswith("_") or k.endswith("link")}
109        space.unicode = 0x20
110        space._bbox = space.tbbox = Rectangle(self.origin.x, self.origin.y, self.origin.x, self.origin.y)
111        space.origin = self.origin
112        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
122    @property
123    def char(self) -> str:
124        """The printable string of the unicode value."""
125        char = chr(self.unicode)
126        return char if char.isprintable() else ""

The printable string of the unicode value.

origin: modm_data.utils.Point
128    @cached_property
129    def origin(self) -> Point:
130        """The origin of the character."""
131        x, y = ctypes.c_double(), ctypes.c_double()
132        assert pp.raw.FPDFText_GetCharOrigin(self._text, self._index, x, y)
133        if self._page.rotation:
134            return Point(y.value, self._page.height - x.value)
135        return Point(x.value, y.value)

The origin of the character.

width: float
137    @cached_property
138    def width(self) -> float:
139        """The width of the character's bounding box."""
140        if self.rotation:
141            return self.bbox.height
142        return self.bbox.width

The width of the character's bounding box.

height: float
144    @cached_property
145    def height(self) -> float:
146        """The height of the character's bounding box."""
147        if self.rotation:
148            return self.bbox.width
149        return self.bbox.height

The height of the character's bounding box.

tbbox: modm_data.utils.Rectangle
151    @cached_property
152    def tbbox(self) -> Rectangle:
153        """The tight bounding box of the character."""
154        tbbox = Rectangle(*self._text.get_charbox(self._index))
155        if self._page.rotation:
156            tbbox = Rectangle(tbbox.p0.y, self._page.height - tbbox.p1.x, tbbox.p1.y, self._page.height - tbbox.p0.x)
157        return tbbox

The tight bounding box of the character.

bbox: modm_data.utils.Rectangle
159    @property
160    def bbox(self) -> Rectangle:
161        """
162        The loose bounding box of the character.
163        .. note::
164            If the loose bounding box is not available, the tight bounding box
165            is used instead.
166        """
167        if not self._bbox.width or not self._bbox.height:
168            return self.tbbox
169        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
171    @cached_property
172    def twidth(self) -> float:
173        """The width of the character's tight bounding box."""
174        return self.tbbox.width

The width of the character's tight bounding box.

theight: float
176    @cached_property
177    def theight(self) -> float:
178        """The height of the character's tight bounding box."""
179        return self.tbbox.height

The height of the character's tight bounding box.

render_mode: Character.RenderMode
181    @cached_property
182    def render_mode(self) -> RenderMode:
183        """The render mode of the character."""
184        return Character.RenderMode(
185            pp.raw.FPDFTextObj_GetTextRenderMode(pp.raw.FPDFText_GetTextObject(self._text, self._index))
186        )

The render mode of the character.

rotation: int
188    @cached_property
189    def rotation(self) -> int:
190        """The rotation of the character in degrees modulo 360."""
191        # Special case for vertical text in rotated pages
192        if self._page.rotation == 90 and self._rotation == 0 and self.unicode not in {0x20, 0xA, 0xD}:
193            return 90
194        if self._page.rotation and self._rotation:
195            return (self._page.rotation + self._rotation) % 360
196        return self._rotation

The rotation of the character in degrees modulo 360.

size: float
198    @cached_property
199    def size(self) -> float:
200        """The font size of the character."""
201        return pp.raw.FPDFText_GetFontSize(self._text, self._index)

The font size of the character.

weight: int
203    @cached_property
204    def weight(self) -> int:
205        """The font weight of the character."""
206        return pp.raw.FPDFText_GetFontWeight(self._text, self._index)

The font weight of the character.

fill: int
208    @cached_property
209    def fill(self) -> int:
210        """The fill color of the character."""
211        r, g, b, a = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint()
212        pp.raw.FPDFText_GetFillColor(self._text, self._index, r, g, b, a)
213        return r.value << 24 | g.value << 16 | b.value << 8 | a.value

The fill color of the character.

stroke: int
215    @cached_property
216    def stroke(self) -> int:
217        """The stroke color of the character."""
218        r, g, b, a = ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint(), ctypes.c_uint()
219        pp.raw.FPDFText_GetStrokeColor(self._text, self._index, r, g, b, a)
220        return r.value << 24 | g.value << 16 | b.value << 8 | a.value

The stroke color of the character.

font: str
222    @cached_property
223    def font(self) -> str:
224        """The font name of the character."""
225        return self._font_flags()[0]

The font name of the character.

flags: int
227    @cached_property
228    def flags(self) -> int:
229        """The font flags of the character."""
230        return self._font_flags()[1]

The font flags of the character.

def descr(self) -> str:
232    def descr(self) -> str:
233        """Human-readable description of the character for debugging."""
234        char = chr(self.unicode)
235        if not char.isprintable():
236            char = hex(self.unicode)
237        return (
238            f"Chr({char}, {self.size}, {self.weight}, {self.rotation}, "
239            f"{self.render_mode}, {self.font}, {hex(self.flags)}, "
240            f"{self.fill}, {self.stroke}, {repr(self.bbox)})"
241        )

Human-readable description of the character for debugging.

class Character.RenderMode(enum.Enum):
28    class RenderMode(Enum):
29        """Tells the PDF viewer how to render this character glyph."""
30
31        UNKNOWN = -1
32        FILL = 0
33        STROKE = 1
34        FILL_STROKE = 2
35        INVISIBLE = 3
36        FILL_CLIP = 4
37        STROKE_CLIP = 5
38        FILL_STROKE_CLIP = 6
39        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 cap(self) -> Cap:
 90        """Line cap type."""
 91        return Path.Cap(pp.raw.FPDFPageObj_GetLineCap(self))
 92
 93    @cached_property
 94    def join(self) -> Join:
 95        """Line join type."""
 96        return Path.Join(pp.raw.FPDFPageObj_GetLineJoin(self))
 97
 98    @cached_property
 99    def bbox(self) -> Rectangle:
100        """
101        Bounding box of the path.
102        .. warning::
103            The bounding is only approximated using the control points!
104            Therefore bezier curves will likely have a larger bounding box.
105        """
106        left, bottom = ctypes.c_float(), ctypes.c_float()
107        right, top = ctypes.c_float(), ctypes.c_float()
108        assert pp.raw.FPDFPageObj_GetBounds(self, left, bottom, right, top)
109        bbox = Rectangle(left.value, bottom.value, right.value, top.value)
110        if self.page.rotation:
111            bbox = Rectangle(bbox.p0.y, self.page.height - bbox.p1.x, bbox.p1.y, self.page.height - bbox.p0.x)
112        return bbox
113
114    @cached_property
115    def points(self) -> list[Point]:
116        """
117        List of points of the path. If the path is closed, the first point is
118        added to the end of the list.
119        """
120        points = []
121        for ii in range(self.count):
122            seg = pp.raw.FPDFPath_GetPathSegment(self, ii)
123            ptype = Path.Type(pp.raw.FPDFPathSegment_GetType(seg))
124            # The first point should always be MOVETO
125            assert ii or ptype == Path.Type.MOVE
126
127            x, y = ctypes.c_float(), ctypes.c_float()
128            assert pp.raw.FPDFPathSegment_GetPoint(seg, x, y)
129            x, y = self.matrix.on_point(x.value, y.value)
130            points.append(Point(x, y, type=ptype))
131
132            if pp.raw.FPDFPathSegment_GetClose(seg):
133                points.append(Point(points[0].x, points[0].y, type=Path.Type.LINE))
134
135        if self.page.rotation:
136            points = [Point(y, self.page.height - x, type=p.type) for p in points]
137        return points
138
139    @cached_property
140    def lines(self) -> list[Line]:
141        """List of lines between the path points."""
142        points = self.points
143        return [
144            Line(points[ii], points[ii + 1], width=self.width, type=points[ii + 1].type)
145            for ii in range(len(points) - 1)
146        ]
147
148    def __repr__(self) -> str:
149        points = ",".join(repr(p) for p in self.points)
150        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.

cap: Path.Cap
88    @cached_property
89    def cap(self) -> Cap:
90        """Line cap type."""
91        return Path.Cap(pp.raw.FPDFPageObj_GetLineCap(self))

Line cap type.

join: Path.Join
93    @cached_property
94    def join(self) -> Join:
95        """Line join type."""
96        return Path.Join(pp.raw.FPDFPageObj_GetLineJoin(self))

Line join type.

bbox: modm_data.utils.Rectangle
 98    @cached_property
 99    def bbox(self) -> Rectangle:
100        """
101        Bounding box of the path.
102        .. warning::
103            The bounding is only approximated using the control points!
104            Therefore bezier curves will likely have a larger bounding box.
105        """
106        left, bottom = ctypes.c_float(), ctypes.c_float()
107        right, top = ctypes.c_float(), ctypes.c_float()
108        assert pp.raw.FPDFPageObj_GetBounds(self, left, bottom, right, top)
109        bbox = Rectangle(left.value, bottom.value, right.value, top.value)
110        if self.page.rotation:
111            bbox = Rectangle(bbox.p0.y, self.page.height - bbox.p1.x, bbox.p1.y, self.page.height - bbox.p0.x)
112        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]
114    @cached_property
115    def points(self) -> list[Point]:
116        """
117        List of points of the path. If the path is closed, the first point is
118        added to the end of the list.
119        """
120        points = []
121        for ii in range(self.count):
122            seg = pp.raw.FPDFPath_GetPathSegment(self, ii)
123            ptype = Path.Type(pp.raw.FPDFPathSegment_GetType(seg))
124            # The first point should always be MOVETO
125            assert ii or ptype == Path.Type.MOVE
126
127            x, y = ctypes.c_float(), ctypes.c_float()
128            assert pp.raw.FPDFPathSegment_GetPoint(seg, x, y)
129            x, y = self.matrix.on_point(x.value, y.value)
130            points.append(Point(x, y, type=ptype))
131
132            if pp.raw.FPDFPathSegment_GetClose(seg):
133                points.append(Point(points[0].x, points[0].y, type=Path.Type.LINE))
134
135        if self.page.rotation:
136            points = [Point(y, self.page.height - x, type=p.type) for p in points]
137        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]
139    @cached_property
140    def lines(self) -> list[Line]:
141        """List of lines between the path points."""
142        points = self.points
143        return [
144            Line(points[ii], points[ii + 1], width=self.width, type=points[ii + 1].type)
145            for ii in range(len(points) - 1)
146        ]

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):
10class Image(pp.PdfImage):
11    """
12    This class extends `pypdfium2.PdfImage` to align it with the interface of
13    the `Path` class so that it can be used in the same
14    algorithms without filtering.
15
16    You must construct the images by calling `modm_data.pdf.page.Page.images`.
17
18    .. note:: Images are currently ignored.
19    """
20
21    # Overwrite the PdfPageObject.__new__ function
22    def __new__(cls, *args, **kwargs):
23        return object.__new__(cls)
24
25    def __init__(self, obj):
26        """
27        :param obj: Page object of the image.
28        """
29        super().__init__(obj.raw, obj.page, obj.pdf, obj.container, obj.level)
30        assert pp.raw.FPDFPageObj_GetType(obj.raw) == pp.raw.FPDF_PAGEOBJ_IMAGE
31        self.type = pp.raw.FPDF_PAGEOBJ_IMAGE
32
33        self.count: int = 4
34        """Number of segments. Always 4 due to rectangular image form.
35           (For compatibility with `Path.count`.)"""
36        self.stroke: int = 0
37        """The border stroke color. Always 0.
38           (For compatibility with `Path.stroke`.)"""
39        self.fill: int = 0
40        """The image fill color. Always 0.
41           (For compatibility with `Path.fill`.)"""
42        self.width: float = 0
43        """The border line width. Always 0.
44           (For compatibility with `Path.width`.)"""
45
46    @cached_property
47    def matrix(self) -> pp.PdfMatrix:
48        """The transformation matrix."""
49        return self.get_matrix()
50
51    @cached_property
52    def bbox(self) -> Rectangle:
53        """The bounding box of the image."""
54        bbox = Rectangle(*self.get_bounds())
55        if self.page.rotation:
56            bbox = Rectangle(bbox.p0.y, self.page.height - bbox.p1.x, bbox.p1.y, self.page.height - bbox.p0.x)
57        return bbox
58
59    @cached_property
60    def points(self) -> list[Point]:
61        """
62        The 4 points of the bounding box.
63        (For compatibility with `Path.points`.)
64        """
65        points = self.bbox.points
66        if self.page.rotation:
67            points = [Point(p.y, self.page.height - p.x, p.type) for p in points]
68        return points
69
70    @cached_property
71    def lines(self) -> list[Line]:
72        """
73        The 4 lines of the bounding box.
74        (For compatibility with `Path.lines`.)
75        """
76        p = self.points
77        return [
78            Line(p[0], p[1], p[1].type, 0),
79            Line(p[1], p[2], p[2].type, 0),
80            Line(p[2], p[3], p[3].type, 0),
81            Line(p[3], p[0], p[0].type, 0),
82        ]
83
84    def __repr__(self) -> str:
85        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)
25    def __init__(self, obj):
26        """
27        :param obj: Page object of the image.
28        """
29        super().__init__(obj.raw, obj.page, obj.pdf, obj.container, obj.level)
30        assert pp.raw.FPDFPageObj_GetType(obj.raw) == pp.raw.FPDF_PAGEOBJ_IMAGE
31        self.type = pp.raw.FPDF_PAGEOBJ_IMAGE
32
33        self.count: int = 4
34        """Number of segments. Always 4 due to rectangular image form.
35           (For compatibility with `Path.count`.)"""
36        self.stroke: int = 0
37        """The border stroke color. Always 0.
38           (For compatibility with `Path.stroke`.)"""
39        self.fill: int = 0
40        """The image fill color. Always 0.
41           (For compatibility with `Path.fill`.)"""
42        self.width: float = 0
43        """The border line width. Always 0.
44           (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
46    @cached_property
47    def matrix(self) -> pp.PdfMatrix:
48        """The transformation matrix."""
49        return self.get_matrix()

The transformation matrix.

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

The bounding box of the image.

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

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

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

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

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.