"""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 added_margin(margin_mm): """Interpolate the configured (millimetres, pixels) points, clamping at ends.""" if margin_mm is None: return configuration.ANNOTATION_MARGIN_LEFT points = configuration.ANNOTATION_MARGIN_POINTS if (not points or any(x < 0 or type(y) is not int or y < 0 for x, y in points) or any(a[0] >= b[0] for a, b in zip(points, points[1:]))): raise ValueError('Annotation margin points must have increasing mm and non-negative integer pixels') if margin_mm <= points[0][0]: return points[0][1] for (x0, y0), (x1, y1) in zip(points, points[1:]): if margin_mm <= x1: return round(y0 + (y1 - y0) * (margin_mm - x0) / (x1 - x0)) return points[-1][1] def layout_for(pdf): margins = [added_margin(part.get('margin_mm')) for part in answer_parts(pdf)] return {'version': 2, 'margin_left': max(margins, default=added_margin(None)), 'page_margins': margins, 'narrow_left_pad_limit': 0} def legacy_layout(): # Old annotation scans were produced with these literal constants. return {'version': 1, 'margin_left': 300, 'narrow_left_pad_limit': 300}