89 lines
2.8 KiB
Python
89 lines
2.8 KiB
Python
"""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}
|