"""Answer provenance and reproducible annotation spacing (200 dpi pixels).""" from hashlib import sha256 from pathlib import Path import shutil import pymupdf from copienator import configuration from copienator.json_io import atomic_write_json, read_json def fingerprint(path): return sha256(Path(path).read_bytes()).hexdigest() def metadata_path(pdf): return Path(pdf).with_suffix('.layout.json') def answer_parts(pdf): """Ignore stale sidecars after a PDF is replaced or manually edited.""" if pdf is None or not Path(pdf).is_file(): return [] try: data = read_json(metadata_path(pdf), default={}) if data.get('sha256') == fingerprint(pdf): parts = data.get('parts', []) if isinstance(parts, list) and all(isinstance(part, dict) for part in parts): return parts except (OSError, ValueError, TypeError, AttributeError): pass return [] def save_parts(pdf, parts): atomic_write_json(metadata_path(pdf), { 'version': 1, 'sha256': fingerprint(pdf), 'parts': parts, }) def page_parts(pdf): parts = answer_parts(pdf) if not Path(pdf).is_file(): return [] with pymupdf.open(pdf) as document: if len(parts) == len(document): return parts return [{'margin_status': 'unknown'} for _ in document] def copy_answer_pdf(source, destination): parts = answer_parts(source) shutil.copy(source, destination) save_parts(destination, parts) def move_answer_pdf(source, destination): parts = answer_parts(source) shutil.move(str(source), str(destination)) save_parts(destination, parts) metadata_path(source).unlink(missing_ok=True) def layout_for(pdf): low = any(part.get('margin_status') == 'low' for part in answer_parts(pdf)) margin = (configuration.ANNOTATION_LOW_MARGIN_LEFT if low else configuration.ANNOTATION_MARGIN_LEFT) if type(margin) is not int or margin < 0: raise ValueError('Annotation left margins must be non-negative integers') return {'version': 1, 'margin_left': margin, 'narrow_left_pad_limit': 300} def legacy_layout(): # Old annotation scans were produced with these literal constants. return {'version': 1, 'margin_left': 300, 'narrow_left_pad_limit': 300}