adsfolio.pdf.annotation_merge

Move annotations between PDF editions of the same paper into a new copy.

The target PDF is never modified. A user places each source annotation on the target, or waives it explicitly, and the result is written to a new file after it has been re-read and checked. The companion annotation_matching module finds candidate text locations. Every placement still needs explicit approval.

class adsfolio.pdf.annotation_merge.AnnotationItem(item_id, provenance, source_sha256, page, xref, subtype, supported, companion, reason, rect, content='', title='', subject='', icon_name='', stroke=(), fill=(), opacity=-1.0, flags=0, border_width=None, dashes=(), quads=(), vertices=(), ink=(), line_ends=None, text='', page_rotation=0)[source]

Bases: object

One annotation found in the source PDF, with the data needed to copy it.

Coordinates are PDF points with the origin at the top-left of the page, as reported by PyMuPDF. supported is false for subtypes that cannot be copied without approximation; companion marks popup windows, which belong to a parent annotation and carry no content of their own.

Parameters:
border_width: float | None = None
companion: bool
content: str = ''
dashes: tuple[float, ...] = ()
fill: tuple[float, ...] = ()
flags: int = 0
icon_name: str = ''
ink: tuple[tuple[tuple[float, float], ...], ...] = ()
item_id: str
property kind: str[source]

Return “markup”, “point” or “none” for the placement mode.

line_ends: tuple[int, int] | None = None
property needs_waiver: bool[source]

Return whether this item must be explicitly waived to be left out.

opacity: float = -1.0
page: int
page_rotation: int = 0
provenance: str
quads: tuple[tuple[tuple[float, float], ...], ...] = ()
reason: str
rect: tuple[float, float, float, float]
source_sha256: str
stroke: tuple[float, ...] = ()
subject: str = ''
subtype: str
supported: bool
text: str = ''
title: str = ''
vertices: tuple[tuple[float, float], ...] = ()
xref: int
exception adsfolio.pdf.annotation_merge.AnnotationMergeError[source]

Bases: RuntimeError

Raised when a merge cannot be performed or fails verification.

class adsfolio.pdf.annotation_merge.MergeInput(path, sha256)[source]

Bases: object

An original PDF path and the exact content used for a saved merge.

Parameters:
path: str
sha256: str
class adsfolio.pdf.annotation_merge.MergeReport(output_path, transferred=<factory>, already_present=<factory>, waived=<factory>, unresolved=<factory>, companions=<factory>, verified=False, input_files=(), output_sha256='')[source]

Bases: object

Outcome of one merge. complete is true only if nothing is left over.

Parameters:
already_present: list[str]
companions: list[str]
property complete: bool[source]

Return whether every source annotation was transferred or waived.

input_files: tuple[adsfolio.pdf.annotation_merge.MergeInput, ...] = ()
output_path: str
output_sha256: str = ''
summary()[source]

Return a plain-text summary that never calls a partial merge complete.

Return type:

str

transferred: list[str]
unresolved: list[str]
verified: bool = False
waived: list[str]
exception adsfolio.pdf.annotation_merge.MissingDependencyError[source]

Bases: RuntimeError

Raised when PyMuPDF is not installed.

class adsfolio.pdf.annotation_merge.Placement(item_id, page, rects=(), point=None)[source]

Bases: object

Where one source annotation goes in the target.

Markup annotations use rects (word-line boxes on page). Other supported annotations use point, the target position of the source annotation’s top-left corner; the shape is translated, never scaled.

Parameters:
item_id: str
page: int
point: tuple[float, float] | None = None
rects: tuple[tuple[float, float, float, float], ...] = ()
class adsfolio.pdf.annotation_merge.WordIndex(path)[source]

Bases: object

Words of every page of one PDF, read once for repeated exact lookups.

Parameters:

path (Path)

pages: list[list[tuple[Any, ...]]]
adsfolio.pdf.annotation_merge.build_merged_copy(target_path, source_path, items, placements, waived, output_path)[source]

Write a verified copy of the target with the placed source annotations.

Parameters:
  • target_path (Path) – The canonical PDF and the PDF whose annotations are copied. Both are only read.

  • source_path (Path) – The canonical PDF and the PDF whose annotations are copied. Both are only read.

  • items (list of AnnotationItem) – Result of enumerate_annotations for source_path.

  • placements (dict) – User placements keyed by item_id.

  • waived (set of str) – item_id values the user chose not to copy. Only unsupported, non-popup items may be waived.

  • output_path (Path) – New file. An existing path is never overwritten, and its parent directory must already exist.

Returns:

Items neither placed nor waived are listed as unresolved. Nothing is written unless every transferred annotation re-reads correctly; otherwise AnnotationMergeError is raised.

Return type:

MergeReport

Notes

Each copied annotation carries a private provenance key (hash of the source file, page and object number). A later merge of the same source into the merged copy reports those items as already present instead of adding duplicates. Provenance is lost if another tool rewrites the annotation, and it does not link different files of one paper.

adsfolio.pdf.annotation_merge.enumerate_annotations(path)[source]

List every annotation object in path, supported or not.

Links, form widgets, popups and all other objects in a page’s annotation array are included, so nothing is skipped silently. Annotations that an earlier merge added to this file are listed like any other.

Parameters:

path (pathlib.Path)

Return type:

list[adsfolio.pdf.annotation_merge.AnnotationItem]

adsfolio.pdf.annotation_merge.import_fitz()[source]

Import PyMuPDF, or raise MissingDependencyError with install advice.

Return type:

Any

adsfolio.pdf.annotation_merge.line_rects(words)[source]

Merge PyMuPDF word tuples into one bounding box per text line.

Parameters:

words (list[tuple[Any, ...]])

Return type:

tuple[tuple[float, float, float, float], …]

adsfolio.pdf.annotation_merge.placement_error(item, placement, page_rect, page_rotation=0)[source]

Return why placement is invalid for item, or None if it is valid.

Parameters:
Return type:

str | None

adsfolio.pdf.annotation_merge.suggest_exact_placement(item, index)[source]

Suggest a markup placement when the source words occur exactly once.

The source words must appear as the identical run of words exactly once in the whole target. Case, punctuation and hyphenation must agree; anything else returns None. The caller must still ask the user to confirm.

Parameters:
Return type:

adsfolio.pdf.annotation_merge.Placement | None

adsfolio.pdf.annotation_merge.words_inside(page, rect)[source]

Return the words of page whose centres lie inside rect.

Parameters:
Return type:

list[tuple[Any, …]]