Skills › Scientific Agent Skills › pptx-posters › _manifest.py
_manifest.py
1324 lines · skills/pptx-posters/scripts/_manifest.py at 5f2bc03
1#!/usr/bin/env python3
2"""Strict poster-manifest validation shared by generation and audit tools."""
4from __future__ import annotations
6import re
7from pathlib import Path
8from typing import Any
10from _common import (
11 PPTX_MAX_INCHES,
12 PPTX_MIN_INCHES,
13 CliError,
14 canonical_json_hash,
15 contrast_ratio,
16 finite_number,
17 has_placeholder,
18 load_json_file,
19 parse_aware_datetime,
20 parse_hex_color,
21 positive_int,
22 reject_unknown_keys,
23 require_bool,
24 require_string,
25 resolve_local_asset,
26 sha256_file,
27)
29MANIFEST_SCHEMA_VERSION = "2.0"
30REPORT_SCHEMA_VERSION = "1.0"
31MAX_ELEMENTS = 250
32MAX_SOURCES = 500
33MAX_ASSETS = 100
34ASSET_SUFFIXES = {".png", ".jpg", ".jpeg"}
36_ID = re.compile(r"^[A-Za-z][A-Za-z0-9_.-]{0,63}$")
37_SHA256 = re.compile(r"^[0-9a-f]{64}$")
38_RELATIVE_PATH = re.compile(r"^[^:/\\][^:\\]*$")
40_TOP_KEYS = {
41 "schema_version",
42 "document",
43 "canvas",
44 "physical_output",
45 "requirements",
46 "quality",
47 "palette",
48 "sources",
49 "assets",
50 "elements",
51 "approval",
52}
53_SOURCE_KINDS = {
54 "author_content",
55 "publication",
56 "dataset",
57 "asset_license",
58 "conference_rule",
59 "printer_rule",
60 "institutional_rule",
61 "other",
62}
63_FONT_BASES = {
64 "heuristic",
65 "source_specific",
66 "conference_requirement",
67 "printer_requirement",
68}
69_TEXT_ROLES = {
70 "title",
71 "authors",
72 "affiliation",
73 "heading",
74 "body",
75 "caption",
76 "reference",
77 "acknowledgement",
78 "contact",
79 "qr_fallback",
80 "other",
81}
84def _identifier(value: Any, *, context: str) -> str:
85 text = require_string(value, context=context, maximum=64)
86 if not _ID.fullmatch(text):
87 raise CliError(
88 f"{context} must start with a letter and contain only letters, "
89 "digits, period, underscore, or hyphen"
90 )
91 return text
94def _nullable_source_id(value: Any, *, context: str) -> str | None:
95 if value is None:
96 return None
97 return _identifier(value, context=context)
100def _string_list(
101 value: Any,
102 *,
103 context: str,
104 minimum: int,
105 maximum: int,
106) -> list[str]:
107 if not isinstance(value, list):
108 raise CliError(f"{context} must be an array")
109 if not minimum <= len(value) <= maximum:
110 raise CliError(
111 f"{context} must contain between {minimum} and {maximum} values"
112 )
113 result = [
114 require_string(item, context=f"{context}[{index}]", maximum=500)
115 for index, item in enumerate(value)
116 ]
117 if len(set(result)) != len(result):
118 raise CliError(f"{context} must not contain duplicates")
119 return result
122def _source_id_list(value: Any, *, context: str) -> list[str]:
123 raw = _string_list(value, context=context, minimum=1, maximum=16)
124 return [
125 _identifier(item, context=f"{context}[{index}]")
126 for index, item in enumerate(raw)
127 ]
130def manifest_content_hash(document: dict[str, Any]) -> str:
131 """Hash all author-controlled manifest content except the approval record."""
132 return canonical_json_hash(
133 {key: value for key, value in document.items() if key != "approval"}
134 )
137def _validate_document(value: Any) -> dict[str, Any]:
138 document = reject_unknown_keys(
139 value,
140 context="document",
141 allowed={"id", "title", "subject", "language", "authors", "source_ids"},
142 required={"id", "title", "subject", "language", "authors", "source_ids"},
143 )
144 _identifier(document["id"], context="document.id")
145 # These strings are written verbatim to python-pptx core properties.
146 require_string(document["title"], context="document.title", maximum=255)
147 require_string(document["subject"], context="document.subject", maximum=255)
148 language = require_string(
149 document["language"], context="document.language", maximum=35
150 )
151 if not re.fullmatch(r"[A-Za-z]{2,8}(?:-[A-Za-z0-9]{1,8})*", language):
152 raise CliError("document.language must be a BCP 47-style language tag")
153 authors = _string_list(
154 document["authors"],
155 context="document.authors",
156 minimum=1,
157 maximum=50,
158 )
159 require_string(
160 "; ".join(authors),
161 context="document.authors joined with '; ' for core metadata",
162 maximum=255,
163 )
164 _source_id_list(document["source_ids"], context="document.source_ids")
165 return document
168def _validate_canvas(value: Any) -> tuple[dict[str, Any], float, float]:
169 canvas = reject_unknown_keys(
170 value,
171 context="canvas",
172 allowed={"width_in", "height_in", "background_color"},
173 required={"width_in", "height_in", "background_color"},
174 )
175 width = finite_number(
176 canvas["width_in"],
177 context="canvas.width_in",
178 minimum=PPTX_MIN_INCHES,
179 maximum=PPTX_MAX_INCHES,
180 )
181 height = finite_number(
182 canvas["height_in"],
183 context="canvas.height_in",
184 minimum=PPTX_MIN_INCHES,
185 maximum=PPTX_MAX_INCHES,
186 )
187 parse_hex_color(canvas["background_color"], context="canvas.background_color")
188 return canvas, width, height
191def _validate_physical_output(
192 value: Any,
193) -> tuple[dict[str, Any], float, float, float, float, float, float]:
194 physical = reject_unknown_keys(
195 value,
196 context="physical_output",
197 allowed={
198 "trim_width_in",
199 "trim_height_in",
200 "bleed_in",
201 "safe_margin_in",
202 "orientation",
203 },
204 required={
205 "trim_width_in",
206 "trim_height_in",
207 "bleed_in",
208 "safe_margin_in",
209 "orientation",
210 },
211 )
212 trim_width = finite_number(
213 physical["trim_width_in"],
214 context="physical_output.trim_width_in",
215 minimum=1.0,
216 maximum=200.0,
217 )
218 trim_height = finite_number(
219 physical["trim_height_in"],
220 context="physical_output.trim_height_in",
221 minimum=1.0,
222 maximum=200.0,
223 )
224 bleed = finite_number(
225 physical["bleed_in"],
226 context="physical_output.bleed_in",
227 minimum=0.0,
228 maximum=2.0,
229 )
230 safe_margin = finite_number(
231 physical["safe_margin_in"],
232 context="physical_output.safe_margin_in",
233 minimum=0.0,
234 maximum=10.0,
235 )
236 if 2 * safe_margin >= min(trim_width, trim_height):
237 raise CliError("physical_output.safe_margin_in consumes the trim area")
238 orientation = require_string(
239 physical["orientation"],
240 context="physical_output.orientation",
241 maximum=16,
242 )
243 expected = (
244 "square"
245 if abs(trim_width - trim_height) <= 1e-6
246 else ("landscape" if trim_width > trim_height else "portrait")
247 )
248 if orientation != expected:
249 raise CliError(
250 "physical_output.orientation does not match the trim dimensions "
251 f"(expected {expected!r})"
252 )
253 artboard_width = trim_width + 2 * bleed
254 artboard_height = trim_height + 2 * bleed
255 return (
256 physical,
257 trim_width,
258 trim_height,
259 bleed,
260 safe_margin,
261 artboard_width,
262 artboard_height,
263 )
266def _validate_sources(value: Any) -> dict[str, dict[str, Any]]:
267 if not isinstance(value, list) or not 1 <= len(value) <= MAX_SOURCES:
268 raise CliError(f"sources must contain between 1 and {MAX_SOURCES} objects")
269 result: dict[str, dict[str, Any]] = {}
270 for index, item in enumerate(value):
271 context = f"sources[{index}]"
272 source = reject_unknown_keys(
273 item,
274 context=context,
275 allowed={"id", "kind", "citation", "locator", "author_verified"},
276 required={"id", "kind", "citation", "locator", "author_verified"},
277 )
278 source_id = _identifier(source["id"], context=f"{context}.id")
279 if source_id in result:
280 raise CliError(f"duplicate source id: {source_id}")
281 kind = require_string(source["kind"], context=f"{context}.kind", maximum=32)
282 if kind not in _SOURCE_KINDS:
283 raise CliError(
284 f"{context}.kind must be one of {', '.join(sorted(_SOURCE_KINDS))}"
285 )
286 require_string(
287 source["citation"], context=f"{context}.citation", maximum=2_000
288 )
289 require_string(source["locator"], context=f"{context}.locator", maximum=2_000)
290 if not require_bool(
291 source["author_verified"], context=f"{context}.author_verified"
292 ):
293 raise CliError(f"{context}.author_verified must be true")
294 result[source_id] = source
295 return result
298def _validate_requirements(
299 value: Any,
300 *,
301 trim_width: float,
302 trim_height: float,
303 bleed: float,
304 safe_margin: float,
305 print_scale: float,
306) -> tuple[dict[str, Any], set[str]]:
307 requirements = reject_unknown_keys(
308 value,
309 context="requirements",
310 allowed={"conference", "printer"},
311 required={"conference", "printer"},
312 )
313 conference = reject_unknown_keys(
314 requirements["conference"],
315 context="requirements.conference",
316 allowed={
317 "confirmed",
318 "source_id",
319 "max_width_in",
320 "max_height_in",
321 "orientation",
322 "required_delivery_format",
323 "notes",
324 },
325 required={
326 "confirmed",
327 "source_id",
328 "max_width_in",
329 "max_height_in",
330 "orientation",
331 "required_delivery_format",
332 "notes",
333 },
334 )
335 if not require_bool(
336 conference["confirmed"], context="requirements.conference.confirmed"
337 ):
338 raise CliError("requirements.conference.confirmed must be true")
339 conference_source = _identifier(
340 conference["source_id"], context="requirements.conference.source_id"
341 )
342 max_width = finite_number(
343 conference["max_width_in"],
344 context="requirements.conference.max_width_in",
345 minimum=1.0,
346 maximum=200.0,
347 )
348 max_height = finite_number(
349 conference["max_height_in"],
350 context="requirements.conference.max_height_in",
351 minimum=1.0,
352 maximum=200.0,
353 )
354 conference_orientation = require_string(
355 conference["orientation"],
356 context="requirements.conference.orientation",
357 maximum=16,
358 )
359 if conference_orientation not in {"portrait", "landscape", "square", "either"}:
360 raise CliError(
361 "requirements.conference.orientation must be portrait, landscape, "
362 "square, or either"
363 )
364 physical_orientation = (
365 "square"
366 if abs(trim_width - trim_height) <= 1e-6
367 else ("landscape" if trim_width > trim_height else "portrait")
368 )
369 if (
370 conference_orientation != "either"
371 and conference_orientation != physical_orientation
372 ):
373 raise CliError("physical output violates the confirmed conference orientation")
374 if trim_width > max_width + 1e-6 or trim_height > max_height + 1e-6:
375 raise CliError("physical output exceeds the confirmed conference dimensions")
376 delivery = require_string(
377 conference["required_delivery_format"],
378 context="requirements.conference.required_delivery_format",
379 maximum=32,
380 )
381 if delivery not in {"PDF", "PPTX", "PDF_AND_PPTX", "OTHER"}:
382 raise CliError(
383 "requirements.conference.required_delivery_format must be PDF, PPTX, "
384 "PDF_AND_PPTX, or OTHER"
385 )
386 require_string(
387 conference["notes"],
388 context="requirements.conference.notes",
389 minimum=0,
390 maximum=2_000,
391 )
393 printer = reject_unknown_keys(
394 requirements["printer"],
395 context="requirements.printer",
396 allowed={
397 "confirmed",
398 "source_id",
399 "trim_width_in",
400 "trim_height_in",
401 "bleed_in",
402 "safe_margin_in",
403 "accepted_color_mode",
404 "scaling_allowed",
405 "notes",
406 },
407 required={
408 "confirmed",
409 "source_id",
410 "trim_width_in",
411 "trim_height_in",
412 "bleed_in",
413 "safe_margin_in",
414 "accepted_color_mode",
415 "scaling_allowed",
416 "notes",
417 },
418 )
419 if not require_bool(printer["confirmed"], context="requirements.printer.confirmed"):
420 raise CliError("requirements.printer.confirmed must be true")
421 printer_source = _identifier(
422 printer["source_id"], context="requirements.printer.source_id"
423 )
424 printer_trim_width = finite_number(
425 printer["trim_width_in"],
426 context="requirements.printer.trim_width_in",
427 minimum=1.0,
428 maximum=200.0,
429 )
430 printer_trim_height = finite_number(
431 printer["trim_height_in"],
432 context="requirements.printer.trim_height_in",
433 minimum=1.0,
434 maximum=200.0,
435 )
436 printer_bleed = finite_number(
437 printer["bleed_in"],
438 context="requirements.printer.bleed_in",
439 minimum=0.0,
440 maximum=2.0,
441 )
442 printer_margin = finite_number(
443 printer["safe_margin_in"],
444 context="requirements.printer.safe_margin_in",
445 minimum=0.0,
446 maximum=10.0,
447 )
448 for actual, required, label in (
449 (trim_width, printer_trim_width, "trim width"),
450 (trim_height, printer_trim_height, "trim height"),
451 (bleed, printer_bleed, "bleed"),
452 (safe_margin, printer_margin, "safe margin"),
453 ):
454 if abs(actual - required) > 1e-6:
455 raise CliError(
456 f"physical output {label} does not match the confirmed printer rule"
457 )
458 color_mode = require_string(
459 printer["accepted_color_mode"],
460 context="requirements.printer.accepted_color_mode",
461 maximum=32,
462 )
463 if color_mode not in {"RGB", "CMYK", "PRINTER_MANAGED"}:
464 raise CliError(
465 "requirements.printer.accepted_color_mode must be RGB, CMYK, "
466 "or PRINTER_MANAGED"
467 )
468 scaling_allowed = require_bool(
469 printer["scaling_allowed"], context="requirements.printer.scaling_allowed"
470 )
471 if not scaling_allowed and abs(print_scale - 1.0) > 1e-6:
472 raise CliError(
473 "printer forbids scaling but canvas and physical artboard differ"
474 )
475 require_string(
476 printer["notes"],
477 context="requirements.printer.notes",
478 minimum=0,
479 maximum=2_000,
480 )
481 return requirements, {conference_source, printer_source}
484def _validate_quality(value: Any) -> tuple[dict[str, Any], set[str]]:
485 quality = reject_unknown_keys(
486 value,
487 context="quality",
488 allowed={
489 "minimum_font_pt_final",
490 "font_guidance_basis",
491 "font_guidance_source_id",
492 "minimum_raster_dpi_final",
493 "raster_dpi_basis",
494 "raster_dpi_source_id",
495 },
496 required={
497 "minimum_font_pt_final",
498 "font_guidance_basis",
499 "font_guidance_source_id",
500 "minimum_raster_dpi_final",
501 "raster_dpi_basis",
502 "raster_dpi_source_id",
503 },
504 )
505 finite_number(
506 quality["minimum_font_pt_final"],
507 context="quality.minimum_font_pt_final",
508 minimum=1.0,
509 maximum=200.0,
510 )
511 finite_number(
512 quality["minimum_raster_dpi_final"],
513 context="quality.minimum_raster_dpi_final",
514 minimum=1.0,
515 maximum=2_400.0,
516 )
517 used: set[str] = set()
518 for prefix in ("font_guidance", "raster_dpi"):
519 basis = require_string(
520 quality[f"{prefix}_basis"],
521 context=f"quality.{prefix}_basis",
522 maximum=32,
523 )
524 if basis not in _FONT_BASES:
525 raise CliError(
526 f"quality.{prefix}_basis must be one of "
527 f"{', '.join(sorted(_FONT_BASES))}"
528 )
529 source_id = _nullable_source_id(
530 quality[f"{prefix}_source_id"],
531 context=f"quality.{prefix}_source_id",
532 )
533 if basis == "heuristic" and source_id is not None:
534 raise CliError(
535 f"quality.{prefix}_source_id must be null when basis is heuristic"
536 )
537 if basis != "heuristic" and source_id is None:
538 raise CliError(
539 f"quality.{prefix}_source_id is required for basis {basis!r}"
540 )
541 if source_id is not None:
542 used.add(source_id)
543 return quality, used
546def _validate_palette(
547 value: Any,
548 *,
549 enforce_thresholds: bool = True,
550) -> tuple[dict[str, Any], dict[str, str], dict[str, dict[str, Any]]]:
551 palette = reject_unknown_keys(
552 value,
553 context="palette",
554 allowed={"colors", "contrast_pairs", "data_series_redundant_encoding"},
555 required={"colors", "contrast_pairs", "data_series_redundant_encoding"},
556 )
557 raw_colors = palette["colors"]
558 if not isinstance(raw_colors, dict) or not 2 <= len(raw_colors) <= 64:
559 raise CliError("palette.colors must contain between 2 and 64 named colors")
560 colors: dict[str, str] = {}
561 for raw_id, value in raw_colors.items():
562 color_id = _identifier(raw_id, context=f"palette.colors[{raw_id!r}]")
563 colors[color_id] = parse_hex_color(
564 value, context=f"palette.colors.{color_id}"
565 )
567 raw_pairs = palette["contrast_pairs"]
568 if not isinstance(raw_pairs, list) or not 1 <= len(raw_pairs) <= 128:
569 raise CliError("palette.contrast_pairs must contain between 1 and 128 pairs")
570 pairs: dict[str, dict[str, Any]] = {}
571 for index, item in enumerate(raw_pairs):
572 context = f"palette.contrast_pairs[{index}]"
573 pair = reject_unknown_keys(
574 item,
575 context=context,
576 allowed={
577 "id",
578 "foreground_color_id",
579 "background_color_id",
580 "usage",
581 },
582 required={
583 "id",
584 "foreground_color_id",
585 "background_color_id",
586 "usage",
587 },
588 )
589 pair_id = _identifier(pair["id"], context=f"{context}.id")
590 if pair_id in pairs:
591 raise CliError(f"duplicate contrast pair id: {pair_id}")
592 foreground_id = _identifier(
593 pair["foreground_color_id"],
594 context=f"{context}.foreground_color_id",
595 )
596 background_id = _identifier(
597 pair["background_color_id"],
598 context=f"{context}.background_color_id",
599 )
600 if foreground_id not in colors or background_id not in colors:
601 raise CliError(f"{context} references an unknown palette color")
602 usage = require_string(pair["usage"], context=f"{context}.usage", maximum=24)
603 thresholds = {"normal_text": 4.5, "large_text": 3.0, "non_text": 3.0}
604 if usage not in thresholds:
605 raise CliError(
606 f"{context}.usage must be normal_text, large_text, or non_text"
607 )
608 ratio = contrast_ratio(colors[foreground_id], colors[background_id])
609 if enforce_thresholds and ratio + 1e-9 < thresholds[usage]:
610 raise CliError(
611 f"{context} contrast is {ratio:.2f}:1, below the "
612 f"{thresholds[usage]:.1f}:1 threshold for {usage}"
613 )
614 pairs[pair_id] = pair
615 if not require_bool(
616 palette["data_series_redundant_encoding"],
617 context="palette.data_series_redundant_encoding",
618 ):
619 raise CliError("palette.data_series_redundant_encoding must be true")
620 return palette, colors, pairs
623def _validate_asset_path_syntax(value: Any, *, context: str) -> str:
624 path = require_string(value, context=context, maximum=512)
625 if not _RELATIVE_PATH.fullmatch(path) or path.startswith(("/", "\\")):
626 raise CliError(f"{context} must be a relative local path")
627 if "\\" in path or any(part in {"", ".", ".."} for part in Path(path).parts):
628 raise CliError(f"{context} contains an unsafe path segment")
629 if Path(path).suffix.lower() not in ASSET_SUFFIXES:
630 raise CliError(
631 f"{context} must use one of {', '.join(sorted(ASSET_SUFFIXES))}"
632 )
633 return path
636def _validate_assets(
637 value: Any,
638 *,
639 manifest_path: Path,
640 verify_assets: bool,
641) -> tuple[dict[str, dict[str, Any]], dict[str, Path], set[str]]:
642 if not isinstance(value, list) or len(value) > MAX_ASSETS:
643 raise CliError(f"assets must be an array with at most {MAX_ASSETS} objects")
644 assets: dict[str, dict[str, Any]] = {}
645 paths: dict[str, Path] = {}
646 used_sources: set[str] = set()
647 used_path_values: set[str] = set()
648 for index, item in enumerate(value):
649 context = f"assets[{index}]"
650 asset = reject_unknown_keys(
651 item,
652 context=context,
653 allowed={
654 "id",
655 "path",
656 "role",
657 "sha256",
658 "source_id",
659 "license",
660 "provenance",
661 "alt_text",
662 "author_approved",
663 "qr_target",
664 },
665 required={
666 "id",
667 "path",
668 "role",
669 "sha256",
670 "source_id",
671 "license",
672 "provenance",
673 "alt_text",
674 "author_approved",
675 "qr_target",
676 },
677 )
678 asset_id = _identifier(asset["id"], context=f"{context}.id")
679 if asset_id in assets:
680 raise CliError(f"duplicate asset id: {asset_id}")
681 path_value = _validate_asset_path_syntax(
682 asset["path"], context=f"{context}.path"
683 )
684 if path_value in used_path_values:
685 raise CliError(
686 f"{context}.path duplicates another asset path: {path_value!r}"
687 )
688 used_path_values.add(path_value)
689 role = require_string(asset["role"], context=f"{context}.role", maximum=16)
690 if role not in {"figure", "logo", "qr_code"}:
691 raise CliError(f"{context}.role must be figure, logo, or qr_code")
692 digest = require_string(
693 asset["sha256"], context=f"{context}.sha256", maximum=64
694 )
695 if not _SHA256.fullmatch(digest):
696 raise CliError(f"{context}.sha256 must be 64 lowercase hex characters")
697 source_id = _identifier(
698 asset["source_id"], context=f"{context}.source_id"
699 )
700 used_sources.add(source_id)
701 require_string(asset["license"], context=f"{context}.license", maximum=500)
702 require_string(
703 asset["provenance"],
704 context=f"{context}.provenance",
705 maximum=2_000,
706 )
707 require_string(
708 asset["alt_text"], context=f"{context}.alt_text", maximum=1_000
709 )
710 if not require_bool(
711 asset["author_approved"], context=f"{context}.author_approved"
712 ):
713 raise CliError(f"{context}.author_approved must be true")
714 qr_target = asset["qr_target"]
715 if role == "qr_code":
716 target = require_string(
717 qr_target, context=f"{context}.qr_target", maximum=2_000
718 )
719 authority = target[len("https://") :].split("/", 1)[0]
720 if (
721 not target.startswith("https://")
722 or not authority
723 or "@" in authority
724 or any(character.isspace() for character in target)
725 ):
726 raise CliError(
727 f"{context}.qr_target must be an absolute https:// URL "
728 "without credentials or whitespace"
729 )
730 elif qr_target is not None:
731 raise CliError(f"{context}.qr_target must be null for non-QR assets")
732 if verify_assets:
733 path = resolve_local_asset(
734 manifest_path,
735 path_value,
736 suffixes=ASSET_SUFFIXES,
737 )
738 actual_digest = sha256_file(path)
739 if actual_digest != digest:
740 raise CliError(
741 f"{context}.sha256 mismatch for {path_value!r}: "
742 f"expected {digest}, got {actual_digest}"
743 )
744 paths[asset_id] = path
745 assets[asset_id] = asset
746 return assets, paths, used_sources
749def _validate_box(
750 item: dict[str, Any],
751 *,
752 context: str,
753 canvas_width: float,
754 canvas_height: float,
755) -> tuple[float, float, float, float]:
756 x = finite_number(item["x_in"], context=f"{context}.x_in", minimum=0.0)
757 y = finite_number(item["y_in"], context=f"{context}.y_in", minimum=0.0)
758 width = finite_number(
759 item["width_in"], context=f"{context}.width_in", minimum=0.01
760 )
761 height = finite_number(
762 item["height_in"], context=f"{context}.height_in", minimum=0.01
763 )
764 if x + width > canvas_width + 1e-6 or y + height > canvas_height + 1e-6:
765 raise CliError(f"{context} extends beyond the PowerPoint canvas")
766 return x, y, width, height
769def _validate_elements(
770 value: Any,
771 *,
772 document: dict[str, Any],
773 canvas_width: float,
774 canvas_height: float,
775 print_scale: float,
776 bleed: float,
777 safe_margin: float,
778 quality: dict[str, Any],
779 colors: dict[str, str],
780 contrast_pairs: dict[str, dict[str, Any]],
781 assets: dict[str, dict[str, Any]],
782) -> tuple[dict[str, dict[str, Any]], set[str], set[str]]:
783 if not isinstance(value, list) or not 1 <= len(value) <= MAX_ELEMENTS:
784 raise CliError(f"elements must contain between 1 and {MAX_ELEMENTS} objects")
785 elements: dict[str, dict[str, Any]] = {}
786 used_sources: set[str] = set()
787 used_assets: set[str] = set()
788 reading_orders: list[int] = []
789 title_matches = 0
790 safe_inset_design = (bleed + safe_margin) / print_scale
792 common = {
793 "id",
794 "type",
795 "reading_order",
796 "x_in",
797 "y_in",
798 "width_in",
799 "height_in",
800 "source_ids",
801 "author_approved",
802 "allow_in_bleed",
803 }
804 text_keys = common | {
805 "role",
806 "text",
807 "font_size_pt_design",
808 "font_face",
809 "bold",
810 "align",
811 "vertical_align",
812 "contrast_pair_id",
813 "line_color_id",
814 "line_width_pt",
815 "margin_in",
816 }
817 image_keys = common | {
818 "asset_id",
819 "fit",
820 "fallback_text_element_id",
821 "long_description_element_id",
822 }
824 for index, item in enumerate(value):
825 context = f"elements[{index}]"
826 if not isinstance(item, dict):
827 raise CliError(f"{context} must be an object")
828 element_type = require_string(
829 item.get("type"), context=f"{context}.type", maximum=16
830 )
831 allowed = text_keys if element_type == "text" else image_keys
832 if element_type not in {"text", "image"}:
833 raise CliError(f"{context}.type must be text or image")
834 element = reject_unknown_keys(
835 item,
836 context=context,
837 allowed=allowed,
838 required=allowed,
839 )
840 element_id = _identifier(element["id"], context=f"{context}.id")
841 if element_id in elements:
842 raise CliError(f"duplicate element id: {element_id}")
843 order = positive_int(
844 element["reading_order"],
845 context=f"{context}.reading_order",
846 maximum=MAX_ELEMENTS,
847 )
848 reading_orders.append(order)
849 x, y, width, height = _validate_box(
850 element,
851 context=context,
852 canvas_width=canvas_width,
853 canvas_height=canvas_height,
854 )
855 source_ids = _source_id_list(
856 element["source_ids"], context=f"{context}.source_ids"
857 )
858 used_sources.update(source_ids)
859 if not require_bool(
860 element["author_approved"], context=f"{context}.author_approved"
861 ):
862 raise CliError(f"{context}.author_approved must be true")
863 allow_in_bleed = require_bool(
864 element["allow_in_bleed"], context=f"{context}.allow_in_bleed"
865 )
866 if not allow_in_bleed:
867 if (
868 x < safe_inset_design - 1e-6
869 or y < safe_inset_design - 1e-6
870 or x + width > canvas_width - safe_inset_design + 1e-6
871 or y + height > canvas_height - safe_inset_design + 1e-6
872 ):
873 raise CliError(
874 f"{context} crosses the confirmed final safe margin; "
875 "only intentional bleed imagery may set allow_in_bleed=true"
876 )
878 if element_type == "text":
879 if allow_in_bleed:
880 raise CliError(f"{context}: text must not be placed in the bleed area")
881 role = require_string(
882 element["role"], context=f"{context}.role", maximum=32
883 )
884 if role not in _TEXT_ROLES:
885 raise CliError(
886 f"{context}.role must be one of {', '.join(sorted(_TEXT_ROLES))}"
887 )
888 text = require_string(
889 element["text"], context=f"{context}.text", maximum=20_000
890 )
891 if role == "title" and text == document["title"]:
892 title_matches += 1
893 design_font = finite_number(
894 element["font_size_pt_design"],
895 context=f"{context}.font_size_pt_design",
896 minimum=1.0,
897 maximum=500.0,
898 )
899 final_font = design_font * print_scale
900 minimum_font = float(quality["minimum_font_pt_final"])
901 if final_font + 1e-6 < minimum_font:
902 raise CliError(
903 f"{context} final font is {final_font:.2f} pt, below the "
904 f"manifest's {minimum_font:.2f} pt minimum"
905 )
906 require_string(
907 element["font_face"], context=f"{context}.font_face", maximum=100
908 )
909 bold = require_bool(element["bold"], context=f"{context}.bold")
910 align = require_string(
911 element["align"], context=f"{context}.align", maximum=16
912 )
913 if align not in {"left", "center", "right"}:
914 raise CliError(f"{context}.align must be left, center, or right")
915 vertical = require_string(
916 element["vertical_align"],
917 context=f"{context}.vertical_align",
918 maximum=16,
919 )
920 if vertical not in {"top", "middle", "bottom"}:
921 raise CliError(
922 f"{context}.vertical_align must be top, middle, or bottom"
923 )
924 pair_id = _identifier(
925 element["contrast_pair_id"],
926 context=f"{context}.contrast_pair_id",
927 )
928 if pair_id not in contrast_pairs:
929 raise CliError(f"{context} references unknown contrast pair {pair_id!r}")
930 pair = contrast_pairs[pair_id]
931 usage = pair["usage"]
932 if usage == "non_text":
933 raise CliError(f"{context} must use a text contrast pair")
934 if usage == "large_text":
935 is_large = final_font >= 18.0 or (bold and final_font >= 14.0)
936 if not is_large:
937 raise CliError(
938 f"{context} uses a large_text contrast pair but is not "
939 "at least 18 pt final, or 14 pt final and bold"
940 )
941 line_color_id = element["line_color_id"]
942 if line_color_id is not None:
943 line_id = _identifier(
944 line_color_id, context=f"{context}.line_color_id"
945 )
946 if line_id not in colors:
947 raise CliError(f"{context} references unknown line color {line_id!r}")
948 line_width = finite_number(
949 element["line_width_pt"],
950 context=f"{context}.line_width_pt",
951 minimum=0.0,
952 maximum=72.0,
953 )
954 if line_color_id is None and line_width != 0:
955 raise CliError(
956 f"{context}.line_width_pt must be 0 when line_color_id is null"
957 )
958 margin = finite_number(
959 element["margin_in"],
960 context=f"{context}.margin_in",
961 minimum=0.0,
962 maximum=5.0,
963 )
964 if 2 * margin >= min(width, height):
965 raise CliError(f"{context}.margin_in consumes the text box")
966 else:
967 asset_id = _identifier(
968 element["asset_id"], context=f"{context}.asset_id"
969 )
970 if asset_id not in assets:
971 raise CliError(f"{context} references unknown asset {asset_id!r}")
972 used_assets.add(asset_id)
973 if assets[asset_id]["source_id"] not in source_ids:
974 raise CliError(
975 f"{context}.source_ids must include the asset's exact source_id"
976 )
977 fit = require_string(element["fit"], context=f"{context}.fit", maximum=16)
978 if fit != "contain":
979 raise CliError(f"{context}.fit must be contain")
980 fallback_id = element["fallback_text_element_id"]
981 long_description_id = element["long_description_element_id"]
982 if long_description_id is not None:
983 _identifier(
984 long_description_id,
985 context=f"{context}.long_description_element_id",
986 )
987 if assets[asset_id]["role"] == "qr_code":
988 _identifier(
989 fallback_id,
990 context=f"{context}.fallback_text_element_id",
991 )
992 if abs(width - height) > 1e-6:
993 raise CliError(f"{context}: QR placement box must be square")
994 if long_description_id is not None:
995 raise CliError(
996 f"{context}.long_description_element_id must be null for "
997 "QR assets; use the required visible fallback text"
998 )
999 elif fallback_id is not None:
1000 raise CliError(
1001 f"{context}.fallback_text_element_id must be null for non-QR assets"
1002 )
1003 elements[element_id] = element
1005 expected_order = list(range(1, len(value) + 1))
1006 if sorted(reading_orders) != expected_order:
1007 raise CliError(
1008 "elements.reading_order values must be unique and contiguous from 1"
1009 )
1010 if reading_orders != expected_order:
1011 raise CliError(
1012 "elements must be listed in ascending reading_order so generation "
1013 "does not silently reorder approved content"
1014 )
1015 if title_matches != 1:
1016 raise CliError(
1017 "exactly one title element must match document.title verbatim"
1018 )
1019 title = next(
1020 element
1021 for element in elements.values()
1022 if element["type"] == "text"
1023 and element["role"] == "title"
1024 and element["text"] == document["title"]
1025 )
1026 if title["reading_order"] != 1:
1027 raise CliError(
1028 "the exact title element must have reading_order 1 so it can be the "
1029 "native PowerPoint slide title"
1030 )
1032 for element_id, element in elements.items():
1033 if element["type"] != "image":
1034 continue
1035 asset = assets[element["asset_id"]]
1036 long_description_id = element["long_description_element_id"]
1037 if long_description_id is not None:
1038 description = elements.get(long_description_id)
1039 if description is None or description["type"] != "text":
1040 raise CliError(
1041 f"image {element_id!r} references missing text long description "
1042 f"{long_description_id!r}"
1043 )
1044 if description["role"] not in {"body", "caption", "other"}:
1045 raise CliError(
1046 f"long description {long_description_id!r} must have role "
1047 "'body', 'caption', or 'other'"
1048 )
1049 if description["reading_order"] <= element["reading_order"]:
1050 raise CliError(
1051 f"long description {long_description_id!r} must follow image "
1052 f"{element_id!r} in reading order"
1053 )
1054 if not set(element["source_ids"]).issubset(description["source_ids"]):
1055 raise CliError(
1056 f"long description {long_description_id!r} must include every "
1057 f"source_id used by image {element_id!r}"
1058 )
1059 if asset["role"] != "qr_code":
1060 continue
1061 fallback_id = element["fallback_text_element_id"]
1062 fallback = elements.get(fallback_id)
1063 if fallback is None or fallback["type"] != "text":
1064 raise CliError(
1065 f"QR image {element_id!r} references missing text fallback "
1066 f"{fallback_id!r}"
1067 )
1068 if fallback["role"] != "qr_fallback":
1069 raise CliError(
1070 f"QR fallback {fallback_id!r} must have role 'qr_fallback'"
1071 )
1072 if asset["qr_target"] not in fallback["text"]:
1073 raise CliError(
1074 f"QR fallback {fallback_id!r} must contain the exact QR target URL"
1075 )
1076 return elements, used_sources, used_assets
1079def _validate_approval(
1080 value: Any,
1081 *,
1082 content_hash: str,
1083 require_approval: bool,
1084) -> dict[str, Any]:
1085 approval = reject_unknown_keys(
1086 value,
1087 context="approval",
1088 allowed={"status", "approved_by", "approved_at", "content_sha256"},
1089 required={"status", "approved_by", "approved_at", "content_sha256"},
1090 )
1091 status = require_string(approval["status"], context="approval.status", maximum=16)
1092 if status not in {"draft", "approved"}:
1093 raise CliError("approval.status must be draft or approved")
1094 if require_approval and status != "approved":
1095 raise CliError("approval.status must be approved")
1096 if status == "approved":
1097 require_string(
1098 approval["approved_by"],
1099 context="approval.approved_by",
1100 maximum=200,
1101 )
1102 parse_aware_datetime(approval["approved_at"], context="approval.approved_at")
1103 digest = require_string(
1104 approval["content_sha256"],
1105 context="approval.content_sha256",
1106 maximum=64,
1107 )
1108 if not _SHA256.fullmatch(digest):
1109 raise CliError(
1110 "approval.content_sha256 must be 64 lowercase hex characters"
1111 )
1112 if digest != content_hash:
1113 raise CliError(
1114 "approval.content_sha256 does not match the current manifest content; "
1115 "author approval must be renewed after every content change"
1116 )
1117 else:
1118 for key in ("approved_by", "approved_at", "content_sha256"):
1119 if approval[key] is not None:
1120 raise CliError(f"approval.{key} must be null while status is draft")
1121 return approval
1124def validate_manifest_document(
1125 document: Any,
1126 *,
1127 manifest_path: Path,
1128 verify_assets: bool = True,
1129 require_approval: bool = True,
1130 enforce_contrast: bool = True,
1131) -> dict[str, Any]:
1132 """Validate a parsed manifest and return a non-content audit report."""
1133 manifest = reject_unknown_keys(
1134 document,
1135 context="manifest",
1136 allowed=_TOP_KEYS,
1137 required=_TOP_KEYS,
1138 )
1139 if manifest["schema_version"] != MANIFEST_SCHEMA_VERSION:
1140 raise CliError(
1141 f"schema_version must be {MANIFEST_SCHEMA_VERSION!r}, got "
1142 f"{manifest['schema_version']!r}"
1143 )
1144 placeholder = has_placeholder(manifest)
1145 if placeholder is not None:
1146 path, text = placeholder
1147 raise CliError(f"placeholder content is forbidden at {path}: {text!r}")
1149 document_record = _validate_document(manifest["document"])
1150 _, canvas_width, canvas_height = _validate_canvas(manifest["canvas"])
1151 (
1152 _,
1153 trim_width,
1154 trim_height,
1155 bleed,
1156 safe_margin,
1157 artboard_width,
1158 artboard_height,
1159 ) = _validate_physical_output(manifest["physical_output"])
1160 scale_x = artboard_width / canvas_width
1161 scale_y = artboard_height / canvas_height
1162 if abs(scale_x - scale_y) > max(1e-6, scale_x * 0.001):
1163 raise CliError(
1164 "PowerPoint canvas and final artboard have different aspect ratios; "
1165 "nonuniform print scaling is forbidden"
1166 )
1167 print_scale = (scale_x + scale_y) / 2.0
1169 sources = _validate_sources(manifest["sources"])
1170 requirements, requirement_sources = _validate_requirements(
1171 manifest["requirements"],
1172 trim_width=trim_width,
1173 trim_height=trim_height,
1174 bleed=bleed,
1175 safe_margin=safe_margin,
1176 print_scale=print_scale,
1177 )
1178 quality, quality_sources = _validate_quality(manifest["quality"])
1179 _, colors, contrast_pairs = _validate_palette(
1180 manifest["palette"],
1181 enforce_thresholds=enforce_contrast,
1182 )
1183 assets, asset_paths, asset_sources = _validate_assets(
1184 manifest["assets"],
1185 manifest_path=manifest_path,
1186 verify_assets=verify_assets,
1187 )
1188 elements, element_sources, used_assets = _validate_elements(
1189 manifest["elements"],
1190 document=document_record,
1191 canvas_width=canvas_width,
1192 canvas_height=canvas_height,
1193 print_scale=print_scale,
1194 bleed=bleed,
1195 safe_margin=safe_margin,
1196 quality=quality,
1197 colors=colors,
1198 contrast_pairs=contrast_pairs,
1199 assets=assets,
1200 )
1201 if set(assets) != used_assets:
1202 unused = sorted(set(assets) - used_assets)
1203 raise CliError(f"unused assets are forbidden: {', '.join(unused)}")
1205 used_sources = (
1206 requirement_sources
1207 | quality_sources
1208 | asset_sources
1209 | element_sources
1210 | set(document_record["source_ids"])
1211 )
1212 unknown_sources = sorted(used_sources - set(sources))
1213 if unknown_sources:
1214 raise CliError(
1215 f"unknown exact source id(s): {', '.join(unknown_sources)}"
1216 )
1217 expected_source_kinds = {
1218 requirements["conference"]["source_id"]: "conference_rule",
1219 requirements["printer"]["source_id"]: "printer_rule",
1220 }
1221 for source_id, expected_kind in expected_source_kinds.items():
1222 if sources[source_id]["kind"] != expected_kind:
1223 raise CliError(
1224 f"source {source_id!r} must have kind {expected_kind!r} for its "
1225 "declared requirement"
1226 )
1227 for prefix, expected_kind in (
1228 ("font_guidance", "conference_rule"),
1229 ("raster_dpi", "conference_rule"),
1230 ):
1231 basis = quality[f"{prefix}_basis"]
1232 source_id = quality[f"{prefix}_source_id"]
1233 if basis == "conference_requirement" and sources[source_id]["kind"] != expected_kind:
1234 raise CliError(
1235 f"quality.{prefix}_source_id must reference a conference_rule"
1236 )
1237 if (
1238 basis == "printer_requirement"
1239 and sources[source_id]["kind"] != "printer_rule"
1240 ):
1241 raise CliError(
1242 f"quality.{prefix}_source_id must reference a printer_rule"
1243 )
1244 unused_sources = sorted(set(sources) - used_sources)
1245 if unused_sources:
1246 raise CliError(f"unused source records are forbidden: {', '.join(unused_sources)}")
1248 content_hash = manifest_content_hash(manifest)
1249 approval = _validate_approval(
1250 manifest["approval"],
1251 content_hash=content_hash,
1252 require_approval=require_approval,
1253 )
1254 return {
1255 "schema_version": REPORT_SCHEMA_VERSION,
1256 "valid": True,
1257 "manifest_path": str(manifest_path),
1258 "manifest_schema_version": MANIFEST_SCHEMA_VERSION,
1259 "content_sha256": content_hash,
1260 "approval_status": approval["status"],
1261 "assets_verified": verify_assets,
1262 "asset_paths": {key: str(value) for key, value in asset_paths.items()},
1263 "counts": {
1264 "sources": len(sources),
1265 "assets": len(assets),
1266 "elements": len(elements),
1267 "text_elements": sum(
1268 element["type"] == "text" for element in elements.values()
1269 ),
1270 "image_elements": sum(
1271 element["type"] == "image" for element in elements.values()
1272 ),
1273 },
1274 "canvas": {
1275 "width_in": canvas_width,
1276 "height_in": canvas_height,
1277 },
1278 "physical_output": {
1279 "trim_width_in": trim_width,
1280 "trim_height_in": trim_height,
1281 "bleed_in": bleed,
1282 "safe_margin_in": safe_margin,
1283 "artboard_width_in": artboard_width,
1284 "artboard_height_in": artboard_height,
1285 "print_scale": print_scale,
1286 "print_scale_percent": print_scale * 100.0,
1287 },
1288 "quality_basis": {
1289 "minimum_font_pt_final": float(quality["minimum_font_pt_final"]),
1290 "font_guidance_basis": quality["font_guidance_basis"],
1291 "minimum_raster_dpi_final": float(
1292 quality["minimum_raster_dpi_final"]
1293 ),
1294 "raster_dpi_basis": quality["raster_dpi_basis"],
1295 },
1296 "delivery": {
1297 "conference_format": requirements["conference"][
1298 "required_delivery_format"
1299 ],
1300 "printer_color_mode": requirements["printer"][
1301 "accepted_color_mode"
1302 ],
1303 "printer_scaling_allowed": requirements["printer"]["scaling_allowed"],
1304 },
1305 }
1308def load_and_validate_manifest(
1309 value: str | Path,
1310 *,
1311 verify_assets: bool = True,
1312 require_approval: bool = True,
1313 enforce_contrast: bool = True,
1314) -> tuple[Path, dict[str, Any], dict[str, Any]]:
1315 """Load strict JSON and validate the complete poster manifest."""
1316 path, document = load_json_file(value)
1317 report = validate_manifest_document(
1318 document,
1319 manifest_path=path,
1320 verify_assets=verify_assets,
1321 require_approval=require_approval,
1322 enforce_contrast=enforce_contrast,
1323 )
1324 return path, document, report