andruhovski

How to Add Header or Footer

Oct 29th, 2026
118
0
Never
Not a member of Pastebin yet? Sign Up, it unlocks many cool features!
Python 11.39 KB | Source Code | 0 0
  1. """Add headers and footers with Aspose.PDF; always start from a fresh source."""
  2. import argparse
  3. from collections import Counter
  4. from dataclasses import dataclass
  5. from importlib.metadata import version
  6. import os
  7. from pathlib import Path
  8. import re
  9. import sys
  10. from tempfile import TemporaryDirectory
  11.  
  12. import aspose.pdf as pdf
  13.  
  14. BASE = Path(__file__).resolve().parent
  15. HEADER = "Finance Department | Monthly Report"
  16. MARGIN = 24
  17. BAND = 60
  18. FONT_SIZE = 10
  19. PAGE_LABEL = re.compile(r"\bPage\s+\d+\s+of\s+\d+\b")
  20.  
  21.  
  22. @dataclass(frozen=True)
  23. class PageSnapshot:
  24.     words: Counter
  25.     crop_box: tuple
  26.     media_box: tuple
  27.     rotation: int
  28.  
  29.  
  30. def labels(count, skip_cover=True, start=1, only_page=None):
  31.     """Map physical (one-based) pages to explicit displayed labels."""
  32.     if type(start) is not int or start < 1:
  33.         raise ValueError("start must be positive")
  34.     eligible = list(range(2 if skip_cover else 1, count + 1))
  35.     if not eligible:
  36.         raise ValueError("No eligible pages")
  37.     if only_page is not None and (type(only_page) is not int or only_page not in eligible):
  38.         raise ValueError("Selected page must be eligible and within the document")
  39.     last = start + len(eligible) - 1
  40.     return {p: f"Page {start + i} of {last}"
  41.             for i, p in enumerate(eligible) if only_page is None or p == only_page}
  42.  
  43.  
  44. def rectangle_values(rectangle):
  45.     """Copy coordinates so snapshots do not retain mutable .NET rectangles."""
  46.     return (rectangle.llx, rectangle.lly, rectangle.urx, rectangle.ury)
  47.  
  48.  
  49. def page_size(page):
  50.     rectangle = page.get_page_rect(True)
  51.     return rectangle.width, rectangle.height
  52.  
  53.  
  54. def extract_text(page):
  55.     """Read text in unrotated coordinates; restore page rotation even on failure."""
  56.     rotation = page.rotate
  57.     try:
  58.         page.rotate = pdf.Rotation.NONE
  59.         absorber = pdf.text.TextAbsorber()
  60.         page.accept(absorber)
  61.         return absorber.text
  62.     finally:
  63.         page.rotate = rotation
  64.  
  65.  
  66. def text_rectangles(page, phrase=None):
  67.     """Return copies of text rectangles in the original PDF coordinate system."""
  68.     rotation = page.rotate
  69.     try:
  70.         page.rotate = pdf.Rotation.NONE
  71.         absorber = (pdf.text.TextFragmentAbsorber() if phrase is None
  72.                     else pdf.text.TextFragmentAbsorber(phrase))
  73.         page.accept(absorber)
  74.         return [rectangle_values(fragment.rectangle) for fragment in absorber.text_fragments]
  75.     finally:
  76.         page.rotate = rotation
  77.  
  78.  
  79. def visible_rectangle(page, rectangle):
  80.     """Map unrotated PDF coordinates to the viewer's bottom-left coordinate system."""
  81.     crop = page.rect
  82.     rotation = pdf.Page.rotation_to_int(page.rotate)
  83.     points = []
  84.     for x, y in ((rectangle[0], rectangle[1]), (rectangle[2], rectangle[1]),
  85.                  (rectangle[0], rectangle[3]), (rectangle[2], rectangle[3])):
  86.         x, y = x - crop.llx, y - crop.lly
  87.         if rotation == 90:
  88.             x, y = y, crop.width - x
  89.         elif rotation == 180:
  90.             x, y = crop.width - x, crop.height - y
  91.         elif rotation == 270:
  92.             x, y = crop.height - y, x
  93.         points.append((x, y))
  94.     return (min(x for x, y in points), min(y for x, y in points),
  95.             max(x for x, y in points), max(y for x, y in points))
  96.  
  97.  
  98. def inspect_document(document, header, numbering, font):
  99.     """Validate static inputs without rebuilding or normalizing their pages."""
  100.     if document.is_encrypted:
  101.         raise ValueError("Encrypted inputs require a separate workflow")
  102.     if len(document.form.fields):
  103.         raise ValueError("Forms and signatures are outside this static-page example")
  104.     if len(document.embedded_files):
  105.         raise ValueError("Attachments require a separate preservation workflow")
  106.     snapshots = []
  107.     for index, page in enumerate(document.pages, 1):
  108.         if len(page.annotations):
  109.             raise ValueError(f"Page {index}: annotations/widgets/links are unsupported")
  110.         text = extract_text(page)
  111.         if header in text or "Evaluation Only" in text or PAGE_LABEL.search(text):
  112.             raise ValueError(f"Page {index}: input appears stamped; use fresh source")
  113.         width, height = page_size(page)
  114.         if min(width, height) < 180:
  115.             raise ValueError(f"Page {index}: too small for reserved stamp bands")
  116.         if index in numbering:
  117.             for raw in text_rectangles(page):
  118.                 left, bottom, right, top = visible_rectangle(page, raw)
  119.                 # Ignore text wholly outside the visible CropBox.
  120.                 if right <= 0 or left >= width or top <= 0 or bottom >= height:
  121.                     continue
  122.                 if bottom < BAND or top > height - BAND:
  123.                     raise ValueError(f"Page {index}: text occupies a reserved 60pt band")
  124.             for value in (header, numbering[index]):
  125.                 if font.measure_string(value, FONT_SIZE) > width - 2 * MARGIN:
  126.                     raise ValueError(f"Page {index}: header or footer is too wide")
  127.         snapshots.append(PageSnapshot(
  128.             Counter(text.split()), rectangle_values(page.crop_box),
  129.             rectangle_values(page.media_box), pdf.Page.rotation_to_int(page.rotate)))
  130.     return snapshots
  131.  
  132.  
  133. def add_header_footer(page, header, footer):
  134.     """Aspose aligns stamps to the visible rotated page; dimensions are PDF points."""
  135.     font = pdf.text.FontRepository.find_font("Arial")
  136.     for value, is_footer in ((header, False), (footer, True)):
  137.         stamp = pdf.TextStamp(value)
  138.         stamp.background = False
  139.         stamp.text_state.font = font
  140.         stamp.text_state.font_size = FONT_SIZE
  141.         stamp.text_state.foreground_color = pdf.Color.dark_blue
  142.         if is_footer:
  143.             stamp.horizontal_alignment = pdf.HorizontalAlignment.CENTER
  144.             stamp.vertical_alignment = pdf.VerticalAlignment.BOTTOM
  145.             stamp.bottom_margin = MARGIN
  146.         else:
  147.             stamp.horizontal_alignment = pdf.HorizontalAlignment.LEFT
  148.             stamp.vertical_alignment = pdf.VerticalAlignment.TOP
  149.             stamp.left_margin = MARGIN
  150.             stamp.top_margin = MARGIN
  151.         page.add_stamp(stamp)
  152.  
  153.  
  154. def verify(staged, snapshots, numbering, header):
  155.     """Reopen with Aspose and check text, original page boxes/rotation, and stamp placement."""
  156.     with pdf.Document(str(staged)) as result:
  157.         if len(result.pages) != len(snapshots):
  158.             raise ValueError("Saved page count changed")
  159.         for index, snapshot in enumerate(snapshots, 1):
  160.             page = result.pages[index]
  161.             text = extract_text(page)
  162.             if snapshot.words - Counter(text.split()):
  163.                 raise ValueError(f"Page {index}: source text missing after save")
  164.             geometry = rectangle_values(page.crop_box) + rectangle_values(page.media_box)
  165.             expected = snapshot.crop_box + snapshot.media_box
  166.             if (pdf.Page.rotation_to_int(page.rotate) != snapshot.rotation
  167.                     or any(abs(a - b) > .1 for a, b in zip(geometry, expected))):
  168.                 raise ValueError(f"Page {index}: page boxes or rotation changed")
  169.             if index not in numbering:
  170.                 if header in text or PAGE_LABEL.search(text):
  171.                     raise ValueError(f"Page {index}: excluded page was stamped")
  172.                 continue
  173.             width, height = page_size(page)
  174.             for value, is_footer in ((header, False), (numbering[index], True)):
  175.                 matches = text_rectangles(page, value)
  176.                 if len(matches) != 1:
  177.                     raise ValueError(f"Page {index}: expected exactly one {value!r}")
  178.                 left, bottom, right, top = visible_rectangle(page, matches[0])
  179.                 if left < -.1 or bottom < -.1 or right > width + .1 or top > height + .1:
  180.                     raise ValueError(f"Page {index}: clipped stamp")
  181.                 if is_footer:
  182.                     if top > BAND or abs((left + right) / 2 - width / 2) > 1:
  183.                         raise ValueError(f"Page {index}: footer outside centered bottom band")
  184.                 elif bottom < height - BAND:
  185.                     raise ValueError(f"Page {index}: header outside top band")
  186.  
  187.  
  188. def process(source, destination, *, header=HEADER, skip_cover=True, start=1,
  189.             only_page=None, allow_evaluation=False):
  190.     source = Path(source).resolve(strict=True)
  191.     destination = Path(destination).resolve()
  192.     if source == destination or os.path.lexists(destination):
  193.         raise FileExistsError(f"Refusing destination collision: {destination}")
  194.     if not header.strip() or not header.isascii() or "\n" in header or "\r" in header:
  195.         raise ValueError("Header must be one nonempty ASCII line for this font example")
  196.     license_object = None
  197.     license_path = os.environ.get("ASPOSE_PDF_LICENSE")
  198.     if license_path:
  199.         license_object = pdf.License()
  200.         license_object.set_license(str(Path(license_path).resolve(strict=True)))
  201.     if license_object is None and not allow_evaluation:
  202.         raise ValueError("Set ASPOSE_PDF_LICENSE or pass --allow-evaluation for a watermarked demo")
  203.  
  204.     with pdf.Document(str(source)) as document:
  205.         page_count = len(document.pages)
  206.         if license_object is None and page_count > 4:
  207.             raise ValueError("Evaluation demo is limited to four input pages")
  208.         numbering = labels(page_count, skip_cover, start, only_page)
  209.         font = pdf.text.FontRepository.find_font("Arial")
  210.         snapshots = inspect_document(document, header, numbering, font)
  211.         destination.parent.mkdir(parents=True, exist_ok=True)
  212.         with TemporaryDirectory(prefix=".header-footer-", dir=destination.parent) as temporary:
  213.             staged = Path(temporary) / "stamped.pdf"
  214.             for physical, footer in numbering.items():
  215.                 try:
  216.                     add_header_footer(document.pages[physical], header, footer)
  217.                 except Exception as exc:
  218.                     raise RuntimeError(f"Stamping physical page {physical}: {exc}") from exc
  219.             document.save(str(staged))
  220.             verify(staged, snapshots, numbering, header)
  221.             # Same-filesystem, no-clobber publication; the source is never saved in place.
  222.             os.link(staged, destination)
  223.     return numbering
  224.  
  225.  
  226. def main():
  227.     parser = argparse.ArgumentParser(description=__doc__)
  228.     parser.add_argument("--input", type=Path, default=BASE / "input/monthly-report.pdf")
  229.     parser.add_argument("--output", type=Path, default=BASE / "output/monthly-report.pdf")
  230.     parser.add_argument("--header", default=HEADER)
  231.     parser.add_argument("--start", type=int, default=1)
  232.     parser.add_argument("--include-cover", action="store_true")
  233.     parser.add_argument("--only-page", type=int)
  234.     parser.add_argument("--allow-evaluation", action="store_true")
  235.     args = parser.parse_args()
  236.     try:
  237.         numbering = process(args.input, args.output, header=args.header,
  238.                             skip_cover=not args.include_cover, start=args.start,
  239.                             only_page=args.only_page, allow_evaluation=args.allow_evaluation)
  240.         print(f"Verified labels: {numbering}")
  241.         print(f"Published: {args.output}")
  242.         print(f"Aspose.PDF {version('aspose-pdf')}")
  243.     except Exception as exc:
  244.         print(f"FAILED ({args.input} -> {args.output}): {exc}", file=sys.stderr)
  245.         return 1
  246.     return 0
  247.  
  248.  
  249. if __name__ == "__main__":
  250.     raise SystemExit(main())
  251.  
Advertisement