Source code for papermodels.paper.pdf

"""Read and write PDF annotations with pikepdf."""

from __future__ import annotations
from copy import deepcopy
from typing import Optional, Any, Union
import pikepdf as pike
import re
from decimal import Decimal
from papermodels.datatypes.annotation import Annotation
from papermodels.paper.annotations import scale_annotations
from papermodels.geometry import geom_ops
import pathlib
import parse
import numpy as np


[docs] def load_pdf_annotations( pdf_path: pathlib.Path | str, show_skipped: bool = False, show_unimplemented: bool = False, ) -> list[Annotation]: """ Returns a lists of pdf annotations keyed by page index. 'pdf_path': Path-like object representing the path to the PDF file to open. """ pdf_path = pathlib.Path(pdf_path) with pike.open(pdf_path) as pdf_obj: annots_in_pdf = [] skipped_annots = [] for page_num, page_data in enumerate(pdf_obj.pages): rotate = page_data.get("/Rotate", None) if not hasattr(page_data.obj, "Annots"): continue for annot_idx, annot in enumerate(page_data.obj.Annots): pm_annot = pike_annotation_to_pm_annotation( annot, annot_idx, page_num, rotate, display_unimplemented_annots=show_unimplemented, ) if pm_annot is not None: annots_in_pdf.append(pm_annot) else: skipped_annots.append(annot) if show_skipped: print(skipped_annots) return annots_in_pdf
[docs] def update_pdf_annotations( pdf_path: pathlib.Path, parsed_annotations: dict[Annotation, dict], export_path: pathlib.Path, append_text: bool = False, ) -> None: """ Returns None. Creates a copy of the file at 'pdf_path' with all of annotations representing structural elements (collector and transfer elements, not loads or legend entries) having their text field updated with the assigned tag of the structural element. """ pdf_path = pathlib.Path(pdf_path).resolve() with pike.open(pdf_path, allow_overwriting_input=True) as pdf_obj: for page_idx, page_data in enumerate(pdf_obj.pages): rotate = page_data.get("/Rotate", None) for annot_idx, annot in enumerate(page_data.obj.Annots): for parsed_annotation, annot_attrs in parsed_annotations.items(): annotations_equal = compare_annotations( parsed_annotation, annot, page_idx, rotate ) if annotations_equal: text_to_add = f"tag: {annot_attrs['tag']}" replace_text = not append_text updated_pike_annot = update_annotation_text_field( annot, text_to_add, replace_text ) page_data.obj.Annots[annot_idx] = updated_pike_annot pdf_obj.save(export_path)
[docs] def update_annotation_text_field( pike_annot: pike.Annotation, text: str, replace: bool = False ): """ Returns a copy of the 'pike_annot' with the text field updated with 'text'. If 'replace' is True then the text field is completely replaced with 'text'. If not, then 'text' is appended on a new line to the existing 'text'. """ orig_text = pike_annot.get("/Contents") if ( "tag:" in str(orig_text) and not replace ): # Don't append a tag if there is one there return pike_annot elif replace or orig_text is None: pike_annot["/Contents"] = pike.String(text) else: pike_annot["/Contents"] = pike.String(f"{str(orig_text)}\r\n{text}") return pike_annot
[docs] def compare_annotations( pm_annot: Annotation, pike_annot: pike.Annotation, page_num: int, rotate: int ) -> bool: """ Returns True if the `pm_annot` is nominally equal to the 'pike_annot' by converting the 'pike_annot' to the 'pm_annot' and seeing if they are equal. Will return True for duplicate annotations. """ converted = pike_annotation_to_pm_annotation( pike_annot, pm_annot.local_id, page_num, rotate ) if converted is None: return False else: return pm_annot == converted
[docs] def pike_annotation_to_pm_annotation( annot, annot_idx: int, page_idx: int, rotate: Optional[int] = None, display_unimplemented_annots: bool = False, ) -> Optional[Annotation]: """ Returns either an Annotation object or None. None is returned if: - The annotation has a "parent" key - Has the word "ignore" in the text field - If that annotation is a type that cannot be read yet - The annotation does not have a Subj key Note: Numerical data within pike annotation objects are of type Decimal """ if "/Parent" in annot: return None # Content stream for Bluebeam PDFs is linked as an XObject # The below gets the linked XObject content stream content_stream = annot.get("/AP", {}).get("/N", None) if content_stream is not None: utf_stream = pike.Stream.read_bytes(content_stream).decode("utf-8") stream_dict = parse_content_stream(utf_stream) else: utf_stream = None stream_dict = {} if "/Subj" in annot: annot_type = str(annot["/Subj"]) else: return None if annot_type.lower() in ("polygon", "polyline"): vertices = tuple(annot.get("/Vertices", tuple())) elif annot_type.lower() in ("circle", "ellipse"): annot_type = "Polygon" rect = tuple(annot.get("/Rect", tuple())) x1, y1, x2, y2 = rect h = (x1 + x2) / 2 k = (y1 + y2) / 2 a = (x2 - x1) / 2 b = (y2 - y1) / 2 n = 16 x_vertices = float(h) + float(a) * np.cos(np.linspace(0, 2 * np.pi, n)) y_vertices = float(k) + float(b) * np.sin(np.linspace(0, 2 * np.pi, n)) vertices = [] for idx, x_vertex in enumerate(x_vertices): vertices.append(Decimal(x_vertex)) y_vertex = y_vertices[idx] vertices.append(Decimal(y_vertex)) elif annot_type.lower() in ( "rectangle", "square", "rectangle sketch to scale", ): bbox = tuple(annot.get("/Rect", tuple())) buffers = tuple(annot.get("/RD", (0, 0, 0, 0))) x1, y1, x2, y2 = bbox b1, b2, b3, b4 = buffers vertices = ( x1 + b1, # x0 y1 + b4, # y0 x1 + b1, # x1 y2 - b2, # y1 x2 - b3, # x2 y2 - b2, # y2 x2 - b3, # x3 y1 + b4, # y3 ) elif annot_type.lower() in ("line", "arrow"): vertices = tuple(annot.get("/L", tuple())) annot_type = "Line" else: if display_unimplemented_annots: print(f"Cannot read (yet): {annot_type}") return None if rotate == 90: vertex_array = geom_ops.vertices_to_array(vertices) rotated_vertices = geom_ops.rotate_90_coords(vertex_array, ccw=True) vertices = geom_ops.flatten_vertex_array(rotated_vertices) elif rotate == 270: vertex_array = geom_ops.vertices_to_array(vertices) rotated_vertices = geom_ops.rotate_90_coords(vertex_array, ccw=False) vertices = geom_ops.flatten_vertex_array(rotated_vertices) text = str(annot.get("/Contents", "")) rc = annot.get("/RC") text = parse_html_text_content(str(rc)) if rc is not None else text if "ignore" in text: return None line_color = tuple(annot.get("/C", stream_dict.get("RG", (0, 0, 0)))) fill_color = tuple(annot.get("/IC", stream_dict.get("rg", (1, 1, 1)))) # fill_opacity and global_opacity are different things and should be separated # as of 2025-03-31 I am treating global opacity as being the same as fill # opacity for a quick fix for editors that do not allow control over only # the fill opacity. fill_opacity = annot.get("/FillOpacity", Decimal("1.0")) line_opacity = Decimal("1.0") global_opacity = annot.get("/CA", Decimal("1.0")) if global_opacity != Decimal("1.0") and fill_opacity == Decimal("1.0"): fill_opacity = global_opacity if global_opacity != Decimal("1.0") and line_opacity == Decimal("1.0"): line_opacity = global_opacity line_width = stream_dict.get("w", (1,))[0] line_type = get_dash_pattern(annot, utf_stream) matrix = (1, 0, 0, 1, 0, 0) annotation = Annotation( object_type=annot_type, page=page_idx, vertices=tuple(vertices), text=text, line_color=line_color, fill_color=fill_color, fill_opacity=fill_opacity, line_weight=line_width, line_type=line_type, line_opacity=line_opacity, matrix=matrix, local_id=annot_idx, ) return annotation
[docs] def get_dash_pattern(annot, content_stream: Optional[str] = None) -> Optional[tuple]: """ Returns the dash array of 'annot' as a tuple of floats (e.g. (4.0, 4.0)) or None if the annotation is drawn with a solid line. The border style dictionary (/BS) is the authoritative source. If the annotation does not have one, the dash operator ('[...] phase d') in the appearance 'content_stream' is used instead. PDF editors encode solid lines in several ways (no /BS, /S /S, an empty dash array, '[] 0 d'); all of these are normalized to None so that solid lines compare equal regardless of the software used to draw them. """ border_style = annot.get("/BS", None) if border_style is not None: if border_style.get("/S", None) != "/D": return None dash_array = tuple(border_style.get("/D", (3,))) # [3] is the PDF default elif content_stream is not None: dash_match = re.search(r"\[([^\]]*)\]\s*[-\d.]+\s+d\b", content_stream) if dash_match is None: return None dash_array = tuple(dash_match.group(1).split()) else: return None dash_array = tuple(float(length) for length in dash_array) if not dash_array or sum(dash_array) <= 0: return None return dash_array
[docs] def parse_content_stream(stream: str) -> dict[str, list]: """ Parses data in the content stream into an ordered dictionary. Keys are operators. Values are operands. Currently only parses non-path data reliably. Path data is not parsed separately so, in the output dict, repetitive operators are overwritten in the dict (e.g. multiple 'l' operators will only have one entry). """ commands = {} operand_with_operator = re.compile(r"([\d+\s*|\d+\.\d+\s*]*)([A-Za-z]{1,2})\s*") operators_pattern = re.compile(r"[a-zA-Z]+") operands_pattern = re.compile(r"[\d.]+") floats_pattern = re.compile(r"^\d+\.\d+$") integers_pattern = re.compile(r"^\d+$") matches = operand_with_operator.findall(stream) for match in matches: # operator = operators.findall(match)[0] # operand = operands.findall(match) operands, operator = match numerical_operands = [] operands_matches = operands_pattern.findall(operands) for element in operands_matches: element = element.strip() float_match = floats_pattern.search(element) integer_match = integers_pattern.search(element) if float_match is not None: elem = Decimal(element) numerical_operands.append(elem) elif integer_match is not None: elem = int(element) numerical_operands.append(elem) commands.update({operator: numerical_operands}) return commands
[docs] def parse_html_text_content(xml_code: str) -> str: """ Returns 'Python-formatted' text for html text in the annotation popup """ if xml_code is None: return "" else: format_code = "<body{xml_code}>{html_code}</body>" results = parse.search(format_code, xml_code) html_code = results.named["html_code"] python_text = ( html_code.replace("</p><p>", "\n").replace("<p>", "").replace("</p>", "") ) return python_text