#!/usr/bin/env python3 # Copyright (c) 2019 Collabora, Ltd. # # SPDX-License-Identifier: Apache-2.0 # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # http://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # Author(s): Ryan Pavlik # # Purpose: This script helps drive a per-section PDF diff. import re from itertools import chain, zip_longest from pathlib import Path from pprint import pprint import attr from PyPDF2 import PdfFileReader from pdf_diff import command_line as pdf_diff NUMBERED_TITLE_RE = re.compile( r'(?P([0-9]+[.])+) (?P.*)') @attr.s class Bookmark: title = attr.ib() level = attr.ib() page_number = attr.ib() top = attr.ib(default=None) bottom = attr.ib(default=None) left = attr.ib(default=None) right = attr.ib(default=None) nested_title = attr.ib(default=None) def asdict(self): # fields = attr.fields(Section) return attr.asdict(self) @attr.s class Section: title = attr.ib() level = attr.ib() page_start = attr.ib() page_end = attr.ib() pdf = attr.ib(default=None) page_start_top = attr.ib(default=None) page_end_bottom = attr.ib(default=None) @property def page_numbers(self): return range(self.page_start, self.page_end + 1) @property def section_number(self): match = NUMBERED_TITLE_RE.match(self.title) if match: return match.group('section_num') return None @property def title_text(self): match = NUMBERED_TITLE_RE.match(self.title) if match: return match.group('title_text') return self.title @property def pdf_diff_options(self): fields = attr.fields(Section) ret = attr.asdict(self, filter=attr.filters.include( fields.page_start, fields.page_end)) ret['fn'] = str(self.pdf.fn) if self.page_start_top: ret['page_start_top'] = self.page_start_top if self.page_end_bottom: ret['page_end_bottom'] = self.page_end_bottom return ret def asdict(self): return attr.asdict(self, filter=attr.filters.exclude( attr.fields(Section).pdf)) def add_nested_title(bookmarks): title_stack = [] for i, bookmark in enumerate(bookmarks): while bookmark.level >= len(title_stack): title_stack.pop() title_stack.append(bookmark.title) bookmark.nested_title = " : ".join(title_stack) def outline_to_bookmarks(reader, outline=None, level=1, bookmarks=None): if outline is None: outline = reader.getOutlines() if bookmarks is None: bookmarks = [] for elt in outline: if isinstance(elt, list): outline_to_bookmarks(reader, outline=elt, level=level+1, bookmarks=bookmarks) else: page_num = reader.getDestinationPageNumber(elt) bookmark = Bookmark( title=elt.title, level=level, page_number=page_num + 1) page = reader.getPage(page_num) _, ul_y = page.bleedBox.upperLeft # print(page.bleedBox.upperLeft, page.bleedBox.lowerRight) # Coordinate system is flipped compared to pdf_diff... if elt.top: bookmark.top = float(ul_y) - float(elt.top) if elt.bottom: bookmark.bottom = float(ul_y) - float(elt.bottom) if elt.left: bookmark.left = float(elt.left) if elt.right: bookmark.right = float(elt.right) bookmarks.append(bookmark) return bookmarks def add_section_to_page_map(section, page_map): for i in section.page_numbers: if i not in page_map: page_map[i] = [] page_map[i].append(section) def compute_section_page_map(sections): page_map = {} for sec in sections: # +1 is for an inclusive range for page_num in sec.page_numbers: if page_num not in page_map: page_map[page_num] = [] page_map[page_num].append(sec) return page_map class PdfSpec: def __init__(self, fn): self.fn = fn self.reader = PdfFileReader(open(str(fn), 'rb')) self.bookmark_data = outline_to_bookmarks(self.reader) self._page_pdfs = None self._dom = None @property def dom(self): if self._dom is None: self._dom = pdf_diff.pdf_to_dom(str(self.fn)) return self._dom @property def page_pdfs(self): import pypdftk if not self._page_pdfs: self._page_pdfs = pypdftk.split(self.fn) return self._page_pdfs def pdf_for_page(self, pagenum): if pagenum is None: return None return self.page_pdfs[pagenum] def compute_sections(self, level=None, bookmarks=None, bookmark_predicate=None): if level is None: level = 1 if bookmarks is None: if bookmark_predicate is not None: bookmarks = [x for x in self.bookmark_data if bookmark_predicate(x)] else: bookmarks = [x for x in self.bookmark_data if x.level == level] sections = [] # Add a dummy section taking up all front-matter pages first_bookmark = bookmarks[0] if first_bookmark.page_number != 1: sections.append(Section(title="0. Front Matter", level=level, page_start=1, page_end=first_bookmark.page_number, pdf=self)) prev_bookmark = None for i, bookmark in enumerate(bookmarks): if prev_bookmark is not None: s = prev_bookmark.page_number e = bookmark.page_number if not bookmark.top or bookmark.top == 0: # If no "top", then assume the section ends on a page break. e -= 1 sec = Section(title=prev_bookmark.title, level=prev_bookmark.level, page_start=s, page_end=e, pdf=self) if prev_bookmark.top: sec.page_start_top = prev_bookmark.top if bookmark.top: sec.page_end_bottom = bookmark.top sections.append(sec) prev_bookmark = bookmark if bookmark is not None: # TODO Deal with the last section here! pass # Now, populate the object fields self.comparable_sections = sections self.section_by_title = {sec.title: sec for sec in self.comparable_sections} self.section_by_title_text = {sec.title_text: sec for sec in self.comparable_sections if sec.title_text} self.section_by_number = {sec.section_number: sec for sec in self.comparable_sections if sec.section_number} self.page_map = compute_section_page_map(self.comparable_sections) return sections def find_corresponding_section(self, section): """Find our own section corresponding to the supplied section from another PDF.""" own_section = self.section_by_title.get(section.title) if own_section: # Easy - full title matches return own_section own_section = self.section_by_title_text.get(section.title_text) if own_section: # Not as easy, we had a section renumber, possible issue here! return own_section own_section = self.section_by_number.get(section.section_number) if own_section: # Only the section number matched - WARNING! might be bad match! return own_section # Total failure return None @attr.s class MatchingSection: title = attr.ib() orig_range = attr.ib() new_range = attr.ib() changes = attr.ib(default=None) def __str__(self): return '{} ({}:{}-{}, {}:{}-{})'.format( self.title, self.orig_range['fn'], self.orig_range['page_start'], self.orig_range['page_end'], self.new_range['fn'], self.new_range['page_start'], self.new_range['page_end'], ) def get_section_range_pairs(orig_section, new_pdf): """Return MatchingSection for a section.""" other_section = new_pdf.find_corresponding_section(orig_section) if not other_section: print("Skipping section {} - no match in the other doc!".format( orig_section.title)) return None return MatchingSection( title=orig_section.title, orig_range=orig_section.pdf_diff_options, new_range=other_section.pdf_diff_options) def get_section_page_pairs(orig_section, new_pdf): """Return (orig_page_num, new_page_num) pairs for each page in section.""" other_section = new_pdf.find_corresponding_section(orig_section) if not other_section: print("Skipping section {} - no match in the other doc!".format( orig_section.title)) return [] return zip_longest(orig_section.page_numbers, other_section.page_numbers) def get_page_pairs_by_section(orig_pdf, new_pdf): """Return an iterable of lists of (orig_page_num, new_page_num) pairs. One such list of pairs is returned for each section in orig_pdf.""" return (list(get_section_page_pairs(sec, new_pdf)) for sec in orig_pdf.comparable_sections) def get_all_page_pairs(orig_pdf, new_pdf): """Get a single list of all page pairs. This accommodates inserted pages between sections.""" # Flatten into a single list of pairs raw_pairs = list(chain.from_iterable( get_page_pairs_by_section(orig_pdf, new_pdf))) # For any "full pair" (where both parts are non-None), # we can skip any half-diffs involving either page # in the next pass. We compute that set here. # For example, if we have the pairs: # (1, 1), (2, None), (2, 1), (3, 2) # we can drop the (2, None) pair because the original page 2 # is already being compared against the new page 1 -- see the (2, 1) -- # so there's no sense in saying it only exists in the original. unique_full_pairs = set(((orig_page, new_page) for orig_page, new_page in raw_pairs if orig_page is not None and new_page is not None)) skip_orig_only = set(((orig_page, None) for orig_page, _ in unique_full_pairs)) skip_new_only = set(((None, new_page) for _, new_page in unique_full_pairs)) skip_half_pairs = skip_orig_only.union(skip_new_only) # Main filter pass: deduplicate and filter out excluded half-diffs pairs = [] included_pairs = set() for page_pair in raw_pairs: if page_pair in skip_half_pairs: print("Dropping half-pair covered by other full pair", page_pair) # Don't unnecessarily include a "page only in document X" item. continue if page_pair in included_pairs: print("Dropping duplicated pair", page_pair) # Don't let small sections result in big dupes. continue pairs.append(page_pair) included_pairs.add(page_pair) return pairs class SequenceGapFinder: def __init__(self): self.prev = None def process_and_get_gap(self, current): """Return the range of expected numbers skipped between the last call to this method and the current one. Return None if no numbers skipped.""" ret = None if current is not None: if self.prev is not None and current > self.prev + 1: ret = range(self.prev + 1, current) self.prev = current return ret def zip_longest_permitting_none(a, b): """Do zip_longest except treat None as a zero-element iterable.""" if a is None and b is None: return () if a is None: return ((None, elt) for elt in b) if b is None: return ((elt, None) for elt in a) return zip_longest(a, b) def fill_pair_gaps(pairs): """Add any missing pages to the list of pairs.""" orig_pages = [orig_page for orig_page, _ in pairs if orig_page is not None] new_pages = [new_page for _, new_page in pairs if new_page is not None] assert(orig_pages == sorted(orig_pages)) assert(new_pages == sorted(new_pages)) fixed_pairs = [] orig_gaps = SequenceGapFinder() new_gaps = SequenceGapFinder() for orig_page, new_page in pairs: orig_gap = orig_gaps.process_and_get_gap(orig_page) new_gap = new_gaps.process_and_get_gap(new_page) if orig_gap is not None or new_gap is not None: gap_pairs = list(zip_longest_permitting_none(orig_gap, new_gap)) print("Found gap pairs", gap_pairs) fixed_pairs.extend(gap_pairs) fixed_pairs.append((orig_page, new_page)) return fixed_pairs class GranularPdfDiff: def __init__(self, orig_fn, new_fn): self.orig_pdf = PdfSpec(orig_fn) self.new_pdf = PdfSpec(new_fn) def generate_matching_sections(self): """Return a generator of MatchingSection. At most one MatchingSection is returned for each section in orig_pdf. """ for sec in self.orig_pdf.comparable_sections: matching = get_section_range_pairs(sec, self.new_pdf) if not matching: continue matching.orig_range["dom"] = self.orig_pdf.dom matching.new_range["dom"] = self.new_pdf.dom changes = pdf_diff.compute_changes( matching.orig_range, matching.new_range, bottom_margin=93) if changes: matching.changes = changes yield matching else: print("No changes in", matching) def compute_sections(self, **kwargs): self.orig_pdf.compute_sections(**kwargs) self.new_pdf.compute_sections(**kwargs) def compute_page_pairs(self): pairs = get_all_page_pairs(self.orig_pdf, self.new_pdf) return fill_pair_gaps(pairs) def generate_page_diff(self, orig_page_num, new_page_num): orig_page = self.orig_pdf.pdf_for_page(orig_page_num) new_page = self.new_pdf.pdf_for_page(new_page_num) if orig_page and new_page: changes = pdf_diff.compute_changes( orig_page, new_page) pprint(changes) # TODO def generate_diff_from_pairs(self, pairs): out_docs = [] for orig_page_num, new_page_num in pairs: page_diff = self.generate_page_diff(orig_page_num, new_page_num) if page_diff: out_docs.append(page_diff) # TODO if __name__ == "__main__": SPECDIR = Path(__file__).resolve().parent.parent assert(SPECDIR.name == "specification") ORIG = SPECDIR / 'compare-base' / 'openxr.pdf' NEW = SPECDIR / 'out' / '1.0' / 'openxr.pdf' DIFFDIR = SPECDIR / 'diffs' DIFFDIR.mkdir(exist_ok=True) def is_separate_diff_section(bookmark): # All chapters, except the extension chapter if bookmark.level == 1 and "List of Extensions" not in bookmark.title: return True # All the individual sub-sections in the extension chapter # (one for each extension) if bookmark.level == 2 and "XR_KHR" in bookmark.title: return True return False diff = GranularPdfDiff(ORIG, NEW) diff.compute_sections(bookmark_predicate=is_separate_diff_section) for i, matching in enumerate(diff.generate_matching_sections(), 1): img = pdf_diff.render_changes(matching.changes, ('strike', 'underline'), 900) fn = "Diff part {:02d} - {}.diff.png".format(i, matching.title) full_path = DIFFDIR / fn print('Writing', full_path.relative_to(SPECDIR)) with open(str(full_path), 'wb') as fp: img.save(fp, 'PNG')