Initial support for user general remarks

This commit is contained in:
sebastien committed 2026-10-28 13:51:59 +01:00
1 parent a38c1ff4b3
commit 2c9dc65640
16 files changed
+606 -20

No files matched your search

+12 -3
View File
@@ -2,6 +2,9 @@
from __future__ import annotations
import re
import shutil
from copienator import general_remarks
from copy import deepcopy
from hashlib import sha256
from pathlib import Path
@@ -154,7 +157,7 @@ def edited_bodies(directory: Path, meta: dict, instructions: list[ManualInstruct
def output_removals(directory: Path) -> tuple[str, ...]:
info = read_json(directory / "info.json") if (directory / "info.json").exists() else {}
return (STATE_FILE, "Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
return (general_remarks.IMAGE_FILENAME, STATE_FILE, "Concat.jpg", "Concat_F.jpg", "Concat_F.pdf", "touched.json", "answer_labels.json",
*(f"{label}.jpg" for label, entry in info.items()
if Path(label).name == label))
@@ -181,7 +184,13 @@ def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
label for label, entry in info.items() if entry["not_empty"]
}
all_labels = utils.read_all_labels(root)
images, groups, checked_images = [], [], []
general = general_remarks.existing(directory)
images = [general] if general is not None else []
groups = [[general]] if general is not None else []
checked_images = []
if general is not None and directory != staging:
shutil.copy2(directory / general_remarks.IMAGE_FILENAME,
staging / general_remarks.IMAGE_FILENAME)
seen_contexts = set()
order = sorted(bodies, key=utils.natural_key)
for label in order:
@@ -215,7 +224,7 @@ def rebuild(root: Path, mode: str, directory: Path, staging: Path, meta: dict,
save_paginated_pdf(groups, staging / "Concat_F.pdf")
else:
stack([image for group in groups for image in group]).save(staging / "Concat_F.jpg")
save_checked_pdf(root, checked_images, all_labels, staging / "Concat_F.pdf")
save_checked_pdf(root, checked_images, all_labels, staging / "Concat_F.pdf", general=general)
def stage_baseline(root: Path, mode: str, output: Path, staging: Path,
+4 -1
View File
@@ -7,6 +7,7 @@ from copienator import atomic_write_json, configuration, read_json
from copienator.annotation_documents import copy_documents
from copienator.return_answers import load_return_answer_options
from copienator.custom_remarks import load_remarks, copy_key
from copienator import general_remarks
STATE_FILE = ".annotation-read-state.json"
DOCUMENT_INPUTS = ("bnote.json", "checkboxes.json", "Reference.jpg", "Concat_annotated.pdf")
@@ -40,6 +41,7 @@ class AnnotationReadCache:
configuration.RETURN_JPEG_ENABLED, configuration.RETURN_PDF_ENABLED,
configuration.LATEX_ANOT_BEFORE, configuration.LATEX_ANOT_AFTER)
remarks = load_remarks(root)
general = general_remarks.load_remarks(root)
for copy_id, labels in data.items():
directory = root / mode / f"Copie{copy_id}"
paths = [directory / "score.json", directory / "bnote.json"]
@@ -48,7 +50,7 @@ class AnnotationReadCache:
for content in labels.values():
pdf = Path(content["pdf_path"])
paths.extend((pdf, pdf.with_suffix(".layout.json")))
payload = json.dumps((2, common, labels, remarks.get(copy_key(copy_id), {}), _files_digest(paths)),
payload = json.dumps((3, common, labels, general.get(copy_key(copy_id), []), remarks.get(copy_key(copy_id), {}), _files_digest(paths)),
sort_keys=True, default=str).encode()
self.signatures[copy_id] = sha256(payload).hexdigest()
@@ -69,6 +71,7 @@ class AnnotationReadCache:
info = read_json(directory / "info.json")
outputs.extend(directory / f"{label}.jpg" for label, entry in info.items()
if entry["present"])
outputs.extend(directory.glob(general_remarks.IMAGE_FILENAME))
outputs.extend(directory.glob("Concat*.*"))
outputs.extend(directory.glob(".annotation-base-*.png"))
outputs.extend(directory.glob("annotation_baseline.json"))
+10 -2
View File
@@ -26,6 +26,7 @@ from copienator.annotation_documents import copy_documents
from copienator.annotation_layout import legacy_layout
from copienator.annotation_read_cache import AnnotationReadCache
from copienator.custom_remarks import apply_custom_remarks
from copienator import general_remarks
from copienator.annotation_modifications import (
BASELINE,
output_removals,
@@ -260,9 +261,16 @@ def apply_actions_and_regenerate(
if incomplete:
return ExitCode.PARTIAL
general = general_remarks.render(workspace.root, student_id,
max((im.width for im in dirty_images.values()), default=800))
if general is not None:
concatenated.insert(0, general)
filtered.insert(0, general)
concat_image = concatenate(concatenated)
filtered_image = concatenate(filtered)
with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging:
if general is not None:
general.save(staging / general_remarks.IMAGE_FILENAME)
for label, image in dirty_images.items():
image.save(staging / f"{label}.jpg")
if not preserve_score_file:
@@ -277,8 +285,8 @@ def apply_actions_and_regenerate(
# Imported here because the grouped reader also uses this module.
from copienator.commands.reading_grouped_annotations import save_checked_pdf
save_checked_pdf(
workspace.root, list(zip(filtered_labels, filtered, strict=True)),
all_labels, staging / "Concat_F.pdf",
workspace.root, [(label, dirty_images[label]) for label in filtered_labels],
all_labels, staging / "Concat_F.pdf", general=general,
)
stage_baseline(workspace.root, "Bnot", output_dir, staging, dirty_images,
header_heights, build_answer_info(scores, labels_data, answer_labels),
@@ -29,6 +29,7 @@ from copienator.annotation_documents import copy_documents, tablet_documents
from copienator.annotation_layout import legacy_layout
from copienator.annotation_read_cache import AnnotationReadCache
from copienator.custom_remarks import apply_custom_remarks
from copienator import general_remarks
from copienator.annotation_modifications import (
BASELINE,
baseline,
@@ -150,9 +151,9 @@ def save_paginated_pdf(
)
def save_checked_pdf(root, labelled_images, all_labels, output_path):
def save_checked_pdf(root, labelled_images, all_labels, output_path, *, general=None):
"""Build the checked-mode return PDF with the same supplements as BGnot."""
groups = []
groups = [[general]] if general is not None else []
seen_contexts = set()
for label, image in labelled_images:
extras = get_extra_pdfs_as_images(
@@ -410,10 +411,17 @@ def apply_actions_and_regenerate_grouped(
filtered_groups.append([*extras, final_image])
touched[label] = annotation_dir == "BGnot"
general = general_remarks.render(workspace.root, student_id,
max((im.width for im in dirty_images.values()), default=800))
if general is not None:
concat_images.insert(0, general)
filtered_groups.insert(0, [general])
concat_image = concatenate(concat_images)
if incomplete:
return ExitCode.PARTIAL, "\n".join(logs)
with staged_files(output_dir, remove=(*output_removals(output_dir), BASELINE)) as staging:
if general is not None:
general.save(staging / general_remarks.IMAGE_FILENAME)
if simple_layout is not None:
simple_layout["replaced"] = sorted(
set(simple_layout["replaced"]) | selected_labels
@@ -440,7 +448,7 @@ def apply_actions_and_regenerate_grouped(
save_checked_pdf(
workspace.root,
[(label, dirty_images[label]) for label in filtered_labels],
all_labels, staging / "Concat_F.pdf",
all_labels, staging / "Concat_F.pdf", general=general,
)
stage_baseline(workspace.root, annotation_dir, output_dir, staging, dirty_images,
header_heights, build_answer_info(scores, labels_data, answer_labels, touched),
+68
View File
@@ -0,0 +1,68 @@
"""Ordered copy-level remarks and their generated LaTeX image."""
from pathlib import Path
from PIL import Image
from copienator import atomic_update_json, read_json
from copienator.custom_remarks import copy_key
FILENAME = "general_remarks.json"
IMAGE_FILENAME = "Remarques_generales.jpg"
def validate(data):
if not isinstance(data, dict) or any(
not isinstance(key, str) or copy_key(key) != key
or not isinstance(remarks, list)
or any(not isinstance(text, str) for text in remarks)
for key, remarks in data.items()
):
raise ValueError(f"Format invalide dans {FILENAME}.")
def load_remarks(root: Path) -> dict[str, list[str]]:
data = read_json(root / FILENAME, default={})
validate(data)
return data
def remarks_for_copy(root: Path, copy_id: str) -> list[str]:
return load_remarks(root).get(copy_key(copy_id), [])
def set_remarks(root: Path, copy_id: str, remarks: list[str]) -> list[str]:
key = copy_key(copy_id)
current = [text.strip() for text in remarks if text.strip()]
previous = []
def update(data):
nonlocal previous
validate(data)
previous = data.get(key, [])
if current:
data[key] = current
else:
data.pop(key, None)
atomic_update_json(root / FILENAME, update, default_factory=dict)
return previous
def render(root: Path, copy_id: str, width: int) -> Image.Image | None:
remarks = remarks_for_copy(root, copy_id)
if not remarks:
return None
from copienator.commands.annotating import render_real_latex_text
text = r"\textbf{Remarques générales}\par\medskip " + r"\par\medskip ".join(remarks)
content = render_real_latex_text(text, max(100, width - 40)).convert("RGB")
image = Image.new("RGB", (max(width, content.width + 40), content.height + 40), "white")
image.paste(content, (20, 20))
return image
def existing(directory: Path) -> Image.Image | None:
path = directory / IMAGE_FILENAME
if path.is_file():
with Image.open(path) as image:
return image.convert("RGB")
return None
+47 -8
View File
@@ -13,6 +13,10 @@ WIDTH_TOLERANCE = .08
# Start below it with clearance, including on later pages with repeated names.
BODY_TOP = .30
BODY_BOTTOM = .82
LABEL_BOTTOM = .92
LABEL_LEFT_FRACTION = .05
COHERENT_MARGIN_TOLERANCE_MM = 10
LABEL_MARGIN_GAP_MM = 10
DISCARD_LEFT_FRACTION = .10
STANDARD_MARGIN_MM = 40
MARGIN_CLEARANCE_MM = 20
@@ -47,21 +51,28 @@ def handwriting_mask(rgb: np.ndarray) -> np.ndarray:
return keep[labels] & diagnostics['masks']['foreground']
def body_edge(mask: np.ndarray) -> dict:
"""Estimate a left edge from occupied 4 mm bands within 30–82% height."""
height = mask.shape[0]
def occupied_band_starts(mask: np.ndarray, bottom_fraction: float) -> list[float]:
"""Ignore empty bands and writing starting beyond the page midpoint."""
height, width = mask.shape
band_height = max(1, round(4 * ANALYSIS_DPI / 25.4))
bottom = round(BODY_BOTTOM * height)
bottom = round(bottom_fraction * height)
starts = []
for y in range(round(BODY_TOP * height), bottom, band_height):
band = mask[y:min(y + band_height, bottom)]
xs = np.flatnonzero(band.sum(axis=0) >= 2)
if band.sum() >= 40 and len(xs) >= 12:
if band.sum() >= 40 and len(xs) >= 12 and xs[0] <= width / 2:
starts.append(float(xs[0]) * 25.4 / ANALYSIS_DPI)
return starts
def body_edge(mask: np.ndarray) -> dict:
"""Keep the body decile; collect a wider 30–92% label-search window."""
starts = occupied_band_starts(mask, BODY_BOTTOM)
return {
'edge_mm': trimmed_left_edge(starts) if len(starts) >= 6 else None,
'bands': len(starts),
'band_starts_mm': starts,
'label_band_starts_mm': occupied_band_starts(mask, LABEL_BOTTOM),
}
@@ -90,7 +101,7 @@ def format_groups(images: list[Image.Image], reference_width: float) -> list[lis
def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width: float) -> dict:
"""Pool occupied bands and use the larger half-margin or 20 mm-clearance cut."""
"""Estimate body margins, then retain left labels supported across coherent pages."""
starts = [0] * len(images)
groups = []
page_estimates = {}
@@ -116,6 +127,24 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
status = 'near-edge' if edge < MIN_MARGIN_MM else 'margin'
left_mm = (0.0 if edge < MIN_MARGIN_MM else
max(edge / 2, edge - MARGIN_CLEARANCE_MM))
# Use ALL coherent pages, not just the four body-margin samples.
# A sparse/low/outlying page cannot supply evidence for this format's
# established body margin. Its normal fallback behaviour remains below.
coherent = [index for index in indices
if edge is not None and page_estimate(index)['edge_mm'] is not None
and page_estimate(index)['edge_mm'] >= MIN_MARGIN_MM
and abs(page_estimate(index)['edge_mm'] - edge) <= COHERENT_MARGIN_TOLERANCE_MM]
label_starts = [start for index in coherent for start in
page_estimate(index).get('label_band_starts_mm',
page_estimate(index)['band_starts_mm'])]
label_edge = (round(sorted(label_starts)[int(len(label_starts) * LABEL_LEFT_FRACTION)], 1)
if label_starts else None)
labels_in_margin = (status == 'margin' and len(coherent) >= min(2, len(indices))
and label_edge is not None and edge - label_edge >= LABEL_MARGIN_GAP_MM)
body_left_mm = left_mm
if labels_in_margin:
status = 'labels-in-margin'
left_mm = 0.0
# Secondary subpages can start in the middle of an answer. Preserve
# their left edge even when the only visible writing is indented.
if images[indices[0]].width < .8 * reference_width:
@@ -129,6 +158,11 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
'status': status,
'edge_mm': edge,
'left_mm': round(left_mm, 1),
'body_left_mm': round(body_left_mm, 1),
'labels_in_margin': labels_in_margin,
'coherent_pages': [index + 1 for index in coherent],
'label_edge_mm': label_edge,
'label_bands': len(label_starts),
'bands': len(band_starts),
'discarded_bands': int(len(band_starts) * DISCARD_LEFT_FRACTION),
'samples': [{'page': index + 1, **item} for index, item in estimates.items()],
@@ -154,9 +188,14 @@ def analyse_margins(images: list[Image.Image], render_dpi: int, reference_width:
'status': 'unknown' if edge is None else
('low' if edge < MIN_MARGIN_MM else 'normal')})
return {'version': 8, 'pages': pages, 'starts_px': starts, 'groups': groups,
return {'version': 9, 'pages': pages, 'starts_px': starts, 'groups': groups,
'no_margin_from_page': no_margin_from_page,
'discard_left_fraction': DISCARD_LEFT_FRACTION, 'margin_clearance_mm': MARGIN_CLEARANCE_MM,
'cut_fraction_of_margin': .5, 'cut_rule': 'maximum',
'standard_margin_mm': STANDARD_MARGIN_MM, 'minimum_margin_mm': MIN_MARGIN_MM,
'body_height_fraction': [BODY_TOP, BODY_BOTTOM]}
'body_height_fraction': [BODY_TOP, BODY_BOTTOM],
'label_height_fraction': [BODY_TOP, LABEL_BOTTOM],
'label_left_fraction': LABEL_LEFT_FRACTION,
'coherent_margin_tolerance_mm': COHERENT_MARGIN_TOLERANCE_MM,
'label_margin_gap_mm': LABEL_MARGIN_GAP_MM,
'maximum_band_start_width_fraction': .5}
+6
View File
@@ -1,6 +1,9 @@
from __future__ import annotations
from pathlib import Path
import shutil
from copienator import general_remarks
from PIL import Image
@@ -92,6 +95,9 @@ def publish_answer_returns(
all_labels = utils.read_all_labels(root)
with staged_directory(answers_dir) as staging:
general = source / general_remarks.IMAGE_FILENAME
if general.is_file():
shutil.copy2(general, staging / general.name)
for label in sorted(labels, key=utils.natural_key):
paths = []
if options["context"]: