Not a member of Pastebin yet?
Sign Up,
it unlocks many cool features!
- """Add headers and footers with Aspose.PDF; always start from a fresh source."""
- import argparse
- from collections import Counter
- from dataclasses import dataclass
- from importlib.metadata import version
- import os
- from pathlib import Path
- import re
- import sys
- from tempfile import TemporaryDirectory
- import aspose.pdf as pdf
- BASE = Path(__file__).resolve().parent
- HEADER = "Finance Department | Monthly Report"
- MARGIN = 24
- BAND = 60
- FONT_SIZE = 10
- PAGE_LABEL = re.compile(r"\bPage\s+\d+\s+of\s+\d+\b")
- @dataclass(frozen=True)
- class PageSnapshot:
- words: Counter
- crop_box: tuple
- media_box: tuple
- rotation: int
- def labels(count, skip_cover=True, start=1, only_page=None):
- """Map physical (one-based) pages to explicit displayed labels."""
- if type(start) is not int or start < 1:
- raise ValueError("start must be positive")
- eligible = list(range(2 if skip_cover else 1, count + 1))
- if not eligible:
- raise ValueError("No eligible pages")
- if only_page is not None and (type(only_page) is not int or only_page not in eligible):
- raise ValueError("Selected page must be eligible and within the document")
- last = start + len(eligible) - 1
- return {p: f"Page {start + i} of {last}"
- for i, p in enumerate(eligible) if only_page is None or p == only_page}
- def rectangle_values(rectangle):
- """Copy coordinates so snapshots do not retain mutable .NET rectangles."""
- return (rectangle.llx, rectangle.lly, rectangle.urx, rectangle.ury)
- def page_size(page):
- rectangle = page.get_page_rect(True)
- return rectangle.width, rectangle.height
- def extract_text(page):
- """Read text in unrotated coordinates; restore page rotation even on failure."""
- rotation = page.rotate
- try:
- page.rotate = pdf.Rotation.NONE
- absorber = pdf.text.TextAbsorber()
- page.accept(absorber)
- return absorber.text
- finally:
- page.rotate = rotation
- def text_rectangles(page, phrase=None):
- """Return copies of text rectangles in the original PDF coordinate system."""
- rotation = page.rotate
- try:
- page.rotate = pdf.Rotation.NONE
- absorber = (pdf.text.TextFragmentAbsorber() if phrase is None
- else pdf.text.TextFragmentAbsorber(phrase))
- page.accept(absorber)
- return [rectangle_values(fragment.rectangle) for fragment in absorber.text_fragments]
- finally:
- page.rotate = rotation
- def visible_rectangle(page, rectangle):
- """Map unrotated PDF coordinates to the viewer's bottom-left coordinate system."""
- crop = page.rect
- rotation = pdf.Page.rotation_to_int(page.rotate)
- points = []
- for x, y in ((rectangle[0], rectangle[1]), (rectangle[2], rectangle[1]),
- (rectangle[0], rectangle[3]), (rectangle[2], rectangle[3])):
- x, y = x - crop.llx, y - crop.lly
- if rotation == 90:
- x, y = y, crop.width - x
- elif rotation == 180:
- x, y = crop.width - x, crop.height - y
- elif rotation == 270:
- x, y = crop.height - y, x
- points.append((x, y))
- return (min(x for x, y in points), min(y for x, y in points),
- max(x for x, y in points), max(y for x, y in points))
- def inspect_document(document, header, numbering, font):
- """Validate static inputs without rebuilding or normalizing their pages."""
- if document.is_encrypted:
- raise ValueError("Encrypted inputs require a separate workflow")
- if len(document.form.fields):
- raise ValueError("Forms and signatures are outside this static-page example")
- if len(document.embedded_files):
- raise ValueError("Attachments require a separate preservation workflow")
- snapshots = []
- for index, page in enumerate(document.pages, 1):
- if len(page.annotations):
- raise ValueError(f"Page {index}: annotations/widgets/links are unsupported")
- text = extract_text(page)
- if header in text or "Evaluation Only" in text or PAGE_LABEL.search(text):
- raise ValueError(f"Page {index}: input appears stamped; use fresh source")
- width, height = page_size(page)
- if min(width, height) < 180:
- raise ValueError(f"Page {index}: too small for reserved stamp bands")
- if index in numbering:
- for raw in text_rectangles(page):
- left, bottom, right, top = visible_rectangle(page, raw)
- # Ignore text wholly outside the visible CropBox.
- if right <= 0 or left >= width or top <= 0 or bottom >= height:
- continue
- if bottom < BAND or top > height - BAND:
- raise ValueError(f"Page {index}: text occupies a reserved 60pt band")
- for value in (header, numbering[index]):
- if font.measure_string(value, FONT_SIZE) > width - 2 * MARGIN:
- raise ValueError(f"Page {index}: header or footer is too wide")
- snapshots.append(PageSnapshot(
- Counter(text.split()), rectangle_values(page.crop_box),
- rectangle_values(page.media_box), pdf.Page.rotation_to_int(page.rotate)))
- return snapshots
- def add_header_footer(page, header, footer):
- """Aspose aligns stamps to the visible rotated page; dimensions are PDF points."""
- font = pdf.text.FontRepository.find_font("Arial")
- for value, is_footer in ((header, False), (footer, True)):
- stamp = pdf.TextStamp(value)
- stamp.background = False
- stamp.text_state.font = font
- stamp.text_state.font_size = FONT_SIZE
- stamp.text_state.foreground_color = pdf.Color.dark_blue
- if is_footer:
- stamp.horizontal_alignment = pdf.HorizontalAlignment.CENTER
- stamp.vertical_alignment = pdf.VerticalAlignment.BOTTOM
- stamp.bottom_margin = MARGIN
- else:
- stamp.horizontal_alignment = pdf.HorizontalAlignment.LEFT
- stamp.vertical_alignment = pdf.VerticalAlignment.TOP
- stamp.left_margin = MARGIN
- stamp.top_margin = MARGIN
- page.add_stamp(stamp)
- def verify(staged, snapshots, numbering, header):
- """Reopen with Aspose and check text, original page boxes/rotation, and stamp placement."""
- with pdf.Document(str(staged)) as result:
- if len(result.pages) != len(snapshots):
- raise ValueError("Saved page count changed")
- for index, snapshot in enumerate(snapshots, 1):
- page = result.pages[index]
- text = extract_text(page)
- if snapshot.words - Counter(text.split()):
- raise ValueError(f"Page {index}: source text missing after save")
- geometry = rectangle_values(page.crop_box) + rectangle_values(page.media_box)
- expected = snapshot.crop_box + snapshot.media_box
- if (pdf.Page.rotation_to_int(page.rotate) != snapshot.rotation
- or any(abs(a - b) > .1 for a, b in zip(geometry, expected))):
- raise ValueError(f"Page {index}: page boxes or rotation changed")
- if index not in numbering:
- if header in text or PAGE_LABEL.search(text):
- raise ValueError(f"Page {index}: excluded page was stamped")
- continue
- width, height = page_size(page)
- for value, is_footer in ((header, False), (numbering[index], True)):
- matches = text_rectangles(page, value)
- if len(matches) != 1:
- raise ValueError(f"Page {index}: expected exactly one {value!r}")
- left, bottom, right, top = visible_rectangle(page, matches[0])
- if left < -.1 or bottom < -.1 or right > width + .1 or top > height + .1:
- raise ValueError(f"Page {index}: clipped stamp")
- if is_footer:
- if top > BAND or abs((left + right) / 2 - width / 2) > 1:
- raise ValueError(f"Page {index}: footer outside centered bottom band")
- elif bottom < height - BAND:
- raise ValueError(f"Page {index}: header outside top band")
- def process(source, destination, *, header=HEADER, skip_cover=True, start=1,
- only_page=None, allow_evaluation=False):
- source = Path(source).resolve(strict=True)
- destination = Path(destination).resolve()
- if source == destination or os.path.lexists(destination):
- raise FileExistsError(f"Refusing destination collision: {destination}")
- if not header.strip() or not header.isascii() or "\n" in header or "\r" in header:
- raise ValueError("Header must be one nonempty ASCII line for this font example")
- license_object = None
- license_path = os.environ.get("ASPOSE_PDF_LICENSE")
- if license_path:
- license_object = pdf.License()
- license_object.set_license(str(Path(license_path).resolve(strict=True)))
- if license_object is None and not allow_evaluation:
- raise ValueError("Set ASPOSE_PDF_LICENSE or pass --allow-evaluation for a watermarked demo")
- with pdf.Document(str(source)) as document:
- page_count = len(document.pages)
- if license_object is None and page_count > 4:
- raise ValueError("Evaluation demo is limited to four input pages")
- numbering = labels(page_count, skip_cover, start, only_page)
- font = pdf.text.FontRepository.find_font("Arial")
- snapshots = inspect_document(document, header, numbering, font)
- destination.parent.mkdir(parents=True, exist_ok=True)
- with TemporaryDirectory(prefix=".header-footer-", dir=destination.parent) as temporary:
- staged = Path(temporary) / "stamped.pdf"
- for physical, footer in numbering.items():
- try:
- add_header_footer(document.pages[physical], header, footer)
- except Exception as exc:
- raise RuntimeError(f"Stamping physical page {physical}: {exc}") from exc
- document.save(str(staged))
- verify(staged, snapshots, numbering, header)
- # Same-filesystem, no-clobber publication; the source is never saved in place.
- os.link(staged, destination)
- return numbering
- def main():
- parser = argparse.ArgumentParser(description=__doc__)
- parser.add_argument("--input", type=Path, default=BASE / "input/monthly-report.pdf")
- parser.add_argument("--output", type=Path, default=BASE / "output/monthly-report.pdf")
- parser.add_argument("--header", default=HEADER)
- parser.add_argument("--start", type=int, default=1)
- parser.add_argument("--include-cover", action="store_true")
- parser.add_argument("--only-page", type=int)
- parser.add_argument("--allow-evaluation", action="store_true")
- args = parser.parse_args()
- try:
- numbering = process(args.input, args.output, header=args.header,
- skip_cover=not args.include_cover, start=args.start,
- only_page=args.only_page, allow_evaluation=args.allow_evaluation)
- print(f"Verified labels: {numbering}")
- print(f"Published: {args.output}")
- print(f"Aspose.PDF {version('aspose-pdf')}")
- except Exception as exc:
- print(f"FAILED ({args.input} -> {args.output}): {exc}", file=sys.stderr)
- return 1
- return 0
- if __name__ == "__main__":
- raise SystemExit(main())
Advertisement