From 6f1c7367e05d2611175c89d7cbf3dcb85a45aed9 Mon Sep 17 00:00:00 2001 From: kyeongmin Date: Fri, 22 May 2026 21:54:38 +0900 Subject: [PATCH] feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests) --- Front/client/src/components/SlideCanvas.tsx | 298 +++++++++++++ .../components/slideCanvasDragMath.test.ts | 118 ++++++ .../src/components/slideCanvasDragMath.ts | 137 ++++++ Front/client/src/pages/Home.tsx | 34 ++ Front/client/src/services/userOverridesApi.ts | 22 + Front/client/src/types/designAgent.ts | 7 + Front/client/src/utils/slidePlanUtils.ts | 46 +- .../tests/user_overrides_endpoint.test.ts | 91 +++- .../tests/user_overrides_restore.test.ts | 160 +++++++ .../tests/user_overrides_service.test.ts | 78 +++- Front/vite.config.ts | 13 +- src/image_id_stamper.py | 264 ++++++++++++ src/phase_z2_pipeline.py | 134 ++++++ src/user_overrides_io.py | 30 +- tests/test_image_id_stamper.py | 400 ++++++++++++++++++ tests/test_phase_z2_cli_overrides.py | 348 +++++++++++++++ tests/test_user_overrides_io.py | 47 +- .../test_user_overrides_pipeline_fallback.py | 116 ++++- 18 files changed, 2311 insertions(+), 32 deletions(-) create mode 100644 src/image_id_stamper.py create mode 100644 tests/test_image_id_stamper.py create mode 100644 tests/test_phase_z2_cli_overrides.py diff --git a/Front/client/src/components/SlideCanvas.tsx b/Front/client/src/components/SlideCanvas.tsx index 833e1d5..dff1f14 100644 --- a/Front/client/src/components/SlideCanvas.tsx +++ b/Front/client/src/components/SlideCanvas.tsx @@ -22,9 +22,13 @@ import type { NormalizedContent, } from "../types/designAgent"; import { + IMAGE_RESIZE_MIN_SIZE_PERCENT, + clampImagePercentGeometry, clampZoneMove, crossedDragThreshold, + type ImageDragDirection, } from "./slideCanvasDragMath"; +import type { ImageOverridesOverride } from "../services/userOverridesApi"; interface SlideCanvasProps { slidePlan: SlidePlan | null; @@ -55,6 +59,24 @@ interface SlideCanvasProps { onZoneResize?: ( geometries: Record ) => void; + /** IMP-51 (#79) u8 — persisted slide-absolute image geometries + * (image_id → {x,y,w,h} as percent of 1280×720, range 0–100). Mirrors + * the u3 typed-client `ImageOverride` contract and the u7 stamper that + * emits CSS `left/top/width/height: {value}%`. Forward-compat optional; + * u11 wires this from `userSelection.overrides.image_overrides`. When + * present, SlideCanvas displays the persisted geometry instead of the + * iframe-measured baseline. */ + imageOverrides?: ImageOverridesOverride; + /** IMP-51 (#79) u8 — emitted when the user drags or resizes a stamped + * user-content image. Geometry is slide-absolute percent (0–100 of + * 1280×720), matching the persisted axis schema (u3 typed client) and + * the u7 CSS injection that writes the values directly into + * `left/top/width/height: {value}%`. u10 wires this to a persistence + * handler that updates `image_overrides` on user_overrides.json. */ + onImageResize?: ( + imageId: string, + geometry: { x: number; y: number; w: number; h: number } + ) => void; } const SLIDE_W = 1280; @@ -74,6 +96,8 @@ export default function SlideCanvas({ onSlideClick, onSectionDrop, onZoneResize, + imageOverrides, + onImageResize, }: SlideCanvasProps) { const containerRef = useRef(null); const [scale, setScale] = useState(1); @@ -95,6 +119,24 @@ export default function SlideCanvas({ // Step B : section drag-drop drop target. 사용자가 LeftMdxPanel 의 section 카드 // 를 drag 해서 zone 에 drop 시 그 zone 에 section 할당. dragOver 시 강조 표시. const [dragOverZoneId, setDragOverZoneId] = useState(null); + // IMP-51 (#79) u8 — measured user-content image bboxes inside iframe + // (slide-absolute percent of 1280×720, range 0–100). key = data-image-id + // stamped by u4 (`src/image_id_stamper.py`). Populated in the iframe + // onLoad measure block alongside measuredZones / measuredSlideBody. + // Units intentionally match the persisted `image_overrides` axis (u3 + // typed client) and the u7 CSS injection so the overlay math has a + // single coord space across measured/persisted/emitted values. Used as + // the baseline geometry when no persisted override exists for that id; + // `imageOverrides` prop (u11-fed) wins when present. + const [measuredImages, setMeasuredImages] = useState< + Record + >({}); + // IMP-51 (#79) u8 — currently selected user-content image id (= the one + // whose drag/resize handles are shown). Set by the click-listener + // installed inside the iframe contentDocument when edit mode is active. + // Reset on finalHtmlUrl change and on edit-mode exit so stale ids never + // leak across runs. + const [selectedImageId, setSelectedImageId] = useState(null); // HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용. // 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업. // pendingLayout 과 배타적 (충돌 방지). @@ -122,6 +164,10 @@ export default function SlideCanvas({ const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"]; let inputHandler: ((e: Event) => void) | null = null; + // IMP-51 (#79) u8 — user-content image click listeners installed + // inside the iframe contentDocument. Tracked here so the cleanup + // callback can remove them when edit mode exits (or iframe reloads). + const imageClickBindings: Array<{ el: HTMLImageElement; handler: (e: Event) => void; prevCursor: string; prevOutline: string }> = []; if (isEditMode) { doc.designMode = "on"; doc.querySelectorAll(".slide *").forEach((el) => { @@ -134,17 +180,49 @@ export default function SlideCanvas({ onContentEdit?.(); }; doc.addEventListener("input", inputHandler); + + // IMP-51 (#79) u8 — wire click → selectedImageId on every stamped + // user-content image. Selector mirrors USER_CONTENT_IMAGE_SELECTOR + // in src/image_id_stamper.py (+ requires data-image-id which the + // stamper always emits). Decorative / frame imgs lacking the role + // attribute are intentionally NOT clickable here. + const imgEls = doc.querySelectorAll( + '.slide img[data-image-role="user-content"][data-image-id]' + ); + imgEls.forEach((imgEl) => { + const imgId = imgEl.dataset.imageId; + if (!imgId) return; + const handler = (ev: Event) => { + ev.stopPropagation(); + ev.preventDefault(); + setSelectedImageId(imgId); + }; + const prevCursor = imgEl.style.cursor; + const prevOutline = imgEl.style.outline; + imgEl.style.cursor = "pointer"; + imgEl.style.outline = "1px dashed rgba(16, 185, 129, 0.55)"; + imgEl.addEventListener("click", handler); + imageClickBindings.push({ el: imgEl, handler, prevCursor, prevOutline }); + }); } else { doc.designMode = "off"; doc.querySelectorAll("[contenteditable]").forEach((el) => { (el as HTMLElement).removeAttribute("contenteditable"); }); + // edit-mode exit also clears stale image selection so the handle + // overlay never lingers on a non-editable iframe. + setSelectedImageId(null); } return () => { if (inputHandler && doc) { doc.removeEventListener("input", inputHandler); } + imageClickBindings.forEach(({ el, handler, prevCursor, prevOutline }) => { + el.removeEventListener("click", handler); + el.style.cursor = prevCursor; + el.style.outline = prevOutline; + }); }; }, [isEditMode, finalHtmlUrl, onContentEdit]); @@ -158,6 +236,10 @@ export default function SlideCanvas({ useEffect(() => { setMeasuredZones({}); setMeasuredSlideBody(null); + // IMP-51 (#79) u8 — image measurements + selection are per-render; + // drop both so the new iframe's onLoad starts clean. + setMeasuredImages({}); + setSelectedImageId(null); }, [finalHtmlUrl]); // 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X). @@ -355,6 +437,33 @@ export default function SlideCanvas({ h: r.height / SLIDE_H, }); } + + // ── IMP-51 (#79) u8 — user-content image bbox 측정 ── + // u4 stamper 가 부착한 data-image-id 가 있는 img 만 잡음 + // (decorative / frame img 제외). 측정 결과는 1280×720 기준 + // 슬라이드-절대 percent (0–100) — image_overrides axis (u3 + // 타입 + u7 CSS `left/top/width/height: {value}%` 주입) 와 + // 동일한 좌표계라서 측정 / 영구 저장 / emit 가 1:1 매칭됨. + const imageEls = doc.querySelectorAll( + '.slide img[data-image-role="user-content"][data-image-id]' + ); + const measuredImg: Record< + string, + { x: number; y: number; w: number; h: number } + > = {}; + imageEls.forEach((imgEl) => { + const id = imgEl.dataset.imageId; + if (!id) return; + const r = imgEl.getBoundingClientRect(); + if (r.width <= 0 || r.height <= 0) return; + measuredImg[id] = { + x: (r.left / SLIDE_W) * 100, + y: (r.top / SLIDE_H) * 100, + w: (r.width / SLIDE_W) * 100, + h: (r.height / SLIDE_H) * 100, + }; + }); + setMeasuredImages(measuredImg); } catch (err) { console.warn("[SlideCanvas] iframe inject/measure 실패:", err); } @@ -891,6 +1000,195 @@ export default function SlideCanvas({ ); })} + + {/* ── IMP-51 (#79) u8 — user-content image edit overlay ── + Activates only in edit mode when an image_id appears in either + `imageOverrides` (u11-fed persisted axis) or `measuredImages` + (iframe-measured baseline). pendingLayout suppresses the image + overlay so zone editing and image editing never compete for the + same pointer events. + + For every stamped user-content image we render a transparent + wrapper at the image's slide-absolute coords. Wrapper picks up + the body-drag gesture (move the image without resizing). When + the image is the `selectedImageId` we additionally render 8 + resize handles. Aspect ratio is LOCKED on corner drags by + default; holding Shift during the drag unlocks it (matches the + issue contract "corner_resize_ratio_default_locked_shift_unlock"). + + Coordinate space: slide-absolute percent (0–100) throughout — + measured / persisted / emitted values share the same units as + the u7 CSS injector (`left/top/width/height: {value}%`) and the + u3 typed-client `ImageOverride` contract. CSS values are + written verbatim ({geom.x}%, no scale factor) and pixel deltas + from MouseEvent are converted to percent via + `(dx_px / W_SCALED) * 100` so the round-trip drag → save → + re-render produces identical geometry. IMP-51 (#79) u9 moved + the resize / move math to `clampImagePercentGeometry` in + `slideCanvasDragMath.ts` so the boundary contract Codex #16 + verified is exercised directly by vitest (mirror of how IMP-54 + u3 split the zone math out of SlideCanvas). */} + {!isPendingLayout && isEditMode && finalHtmlUrl && onImageResize && + Object.entries({ ...measuredImages, ...(imageOverrides ?? {}) }).map( + ([imageId]) => { + const persisted = imageOverrides?.[imageId]; + const measured = measuredImages[imageId]; + // override 우선; 없으면 measured baseline. 둘 다 없으면 skip. + const geom = persisted ?? measured; + if (!geom) return null; + const isSelected = selectedImageId === imageId; + + const beginDrag = ( + ev: React.MouseEvent, + direction: ImageDragDirection + ) => { + ev.preventDefault(); + ev.stopPropagation(); + setSelectedImageId(imageId); + const startMouseX = ev.clientX; + const startMouseY = ev.clientY; + const startGeom = { ...geom }; + + // 2026-05-22 demo hot-fix parity — iframe 이 마우스 가로 + // 채서 mouseup leak 일어남 (편집 모드에서 pe=auto). + const iframeEl = iframeRef.current; + const prevIframePE = iframeEl ? iframeEl.style.pointerEvents : ""; + if (iframeEl) iframeEl.style.pointerEvents = "none"; + + const isCorner = + direction === "nw" || + direction === "ne" || + direction === "sw" || + direction === "se"; + + const onMove = (mv: MouseEvent) => { + // Convert pixel delta on the on-screen scaled slide + // back into percent-of-slide so all downstream math + // shares the persisted axis's coord space. W_SCALED / + // H_SCALED already include the wrapper scale factor, + // so dividing then multiplying by 100 gives a stable + // value regardless of viewport zoom. + const dx = ((mv.clientX - startMouseX) / W_SCALED) * 100; + const dy = ((mv.clientY - startMouseY) / H_SCALED) * 100; + // IMP-51 (#79) u9 — boundary contract lives in the + // pure helper so vitest can verify it directly. + // Aspect lock is default on for corner handles and + // released when Shift is held. + const aspectLocked = isCorner && !mv.shiftKey; + const next = clampImagePercentGeometry( + startGeom, + dx, + dy, + direction, + aspectLocked, + IMAGE_RESIZE_MIN_SIZE_PERCENT, + ); + onImageResize(imageId, next); + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + if (iframeEl) iframeEl.style.pointerEvents = prevIframePE; + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + }; + + return ( +
beginDrag(ev, "move")} + className={`absolute z-30 ${ + isSelected + ? "border-2 border-emerald-500 bg-emerald-500/5 shadow-[0_0_0_2px_rgba(16,185,129,0.25)]" + : "border border-dashed border-emerald-400/60 hover:border-emerald-500" + } cursor-grab active:cursor-grabbing`} + style={{ + left: `${geom.x}%`, + top: `${geom.y}%`, + width: `${geom.w}%`, + height: `${geom.h}%`, + pointerEvents: "auto", + }} + title={ + isSelected + ? "이미지 이동 — 드래그 / 모서리 핸들 = 크기 (Shift = 비율 해제)" + : "클릭하여 선택" + } + > + + IMG + + + {isSelected && ( + <> + {/* edges */} +
beginDrag(ev, "top")} + onClick={(ev) => ev.stopPropagation()} + className="absolute -top-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow" + style={{ pointerEvents: "auto" }} + title="상단" + /> +
beginDrag(ev, "bottom")} + onClick={(ev) => ev.stopPropagation()} + className="absolute -bottom-1 left-1/4 w-1/2 h-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ns-resize z-40 transition shadow" + style={{ pointerEvents: "auto" }} + title="하단" + /> +
beginDrag(ev, "left")} + onClick={(ev) => ev.stopPropagation()} + className="absolute top-1/4 -left-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow" + style={{ pointerEvents: "auto" }} + title="좌측" + /> +
beginDrag(ev, "right")} + onClick={(ev) => ev.stopPropagation()} + className="absolute top-1/4 -right-1 h-1/2 w-2 bg-emerald-500/70 hover:bg-emerald-500 rounded cursor-ew-resize z-40 transition shadow" + style={{ pointerEvents: "auto" }} + title="우측" + /> + {/* corners — aspect locked by default, Shift unlocks */} +
beginDrag(ev, "nw")} + onClick={(ev) => ev.stopPropagation()} + className="absolute -top-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow" + style={{ pointerEvents: "auto" }} + title="좌상단 (Shift = 비율 해제)" + /> +
beginDrag(ev, "ne")} + onClick={(ev) => ev.stopPropagation()} + className="absolute -top-1 -right-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow" + style={{ pointerEvents: "auto" }} + title="우상단 (Shift = 비율 해제)" + /> +
beginDrag(ev, "sw")} + onClick={(ev) => ev.stopPropagation()} + className="absolute -bottom-1 -left-1 w-3 h-3 bg-white border-2 border-emerald-500 rounded-sm cursor-nesw-resize z-40 hover:scale-125 transition shadow" + style={{ pointerEvents: "auto" }} + title="좌하단 (Shift = 비율 해제)" + /> +
beginDrag(ev, "se")} + onClick={(ev) => ev.stopPropagation()} + className="absolute -bottom-1 -right-1 w-4 h-4 bg-white border-2 border-emerald-500 rounded-sm cursor-nwse-resize z-40 hover:scale-125 transition shadow" + style={{ pointerEvents: "auto" }} + title="우하단 (Shift = 비율 해제)" + /> + + )} +
+ ); + } + )}
)}
diff --git a/Front/client/src/components/slideCanvasDragMath.test.ts b/Front/client/src/components/slideCanvasDragMath.test.ts index e0437d7..e5215ba 100644 --- a/Front/client/src/components/slideCanvasDragMath.test.ts +++ b/Front/client/src/components/slideCanvasDragMath.test.ts @@ -13,8 +13,11 @@ import { describe, expect, it } from "vitest"; import { DRAG_THRESHOLD_PX, + IMAGE_RESIZE_MIN_SIZE_PERCENT, + clampImagePercentGeometry, clampZoneMove, crossedDragThreshold, + type ImagePercentGeom, type ZoneFracGeom, } from "./slideCanvasDragMath"; @@ -105,3 +108,118 @@ describe("clampZoneMove", () => { expect("h" in out).toBe(false); }); }); + +// IMP-51 (#79) u9 — image overlay resize / move math. +// Boundary contract (must match the inline u8 math Codex #16 verified): +// • slide-bound invariant — x+w ≤ 100 ∧ y+h ≤ 100 for ALL valid inputs, +// including small-near-edge geoms where the existing minSize floor +// would otherwise have pushed past the slide bound. +// • aspect-locked corner — baseAspect = startGeom.w / startGeom.h is +// preserved exactly; the wFloor uses `min(minSize, maxW, maxH*baseAspect)` +// so a floor application never violates either axis. +// The two concrete Codex #15 reproductions are encoded explicitly below +// so a future regression on the boundary math fails this suite directly. +describe("IMAGE_RESIZE_MIN_SIZE_PERCENT", () => { + it("is 2 (percent of slide bbox)", () => { + expect(IMAGE_RESIZE_MIN_SIZE_PERCENT).toBe(2); + }); +}); + +describe("clampImagePercentGeometry", () => { + const baseGeom: ImagePercentGeom = { x: 10, y: 10, w: 20, h: 10 }; + + describe("direction = 'move'", () => { + it("translates and clamps both axes; preserves w/h", () => { + expect( + clampImagePercentGeometry(baseGeom, 5, 7, "move", false), + ).toEqual({ x: 15, y: 17, w: 20, h: 10 }); + }); + + it("clamps negative deltas to (0, 0)", () => { + expect( + clampImagePercentGeometry(baseGeom, -1000, -1000, "move", false), + ).toEqual({ x: 0, y: 0, w: 20, h: 10 }); + }); + + it("clamps max-edge deltas to (100 - w, 100 - h)", () => { + expect( + clampImagePercentGeometry(baseGeom, 1000, 1000, "move", false), + ).toEqual({ x: 80, y: 90, w: 20, h: 10 }); + }); + }); + + describe("edge resize — independent per-axis clamp", () => { + it("right edge clamps width to 100 - startGeom.x", () => { + const out = clampImagePercentGeometry(baseGeom, 1000, 0, "right", false); + expect(out).toEqual({ x: 10, y: 10, w: 90, h: 10 }); + expect(out.x + out.w).toBeLessThanOrEqual(100); + }); + + it("left drag dx=-100 emits {x:0,y:10,w:30,h:10} (Codex regression)", () => { + // From Codex #15 / #16 verification — ordinary left drag past the + // slide edge should pin x at 0 and grow w by the original x amount. + expect( + clampImagePercentGeometry(baseGeom, -100, 0, "left", false), + ).toEqual({ x: 0, y: 10, w: 30, h: 10 }); + }); + + it("near-edge right resize keeps x + w ≤ 100 (Codex #15 reproduction)", () => { + // Pre-fix: minSize=2 floor applied AFTER span clamp would emit + // {x:99, w:2} so x+w=101. Post-fix: floor caps at maxW=1. + const start: ImagePercentGeom = { x: 99, y: 10, w: 0.5, h: 10 }; + const out = clampImagePercentGeometry(start, 1, 0, "right", false); + expect(out).toEqual({ x: 99, y: 10, w: 1, h: 10 }); + expect(out.x + out.w).toBe(100); + }); + + it("top/bottom edges are symmetric to left/right", () => { + const bottom = clampImagePercentGeometry(baseGeom, 0, 1000, "bottom", false); + expect(bottom).toEqual({ x: 10, y: 10, w: 20, h: 90 }); + const top = clampImagePercentGeometry(baseGeom, 0, -100, "top", false); + expect(top).toEqual({ x: 10, y: 0, w: 20, h: 20 }); + }); + }); + + describe("corner resize — aspect locked (default Shift-off)", () => { + it("NW drag dx=-100,dy=-100 emits {x:0,y:5,w:30,h:15} (Codex regression)", () => { + // From Codex #16 verification — aspect-locked NW past the slide + // edge: rightEdge=30, bottomEdge=20, baseAspect=2. Independent + // clamps give x=0,w=30,y=0,h=20. Aspect block then picks the + // limiting axis: newH = 30/2 = 15 (≤20). Re-anchor: y = 20 - 15 = 5. + expect( + clampImagePercentGeometry(baseGeom, -100, -100, "nw", true), + ).toEqual({ x: 0, y: 5, w: 30, h: 15 }); + }); + + it("tiny near-corner NE resize stays within bounds (Codex #15 reproduction)", () => { + // Pre-fix: dual-axis minSize floor would emit w=2, h=2 with + // re-anchor pushing x+w past 100. Post-fix: wFloor caps at + // min(2, maxW=1, maxH*baseAspect=1) = 1, so newW=1, newH=1. + const start: ImagePercentGeom = { x: 99, y: 99, w: 0.5, h: 0.5 }; + const out = clampImagePercentGeometry(start, 1, -1, "ne", true); + expect(out).toEqual({ x: 99, y: 98.5, w: 1, h: 1 }); + expect(out.x + out.w).toBeLessThanOrEqual(100); + expect(out.y + out.h).toBeLessThanOrEqual(100); + }); + + it("preserves baseAspect exactly when the floor is hit", () => { + // 2:1 aspect ratio (w=20, h=10); large negative drag past edges + // hits wFloor. newW/newH ratio must equal baseAspect. + const out = clampImagePercentGeometry( + baseGeom, -1000, -1000, "nw", true, + ); + expect(out.w / out.h).toBeCloseTo(baseGeom.w / baseGeom.h, 10); + }); + }); + + describe("corner resize — Shift unlock (independent edges)", () => { + it("SE without aspect lock degenerates to right + bottom edges", () => { + const corner = clampImagePercentGeometry(baseGeom, 1000, 1000, "se", false); + const sides = clampImagePercentGeometry( + clampImagePercentGeometry(baseGeom, 1000, 0, "right", false), + 0, 1000, "bottom", false, + ); + expect(corner).toEqual(sides); + }); + }); +}); diff --git a/Front/client/src/components/slideCanvasDragMath.ts b/Front/client/src/components/slideCanvasDragMath.ts index 0423127..eabe50c 100644 --- a/Front/client/src/components/slideCanvasDragMath.ts +++ b/Front/client/src/components/slideCanvasDragMath.ts @@ -25,6 +25,143 @@ export const DRAG_THRESHOLD_PX = 5; +// IMP-51 (#79) u9 — image overlay resize / move math extracted from +// SlideCanvas.tsx `beginDrag` onMove (lines 1092–1219 of the u8 patch). +// Slide-absolute percent coordinate space (0–100 on both axes), matching +// the persisted `image_overrides` axis (`src/user_overrides_io.py` u1 +// KNOWN_AXES) and the typed client `ImageOverride` shape (`userOverridesApi.ts` +// u3). The math is the contract Codex #16 verified post-u8 — this file +// is a relocation, not a behavior change. SlideCanvas calls it from a +// single hook so future tweaks need to update one place + the vitest +// suite alongside. +export const IMAGE_RESIZE_MIN_SIZE_PERCENT = 2; + +/** Image overlay geometry in slide-absolute percent (each component ∈ [0, 100]). + * Mirrors `ImageOverride` from `services/userOverridesApi.ts` (u3) so this + * shape moves end-to-end through stamper → overlay → persisted axis. */ +export interface ImagePercentGeom { + x: number; + y: number; + w: number; + h: number; +} + +export type ImageDragDirection = + | "move" + | "left" + | "right" + | "top" + | "bottom" + | "nw" + | "ne" + | "sw" + | "se"; + +/** Apply a percent-space drag delta to `startGeom` per `direction` and clamp. + * + * Contract (must match the inline u8 math Codex #16 verified): + * • `direction === "move"` → translate only; w/h preserved verbatim; + * x/y clamped to `[0, 100 - w]` and `[0, 100 - h]`. + * • Edge handle (`left|right|top|bottom`) → one axis only; opposite + * edge pinned so x+w ≤ 100 and y+h ≤ 100 hold. + * • Corner handle (`nw|ne|sw|se`) with `aspectLocked=false` → two + * independent edges (same per-edge clamp as above). + * • Corner handle with `aspectLocked=true` → preserves + * `baseAspect = startGeom.w / startGeom.h`; the pinned-opposite-corner + * stays fixed; the floored axis is `w` and `h` is re-derived so the + * aspect ratio is exact even at the minSize floor. + * + * `minSize` is best-effort: when the available span (e.g. `100 - startGeom.x` + * for `affectsRight`) is below `minSize`, the floor caps at the span itself + * so the slide-bound invariant (x+w ≤ 100 ∧ y+h ≤ 100) is never violated. + * Pure / deterministic / no DOM access — vitest drives it directly. */ +export function clampImagePercentGeometry( + startGeom: ImagePercentGeom, + dxPercent: number, + dyPercent: number, + direction: ImageDragDirection, + aspectLocked: boolean, + minSize: number = IMAGE_RESIZE_MIN_SIZE_PERCENT, +): ImagePercentGeom { + if (direction === "move") { + const x = Math.max(0, Math.min(100 - startGeom.w, startGeom.x + dxPercent)); + const y = Math.max(0, Math.min(100 - startGeom.h, startGeom.y + dyPercent)); + return { x, y, w: startGeom.w, h: startGeom.h }; + } + + const affectsLeft = + direction === "left" || direction === "nw" || direction === "sw"; + const affectsRight = + direction === "right" || direction === "ne" || direction === "se"; + const affectsTop = + direction === "top" || direction === "nw" || direction === "ne"; + const affectsBottom = + direction === "bottom" || direction === "sw" || direction === "se"; + const isCorner = + direction === "nw" || + direction === "ne" || + direction === "sw" || + direction === "se"; + + const rightEdge = startGeom.x + startGeom.w; + const bottomEdge = startGeom.y + startGeom.h; + let x = startGeom.x; + let y = startGeom.y; + let w = startGeom.w; + let h = startGeom.h; + + if (affectsRight) { + const maxW = 100 - startGeom.x; + const floor = Math.min(minSize, maxW); + w = Math.max(floor, Math.min(maxW, startGeom.w + dxPercent)); + } + if (affectsBottom) { + const maxH = 100 - startGeom.y; + const floor = Math.min(minSize, maxH); + h = Math.max(floor, Math.min(maxH, startGeom.h + dyPercent)); + } + if (affectsLeft) { + const floor = Math.min(minSize, rightEdge); + x = Math.max(0, Math.min(rightEdge - floor, startGeom.x + dxPercent)); + w = rightEdge - x; + } + if (affectsTop) { + const floor = Math.min(minSize, bottomEdge); + y = Math.max(0, Math.min(bottomEdge - floor, startGeom.y + dyPercent)); + h = bottomEdge - y; + } + + if (isCorner && aspectLocked) { + const baseAspect = + startGeom.w > 0 && startGeom.h > 0 ? startGeom.w / startGeom.h : 1; + if (baseAspect > 0) { + const maxW = affectsLeft ? rightEdge : 100 - startGeom.x; + const maxH = affectsTop ? bottomEdge : 100 - startGeom.y; + let newW = w; + let newH = newW / baseAspect; + if (newH > maxH) { + newH = maxH; + newW = newH * baseAspect; + } + if (newW > maxW) { + newW = maxW; + newH = newW / baseAspect; + } + const wFloor = Math.min(minSize, maxW, maxH * baseAspect); + if (newW < wFloor) { + newW = wFloor; + newH = newW / baseAspect; + } + w = newW; + h = newH; + x = affectsLeft ? rightEdge - w : startGeom.x; + y = affectsTop ? bottomEdge - h : startGeom.y; + } + } + + return { x, y, w, h }; +} + /** Returns true once the pointer has travelled far enough from the mousedown * origin to be treated as a drag rather than a click. */ export function crossedDragThreshold(dxPx: number, dyPx: number): boolean { diff --git a/Front/client/src/pages/Home.tsx b/Front/client/src/pages/Home.tsx index b56fff5..2c158e5 100644 --- a/Front/client/src/pages/Home.tsx +++ b/Front/client/src/pages/Home.tsx @@ -15,6 +15,7 @@ import { getSelectedRegion, moveSectionToZone, saveZoneSizes, + saveImageOverride, deriveUserOverridesKey, applyPersistedNonFrameOverrides, remapPersistedFramesToZoneFrames, @@ -540,6 +541,37 @@ export default function Home() { setHasPendingChanges(true); }, []); + // IMP-51 (#79) u10 — wire SlideCanvas's user-content image drag/resize + // emit into the 5th persisted axis. Mirrors handleZoneResize exactly: + // • merge the single (imageId → {x,y,w,h}) tick onto the prior + // in-memory `image_overrides` map via the u11 `saveImageOverride` + // helper so the immutable update path is shared with the test suite, + // • forward the full merged snapshot through `saveUserOverrides` + // (the u3 typed client) under the `image_overrides` key — the 300ms + // debounce defined alongside `zone_geometries` collapses the + // per-mousemove emits into one PUT at gesture-end, + // • flip `hasPendingChanges` so the "선택대로 재생성하기" CTA appears. + // Coordinates are slide-absolute percent (0–100) from u8/u9 — passed + // through unchanged so the on-disk schema matches the SlideCanvas + // overlay, the stamper selector (u4), and the render-time CSS + // injector (u7) without any per-zone transform. + const handleImageResize = useCallback( + (imageId: string, geometry: { x: number; y: number; w: number; h: number }) => { + setState((p) => { + const nextSelection = saveImageOverride(p.userSelection, imageId, geometry); + if (p.uploadedFile) { + const key = deriveUserOverridesKey(p.uploadedFile.name); + void saveUserOverrides(key, { + image_overrides: nextSelection.overrides.image_overrides, + }); + } + return { ...p, userSelection: nextSelection }; + }); + setHasPendingChanges(true); + }, + [], + ); + // 편집 모드 텍스트 변경 시 hasPendingChanges 활성. useCallback 으로 reference 안정화 — // SlideCanvas 의 useEffect 가 매번 rerun 안 하도록 (resize drag 매 mousemove 마다 // re-render 시 useEffect retrigger → iframe contentEditable 재설정 = 매우 느림). @@ -745,6 +777,8 @@ export default function Home() { onSectionDrop={handleSectionDrop} onLayoutResize={handleLayoutResize} onZoneResize={handleZoneResize} + imageOverrides={state.userSelection.overrides.image_overrides} + onImageResize={handleImageResize} /> diff --git a/Front/client/src/services/userOverridesApi.ts b/Front/client/src/services/userOverridesApi.ts index 347f1f1..1c122f1 100644 --- a/Front/client/src/services/userOverridesApi.ts +++ b/Front/client/src/services/userOverridesApi.ts @@ -7,6 +7,12 @@ // the four mutation handlers (u7). It does NOT own the schema — any change // to KNOWN_AXES must land in u1/u4 first, then reflect here. // +// IMP-51 (#79) u3 — added `image_overrides` (5th axis). `image_id` → percent- +// of-slide {x,y,w,h}. Mirrors src/user_overrides_io.py KNOWN_AXES (u1) and +// Front/vite.config.ts KNOWN_USER_OVERRIDES_AXES (u2). Backend stamper + +// render-time CSS injection ride on u4~u7; the SlideCanvas drag/resize +// handles that drive this axis ride on u8~u11. +// // Contract (Stage 2 unit u5 summary): // • Typed `getUserOverrides(key)` → returns `Partial` from // the GET endpoint. Missing / corrupt / non-object payloads degrade to @@ -44,12 +50,28 @@ export type ZoneGeometriesOverride = Record; /** zone_id → ordered list of section_ids assigned to that zone. */ export type ZoneSectionsOverride = Record; +/** + * IMP-51 #79 u3 — image_id → percent-of-slide geometry. Matches the user- + * content image selector `.slide img[data-image-role="user-content"]` + * (stamper in u4) and the render-time CSS injection map (u7). Coordinates + * are slide-absolute percent (0–100) so SlideCanvas drag handles (u8~u11) + * map 1:1 with the persisted axis without per-zone transforms. + */ +export type ImageOverride = { + x: number; + y: number; + w: number; + h: number; +}; +export type ImageOverridesOverride = Record; + /** Full on-disk schema. All axes optional — file may carry any subset. */ export interface UserOverrides { layout: string; frames: FramesOverride; zone_geometries: ZoneGeometriesOverride; zone_sections: ZoneSectionsOverride; + image_overrides: ImageOverridesOverride; } /** Partial-mutation payload. `null` is the explicit clear sentinel (mirrors u4). */ diff --git a/Front/client/src/types/designAgent.ts b/Front/client/src/types/designAgent.ts index baf6831..91e42fc 100644 --- a/Front/client/src/types/designAgent.ts +++ b/Front/client/src/types/designAgent.ts @@ -206,6 +206,13 @@ export interface UserSelection { zone_sections: Record; // zoneId -> sectionIds[] zone_sizes: Record; // layoutGroupId -> [size1, size2, ...] zone_geometries: Record; // zone_id -> geometry + // IMP-51 (#79) u11 — image_id → slide-absolute percent geometry (0–100 + // on each axis). image_id is stamped by `src/image_id_stamper.py` (u4) + // on user-content `` tags; the same key is consumed by the u7 CSS + // injector and the SlideCanvas u8 overlay. Shape mirrors the on-disk + // `image_overrides` axis (KNOWN_AXES, src/user_overrides_io.py u1) and + // the typed-client `ImageOverridesOverride` (services/userOverridesApi.ts u3). + image_overrides: Record; }; } diff --git a/Front/client/src/utils/slidePlanUtils.ts b/Front/client/src/utils/slidePlanUtils.ts index aa88e53..79ec225 100644 --- a/Front/client/src/utils/slidePlanUtils.ts +++ b/Front/client/src/utils/slidePlanUtils.ts @@ -72,6 +72,18 @@ export function applyPersistedNonFrameOverrides( ) { next.zone_sections = { ...persisted.zone_sections }; } + // IMP-51 (#79) u11 — layer the 5th persisted axis (`image_overrides`) by + // the same array / non-object guard the zone_geometries branch uses. The + // u3 typed client (services/userOverridesApi.ts) shape and the on-disk + // KNOWN_AXES entry (src/user_overrides_io.py u1) are both flat dicts + // (image_id → {x,y,w,h} percent-of-slide), so a shallow copy is enough. + if ( + persisted.image_overrides && + typeof persisted.image_overrides === "object" && + !Array.isArray(persisted.image_overrides) + ) { + next.image_overrides = { ...persisted.image_overrides }; + } return { ...selection, overrides: next }; } @@ -143,13 +155,17 @@ export function createInitialUserSelection(slidePlan?: SlidePlan | null): UserSe zone_sections: initialSections, zone_sizes: {}, zone_geometries: {}, + // IMP-51 (#79) u11 — image_overrides axis starts empty; entries land + // here via `saveImageOverride` (SlideCanvas drag/resize handler) and + // are seeded on reopen via `applyPersistedNonFrameOverrides`. + image_overrides: {}, }, }; } export function saveZoneGeometry( - selection: UserSelection, - zoneId: string, + selection: UserSelection, + zoneId: string, geometry: { x: number; y: number; w: number; h: number } ): UserSelection { return { @@ -164,6 +180,32 @@ export function saveZoneGeometry( }; } +/** + * IMP-51 (#79) u11 — record a single `image_id` → slide-absolute percent + * geometry on the in-memory selection. Mirrors `saveZoneGeometry` but on + * the 5th persisted axis (`image_overrides`); the SlideCanvas drag/resize + * handler (u8) emits one entry per pointer move, and u10's Home wiring + * funnels each emit through this helper before scheduling the debounced + * PUT. Pure / immutable — returns a fresh `UserSelection`; the input is + * never mutated. Existing entries for the same `imageId` are replaced. + */ +export function saveImageOverride( + selection: UserSelection, + imageId: string, + geometry: { x: number; y: number; w: number; h: number }, +): UserSelection { + return { + ...selection, + overrides: { + ...selection.overrides, + image_overrides: { + ...selection.overrides.image_overrides, + [imageId]: geometry, + }, + }, + }; +} + export function saveZoneSizes(selection: UserSelection, groupId: string, sizes: number[]): UserSelection { return { ...selection, diff --git a/Front/client/tests/user_overrides_endpoint.test.ts b/Front/client/tests/user_overrides_endpoint.test.ts index 9ea47b7..ff34d15 100644 --- a/Front/client/tests/user_overrides_endpoint.test.ts +++ b/Front/client/tests/user_overrides_endpoint.test.ts @@ -315,6 +315,7 @@ describe("KNOWN_USER_OVERRIDES_AXES (IMP-52 u4)", () => { "zone_geometries", "zone_sections", "frames", + "image_overrides", ]); }); }); @@ -338,16 +339,19 @@ describe("mergeUserOverrides (IMP-52 u4)", () => { }); it("preserves foreign top-level keys in existing", () => { - // Forward-compat: future axes (zone_sizes, image_overrides, etc.) on - // disk must survive PUT writes that only touch the 4 in-scope axes. + // Forward-compat: future axes (zone_sizes, schema_version, etc.) on + // disk must survive PUT writes that only touch the 5 in-scope axes. + // `image_overrides` is no longer a foreign key after IMP-51 #79 u2 — + // it joined KNOWN_USER_OVERRIDES_AXES — so we probe with axes that + // are still NOT in the allowlist. const existing = { layout: "old", zone_sizes: { top: 0.42 }, - image_overrides: { img1: { x: 0.1 } }, + schema_version: 2, }; const merged = mergeUserOverrides(existing, { layout: "new" }); expect(merged.zone_sizes).toEqual({ top: 0.42 }); - expect(merged.image_overrides).toEqual({ img1: { x: 0.1 } }); + expect(merged.schema_version).toBe(2); }); it("clears axis when partial value is null (explicit clear)", () => { @@ -359,7 +363,7 @@ describe("mergeUserOverrides (IMP-52 u4)", () => { it("drops non-axis keys in partial (allowlist)", () => { // PUT payload may carry junk fields (typo, malicious key); allowlist - // ensures only the 4 axes can be written to disk. + // ensures only the 5 axes can be written to disk. const merged = mergeUserOverrides( {}, { layout: "x", random_key: "evil", __proto__: "x" } as Record< @@ -371,7 +375,7 @@ describe("mergeUserOverrides (IMP-52 u4)", () => { expect("random_key" in merged).toBe(false); }); - it("merges all 4 axes when present in partial", () => { + it("merges all 5 axes when present in partial", () => { const merged = mergeUserOverrides( {}, { @@ -379,16 +383,47 @@ describe("mergeUserOverrides (IMP-52 u4)", () => { frames: { "03-1+03-2": "frame_07" }, zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, zone_sections: { top: ["03-1", "03-2"] }, + image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } }, }, ); expect(Object.keys(merged).sort()).toEqual([ "frames", + "image_overrides", "layout", "zone_geometries", "zone_sections", ]); }); + it("preserves image_overrides when absent from partial (5th axis IMP-51 #79 u2)", () => { + // Sibling axis of layout/frames/zone_geometries/zone_sections: a PUT + // that touches only layout must NOT erase the image_overrides map + // already on disk. Mirrors the partial-merge invariant for the 4 + // pre-existing axes. + const existing = { + layout: "old", + image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } }, + }; + const merged = mergeUserOverrides(existing, { layout: "new" }); + expect(merged.image_overrides).toEqual({ + "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 }, + }); + expect(merged.layout).toBe("new"); + }); + + it("clears image_overrides when partial value is null (explicit clear)", () => { + // Same null-sentinel contract as the 4 sibling axes — `null` removes + // the axis from disk so the next render reverts to baseline (no + // user image position/size override). + const existing = { + layout: "x", + image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } }, + }; + const merged = mergeUserOverrides(existing, { image_overrides: null }); + expect("image_overrides" in merged).toBe(false); + expect(merged.layout).toBe("x"); + }); + it("does not mutate the existing input", () => { const existing = { layout: "old", frames: { a: "b" } }; const snapshot = JSON.parse(JSON.stringify(existing)); @@ -572,13 +607,15 @@ describe("handlePutUserOverrides (IMP-52 u4)", () => { }); it("preserves foreign top-level keys on disk (forward-compat)", () => { + // `image_overrides` is no longer a foreign key after IMP-51 #79 u2; + // probe with axes that are still NOT in KNOWN_USER_OVERRIDES_AXES. fs.mkdirSync(overridesDir, { recursive: true }); fs.writeFileSync( path.join(overridesDir, "future.json"), JSON.stringify({ layout: "old", zone_sizes: { top: 0.42 }, - image_overrides: { img1: { x: 0.1 } }, + schema_version: 2, }), "utf-8", ); @@ -592,10 +629,48 @@ describe("handlePutUserOverrides (IMP-52 u4)", () => { fs.readFileSync(path.join(overridesDir, "future.json"), "utf-8"), ); expect(onDisk.zone_sizes).toEqual({ top: 0.42 }); - expect(onDisk.image_overrides).toEqual({ img1: { x: 0.1 } }); + expect(onDisk.schema_version).toBe(2); expect(onDisk.layout).toBe("new"); }); + it("persists image_overrides partial-merge and preserves sibling axes (IMP-51 #79 u2)", () => { + // 5th axis end-to-end PUT round-trip: writing only image_overrides + // must NOT touch the 4 sibling axes already on disk. Mirrors the + // existing partial-merge test for layout above. + fs.mkdirSync(overridesDir, { recursive: true }); + fs.writeFileSync( + path.join(overridesDir, "03.json"), + JSON.stringify({ + layout: "two_zone_split", + frames: { "03-1": "frame_01" }, + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, + zone_sections: { top: ["03-1"] }, + }), + "utf-8", + ); + + const req = makeMockReq({ method: "PUT", url: "/03" }); + const { res, state } = makeMockRes(); + expect(handlePutUserOverrides(req, res, tmpRoot)).toBe(true); + req.send( + JSON.stringify({ + image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } }, + }), + ); + + expect(state.statusCode).toBe(200); + const onDisk = JSON.parse( + fs.readFileSync(path.join(overridesDir, "03.json"), "utf-8"), + ); + expect(onDisk).toEqual({ + layout: "two_zone_split", + frames: { "03-1": "frame_01" }, + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, + zone_sections: { top: ["03-1"] }, + image_overrides: { "img-1": { x: 0.1, y: 0.2, w: 0.3, h: 0.25 } }, + }); + }); + it("drops non-axis payload keys (allowlist enforced at write)", () => { fs.mkdirSync(overridesDir, { recursive: true }); const req = makeMockReq({ method: "PUT", url: "/03" }); diff --git a/Front/client/tests/user_overrides_restore.test.ts b/Front/client/tests/user_overrides_restore.test.ts index cdeef10..cb3f9ea 100644 --- a/Front/client/tests/user_overrides_restore.test.ts +++ b/Front/client/tests/user_overrides_restore.test.ts @@ -31,8 +31,10 @@ import type { } from "../src/types/designAgent"; import { applyPersistedNonFrameOverrides, + createInitialUserSelection, deriveUserOverridesKey, remapPersistedFramesToZoneFrames, + saveImageOverride, } from "../src/utils/slidePlanUtils"; // ─── Fixtures ─────────────────────────────────────────────────────────────── @@ -48,6 +50,10 @@ function makeSelection(overrides?: Partial): UserSel zone_sections: {}, zone_sizes: {}, zone_geometries: {}, + // IMP-51 (#79) u11 — keep the fixture in sync with the 5th persisted + // axis declared on `UserSelection.overrides`. Empty by default so the + // existing IMP-52 cases remain unchanged in shape. + image_overrides: {}, ...overrides, }, }; @@ -300,3 +306,157 @@ describe("remapPersistedFramesToZoneFrames (IMP-52 u6)", () => { expect(remapped["r-top"]).toBe("user-chosen-tpl"); }); }); + +// ─── IMP-51 (#79) u11 — image_overrides axis ──────────────────────────────── +// New 5th persisted axis. The on-disk schema (KNOWN_AXES, +// src/user_overrides_io.py u1), the typed client +// (services/userOverridesApi.ts u3 ImageOverridesOverride), the Vite +// allowlist (vite.config.ts u2), and the backend CLI flag (--override-image +// in src/phase_z2_pipeline.py u5) all expect `image_id` → percent-of-slide +// geometry. u11 owns the in-memory mirror on `UserSelection.overrides` +// (declared in types/designAgent.ts) plus the three pure helpers that +// Home.tsx (u10) wires: +// • applyPersistedNonFrameOverrides — restore-on-reopen layer. +// • createInitialUserSelection — fresh-slide initializer. +// • saveImageOverride — single-image record helper invoked by the +// SlideCanvas u8 drag/resize handler. + +describe("image_overrides axis — applyPersistedNonFrameOverrides (IMP-51 u11)", () => { + it("layers a flat image_overrides dict onto the selection", () => { + const sel = makeSelection(); + const persisted = { + image_overrides: { + "img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 }, + "img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 }, + }, + }; + const next = applyPersistedNonFrameOverrides(sel, persisted); + expect(next.overrides.image_overrides).toEqual({ + "img-abc1234567": { x: 10, y: 15, w: 30.5, h: 25 }, + "img-deadbeef00": { x: 50, y: 50, w: 40, h: 40 }, + }); + // Untouched axes stay at their fixture defaults so the round-trip is + // safe to interleave with the other four axes. + expect(next.overrides.zone_geometries).toEqual({}); + expect(next.overrides.zone_sections).toEqual({}); + expect(next.overrides.layout_preset).toBeUndefined(); + }); + + it("ignores image_overrides when the payload axis is an array", () => { + const sel = makeSelection({ + image_overrides: { "img-existing00": { x: 1, y: 2, w: 30, h: 40 } }, + }); + const next = applyPersistedNonFrameOverrides(sel, { + image_overrides: [] as unknown as Record< + string, + { x: number; y: number; w: number; h: number } + >, + }); + // Same guard the zone_geometries branch uses — array payloads from a + // hand-edited file are rejected and the prior in-memory value stays. + expect(next.overrides.image_overrides).toEqual({ + "img-existing00": { x: 1, y: 2, w: 30, h: 40 }, + }); + }); + + it("ignores image_overrides when the payload axis is null", () => { + const sel = makeSelection({ + image_overrides: { "img-existing00": { x: 0, y: 0, w: 100, h: 100 } }, + }); + const next = applyPersistedNonFrameOverrides(sel, { + image_overrides: null, + }); + expect(next.overrides.image_overrides).toEqual({ + "img-existing00": { x: 0, y: 0, w: 100, h: 100 }, + }); + }); + + it("layers image_overrides alongside the four IMP-52 axes in one call", () => { + const sel = makeSelection(); + const next = applyPersistedNonFrameOverrides(sel, { + layout: "horizontal-2", + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.4 } }, + zone_sections: { top: ["03-1"] }, + image_overrides: { "img-abc1234567": { x: 25, y: 25, w: 50, h: 50 } }, + }); + expect(next.overrides.layout_preset).toBe("horizontal-2"); + expect(next.overrides.zone_geometries).toEqual({ + top: { x: 0, y: 0, w: 1, h: 0.4 }, + }); + expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] }); + expect(next.overrides.image_overrides).toEqual({ + "img-abc1234567": { x: 25, y: 25, w: 50, h: 50 }, + }); + }); + + it("seeds an empty image_overrides on a fresh selection (createInitialUserSelection)", () => { + const sel = createInitialUserSelection(); + expect(sel.overrides.image_overrides).toEqual({}); + // Mirrors the shape Home.tsx receives before any user interaction — + // SlideCanvas u8 expects the axis to exist (not undefined) so its + // `Object.entries(measured + persisted)` merge never crashes. + }); +}); + +describe("image_overrides axis — saveImageOverride (IMP-51 u11)", () => { + const ID_A = "img-abc1234567"; + const ID_B = "img-deadbeef00"; + + it("adds a new image_id entry on an empty axis", () => { + const sel = makeSelection(); + const next = saveImageOverride(sel, ID_A, { x: 10, y: 15, w: 30.5, h: 25 }); + expect(next.overrides.image_overrides).toEqual({ + [ID_A]: { x: 10, y: 15, w: 30.5, h: 25 }, + }); + }); + + it("replaces an existing entry under the same image_id (most recent drag wins)", () => { + const sel = makeSelection({ + image_overrides: { [ID_A]: { x: 0, y: 0, w: 20, h: 20 } }, + }); + const next = saveImageOverride(sel, ID_A, { x: 50, y: 50, w: 30, h: 30 }); + expect(next.overrides.image_overrides).toEqual({ + [ID_A]: { x: 50, y: 50, w: 30, h: 30 }, + }); + }); + + it("preserves sibling image_id entries when adding a new one", () => { + const sel = makeSelection({ + image_overrides: { [ID_A]: { x: 10, y: 10, w: 20, h: 20 } }, + }); + const next = saveImageOverride(sel, ID_B, { x: 60, y: 60, w: 30, h: 30 }); + expect(next.overrides.image_overrides).toEqual({ + [ID_A]: { x: 10, y: 10, w: 20, h: 20 }, + [ID_B]: { x: 60, y: 60, w: 30, h: 30 }, + }); + }); + + it("does NOT touch the other four override axes", () => { + const sel = makeSelection({ + zone_geometries: { top: { x: 0, y: 0, w: 1, h: 0.5 } }, + zone_sections: { top: ["03-1"] }, + zone_frames: { "r-top": "tpl-a" }, + layout_preset: "horizontal-2", + }); + const next = saveImageOverride(sel, ID_A, { x: 10, y: 10, w: 20, h: 20 }); + expect(next.overrides.zone_geometries).toEqual({ + top: { x: 0, y: 0, w: 1, h: 0.5 }, + }); + expect(next.overrides.zone_sections).toEqual({ top: ["03-1"] }); + expect(next.overrides.zone_frames).toEqual({ "r-top": "tpl-a" }); + expect(next.overrides.layout_preset).toBe("horizontal-2"); + }); + + it("returns a NEW selection object (no input mutation)", () => { + const sel = makeSelection({ + image_overrides: { [ID_A]: { x: 0, y: 0, w: 10, h: 10 } }, + }); + const before = { ...sel.overrides.image_overrides }; + const next = saveImageOverride(sel, ID_B, { x: 30, y: 30, w: 20, h: 20 }); + expect(next).not.toBe(sel); + expect(next.overrides).not.toBe(sel.overrides); + expect(next.overrides.image_overrides).not.toBe(sel.overrides.image_overrides); + // Input still pristine. + expect(sel.overrides.image_overrides).toEqual(before); + }); +}); diff --git a/Front/client/tests/user_overrides_service.test.ts b/Front/client/tests/user_overrides_service.test.ts index 28c9c0e..d359daf 100644 --- a/Front/client/tests/user_overrides_service.test.ts +++ b/Front/client/tests/user_overrides_service.test.ts @@ -480,6 +480,82 @@ describe("UserOverridesPartial type (IMP-52 u5)", () => { const b: UserOverridesPartial = { layout: null }; const c: UserOverridesPartial = { frames: { unit: "tmpl" } }; const d: UserOverridesPartial = {}; - expect([a, b, c, d]).toHaveLength(4); + const e: UserOverridesPartial = { + image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } }, + }; + const f: UserOverridesPartial = { image_overrides: null }; + expect([a, b, c, d, e, f]).toHaveLength(6); + }); +}); + +// ============================================================================ +// IMP-51 #79 u3 — image_overrides axis (5th axis) parity coverage +// +// Same debounce / coalescing / clear / per-key isolation guarantees as the +// 4 sibling axes (layout / frames / zone_geometries / zone_sections), but +// asserted explicitly so a regression in the type or the runtime allowlist +// fails here instead of in a downstream u8~u11 handler. +// ============================================================================ + +describe("saveUserOverrides (IMP-51 #79 u3) — image_overrides axis", () => { + it("PUT body carries only image_overrides when that is the sole mutated axis", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { + image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } }, + }); + vi.advanceTimersByTime(300); + await drainMicrotasks(); + + const body = lastPutBody() as Record; + expect(Object.keys(body)).toEqual(["image_overrides"]); + expect(body.image_overrides).toEqual({ + "img-1": { x: 10, y: 20, w: 30, h: 25 }, + }); + expect("layout" in body).toBe(false); + expect("frames" in body).toBe(false); + expect("zone_geometries" in body).toBe(false); + expect("zone_sections" in body).toBe(false); + }); + + it("per-axis later-wins: same image_id mutated twice keeps the LAST value", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { + image_overrides: { "img-1": { x: 0, y: 0, w: 50, h: 50 } }, + }); + void saveUserOverrides("03", { + image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } }, + }); + + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(putCallsCount()).toBe(1); + expect(lastPutBody()).toEqual({ + image_overrides: { "img-1": { x: 25, y: 25, w: 30, h: 30 } }, + }); + }); + + it("forwards null sentinel verbatim (clear all image_overrides on disk)", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { image_overrides: null }); + + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(lastPutBody()).toEqual({ image_overrides: null }); + }); + + it("coalesces with sibling axes in a single PUT", async () => { + fetchMock.mockResolvedValue(mockResponse({})); + void saveUserOverrides("03", { layout: "two_zone_split" }); + void saveUserOverrides("03", { + image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } }, + }); + + vi.advanceTimersByTime(300); + await drainMicrotasks(); + expect(putCallsCount()).toBe(1); + expect(lastPutBody()).toEqual({ + layout: "two_zone_split", + image_overrides: { "img-1": { x: 10, y: 20, w: 30, h: 25 } }, + }); }); }); diff --git a/Front/vite.config.ts b/Front/vite.config.ts index 72a4854..fed65e0 100644 --- a/Front/vite.config.ts +++ b/Front/vite.config.ts @@ -219,20 +219,23 @@ function vitePluginStorageProxy(): Plugin { export const USER_OVERRIDES_KEY_RE = /^[A-Za-z0-9_][A-Za-z0-9_.\-]*$/; -// The four in-scope axes — exact mirror of KNOWN_AXES in +// The five in-scope axes — exact mirror of KNOWN_AXES in // src/user_overrides_io.py. Any payload key outside this allowlist is // silently dropped by the PUT handler (u4) so the on-disk schema cannot // drift from the backend pipeline (u2) contract. Foreign top-level keys // already on disk are preserved verbatim (see mergeUserOverrides). +// IMP-51 (#79) u2: added `image_overrides` (image_id → {x,y,w,h} +// percent-of-slide coordinates). export const KNOWN_USER_OVERRIDES_AXES = [ "layout", "zone_geometries", "zone_sections", "frames", + "image_overrides", ] as const; export type KnownUserOverridesAxis = (typeof KNOWN_USER_OVERRIDES_AXES)[number]; -// 1MB cap on PUT bodies. Override files in practice are < 10KB (4 axes, +// 1MB cap on PUT bodies. Override files in practice are < 10KB (5 axes, // each a small dict). The cap is a safety net against runaway client // loops, not a real schema constraint. const USER_OVERRIDES_PUT_MAX_BYTES = 1_000_000; @@ -319,9 +322,9 @@ export function handleGetUserOverrides( // IMP-52 u4 — pure merge function. Mirrors src/user_overrides_io.save(): // • Only KNOWN_USER_OVERRIDES_AXES present in `partial` are mutated. // • Axes absent from `partial` are preserved verbatim from `existing`. -// • Foreign top-level keys in `existing` (future axes like zone_sizes -// or image_overrides) are preserved verbatim — allowlist guards what -// the PUT writes, NOT what the file already holds. +// • Foreign top-level keys in `existing` (future axes like zone_sizes) +// are preserved verbatim — allowlist guards what the PUT writes, NOT +// what the file already holds. // • `partial[axis] = null` is the explicit clear sentinel (remove key). // • Any non-axis keys in `partial` are silently dropped (allowlist). export function mergeUserOverrides( diff --git a/src/image_id_stamper.py b/src/image_id_stamper.py new file mode 100644 index 0000000..7363c24 --- /dev/null +++ b/src/image_id_stamper.py @@ -0,0 +1,264 @@ +"""IMP-51 (#79) u4 — user-content image stamper for Phase Z final.html. + +Annotates user-content ```` elements with a stable id + role +attribute so the frontend SlideCanvas (u8~u11) can attach drag/resize +handles and the backend CSS injector (u7) can re-apply persisted geometry +on the next render. + +DOM selector contract (single point of truth shared across the axis) : + + .slide img[data-image-role="user-content"] + +This selector is mirrored verbatim in : + +- ``Front/client/src/components/SlideCanvas.tsx`` (u8 handle attach target) +- ``Front/client/src/services/userOverridesApi.ts`` (u3 doc reference) +- ``src/phase_z2_pipeline.py`` u7 hook (CSS injector — pending unit) + +Decorative imgs (frame backgrounds, figma assets, dx-figures, decorative +icons) are NOT stamped, so they are NOT matched by the selector and remain +unaffected. The allowlist that decides "what counts as user-content" is +passed in by the caller (typically ``stage0_normalized_assets["images"]``); +this module does not encode the source-of-truth itself. + +Stable id contract : + + image_id = "img-" + sha1(src)[:10] + +Deterministic across renders so persisted ``image_overrides`` entries +(keyed on ``image_id`` per ``src/user_overrides_io.py`` u1) re-apply +automatically. Duplicate srcs in the same slide get an ordinal suffix +("-1", "-2", ...) appended in DOM order; the first occurrence has no +suffix. + +Forward-compat : current Phase Z final.html emits zero user-content +```` elements (``stage0_normalized_assets["images"]`` is empty across +all recent verify runs). ``stamp_user_content_images(html, sources=())`` +is a pure no-op in that case — returns ``(html, [])`` without scanning. + +Guardrails : + +- No-hardcoding : the allowlist is caller-supplied, never inferred from + sample filenames or path heuristics. +- Idempotent : stamping a previously-stamped tag is a no-op (the + ``data-image-role`` probe short-circuits before re-injecting). +- AI-isolation : this module is pure deterministic Python; no LLM calls. +- Carve-out (IMP-46 #62) : brand-new module, does not touch the + #76 commit ``1186ad8`` cache region. +""" +from __future__ import annotations + +import hashlib +import re +from typing import Iterable + +USER_CONTENT_IMAGE_SELECTOR: str = '.slide img[data-image-role="user-content"]' + +IMAGE_ROLE_ATTR: str = "data-image-role" +IMAGE_ROLE_VALUE: str = "user-content" +IMAGE_ID_ATTR: str = "data-image-id" + +# Matches a single ```` tag. Permissive on attribute order and +# whitespace; captures the inner attribute string + an optional XHTML +# self-close slash. Phase Z renders well-formed Jinja2 output (no inline +# ``<`` in attribute values), so a regex is safe here without pulling in +# an HTML parser. +_IMG_TAG_RE = re.compile( + r"]*?)(/?)>", + flags=re.IGNORECASE | re.DOTALL, +) +# Matches the ``src="..."`` or ``src='...'`` attribute. Group 1 = double, +# group 2 = single. Quote style is preserved by callers that re-emit the +# tag verbatim. +_SRC_ATTR_RE = re.compile( + r"""\bsrc\s*=\s*(?:"([^"]*)"|'([^']*)')""", + flags=re.IGNORECASE, +) +# Probe for an existing ``data-image-role`` attribute (any value, any +# quote) so re-stamping is idempotent. +_ROLE_ATTR_RE = re.compile(r"""\bdata-image-role\s*=""", flags=re.IGNORECASE) + + +def stable_image_id(src: str, ordinal: int = 0) -> str: + """Return the deterministic ``image_id`` for ``src``. + + ``ordinal`` disambiguates repeated occurrences of the same ``src`` in + the same slide (0 = first occurrence, no suffix; 1 → ``-1``; ...). + """ + if not isinstance(src, str): + raise TypeError(f"src must be a string, got {type(src).__name__}: {src!r}") + if ordinal < 0: + raise ValueError(f"ordinal must be >= 0, got {ordinal}") + digest = hashlib.sha1(src.encode("utf-8")).hexdigest()[:10] + base = f"img-{digest}" + return base if ordinal == 0 else f"{base}-{ordinal}" + + +def stamp_user_content_images( + html: str, + sources: Iterable[str] = (), +) -> tuple[str, list[str]]: + """Stamp user-content ```` tags in ``html`` with role + stable id. + + ``sources`` is the allowlist of ``src`` attribute values that count as + user-content (typically ``stage0_normalized_assets["images"]``). Any + ```` whose ``src`` value is in ``sources`` is rewritten to include + ``data-image-role="user-content"`` and ``data-image-id=""``. + Other ```` tags (decorative, figma, frame-internal) are left + unchanged byte-for-byte. + + Returns ``(modified_html, stamped_image_ids)`` where the id list is + in DOM (left-to-right) order. The list may contain duplicates only + via the ordinal-suffix path (``img-``, ``img--1``, ...); + ordering is what the caller persists as the canonical key sequence. + + Forward-compat : empty / all-non-string ``sources`` → pure no-op + (``html`` returned unchanged, empty list). This is the current Phase + Z state since ``stage0_normalized_assets["images"]`` is empty. + """ + allow = {s for s in sources if isinstance(s, str) and s} + if not allow: + return html, [] + + stamped: list[str] = [] + seen_ordinal: dict[str, int] = {} + + def _replace(match: re.Match[str]) -> str: + attrs = match.group(1) or "" + self_close = match.group(2) or "" + src_match = _SRC_ATTR_RE.search(attrs) + if src_match is None: + return match.group(0) + src = src_match.group(1) if src_match.group(1) is not None else src_match.group(2) + if src not in allow: + return match.group(0) + if _ROLE_ATTR_RE.search(attrs): + return match.group(0) + ordinal = seen_ordinal.get(src, 0) + seen_ordinal[src] = ordinal + 1 + image_id = stable_image_id(src, ordinal=ordinal) + stamped.append(image_id) + injected = ( + f' {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"' + f' {IMAGE_ID_ATTR}="{image_id}"' + ) + return f"" + + new_html = _IMG_TAG_RE.sub(_replace, html) + return new_html, stamped + + +# ─── IMP-51 (#79) u7 — render-time CSS injection ────────────────────────── + +# Marker comments wrap the injected ``\n" + f"{_IMP51_STYLE_MARKER_CLOSE}" + ) + if _IMP51_STYLE_MARKER_OPEN in html: + return _IMP51_STYLE_BLOCK_RE.sub(lambda _m: block, html, count=1) + head_close = _HEAD_CLOSE_RE.search(html) + if head_close is not None: + idx = head_close.start() + return html[:idx] + block + "\n" + html[idx:] + body_open = _BODY_OPEN_RE.search(html) + if body_open is not None: + idx = body_open.end() + return html[:idx] + "\n" + block + html[idx:] + return block + "\n" + html diff --git a/src/phase_z2_pipeline.py b/src/phase_z2_pipeline.py index 2f8c7cc..d0afee3 100644 --- a/src/phase_z2_pipeline.py +++ b/src/phase_z2_pipeline.py @@ -3406,6 +3406,7 @@ def run_phase_z2_mvp1( override_frames: Optional[dict[str, str]] = None, override_zone_geometries: Optional[dict[str, dict]] = None, override_section_assignments: Optional[dict[str, list[str]]] = None, + override_image_overrides: Optional[dict[str, dict]] = None, ) -> Path: """MVP-1.5b entry — single slide + composition planner v0 + 8 preset vocabulary. @@ -3419,6 +3420,15 @@ def run_phase_z2_mvp1( 으로 강제. unit_id = "+".join(source_section_ids) (e.g., "03-1" 또는 "03-1+03-2"). 매칭 unit 의 v4_candidates 에 있는 entry 면 그 entry 의 score / label 도 함께 갱신. 없으면 template_id 만 변경. + override_image_overrides : {image_id: {x, y, w, h}} — IMP-51 (#79) u5 axis. + image_id = stable id stamped on user-content `` tags by + ``src/image_id_stamper.py`` (u4). x/y/w/h are percent-of-slide + coordinates (0–100, slide-absolute). Forward-compat kwarg: the + render-time CSS injection that consumes this mapping lands in + u7; until u7 wires the consumer, accepting the kwarg keeps the + backend contract (KNOWN_AXES u1 + Vite allowlist u2 + typed + client u3 + stamper u4) end-to-end addressable from CLI without + diverging the function signature. """ mdx_path = Path(mdx_path) if run_id is None: @@ -5375,6 +5385,36 @@ def run_phase_z2_mvp1( # 7. Render single slide html = render_slide(slide_title, slide_footer, zones_data, layout_preset, layout_css) + # IMP-51 (#79) u4 + u7 — stamp user-content imgs with stable id / + # role attrs, then inject persisted `image_overrides` CSS so the + # next render re-applies the user-edited geometry. + # + # Forward-compat: `stage0_normalized_assets["images"]` is empty in + # every current Phase Z run (Q1 = A confirmed at Stage 1), so the + # stamper returns an empty `stamped_image_ids` list and the CSS + # builder short-circuits to "". The HTML is therefore byte-for-byte + # identical to the pre-IMP-51 output until Phase Z starts emitting + # user-content imgs (separate axis, out of scope for #79). + from src.image_id_stamper import ( + build_image_overrides_style, + inject_image_overrides_style, + stamp_user_content_images, + ) + _user_content_image_srcs = [ + (entry.get("path") or entry.get("src") or "") + for entry in (stage0_normalized_assets.get("images") or []) + if isinstance(entry, dict) + ] + html, _stamped_image_ids = stamp_user_content_images( + html, sources=_user_content_image_srcs, + ) + if override_image_overrides: + _image_overrides_css = build_image_overrides_style( + override_image_overrides, _stamped_image_ids, + ) + if _image_overrides_css: + html = inject_image_overrides_style(html, _image_overrides_css) + # 8. Write final.html out_path = run_dir / "final.html" out_path.write_text(html, encoding="utf-8") @@ -5844,6 +5884,27 @@ if __name__ == "__main__": "--override-section-assignment bottom=03-2,03-3" ), ) + # IMP-51 (#79) u5 — image override CLI flag. IMAGE_ID = stable id stamped + # on user-content `` tags by src/image_id_stamper.py (u4). X,Y,W,H = + # percent-of-slide coordinates (0–100, slide-absolute), consistent with + # the typed client `ImageOverride` shape (u3, userOverridesApi.ts) and + # the persisted `image_overrides` axis (u1, KNOWN_AXES). The render-time + # CSS injection consuming this mapping lands in u7; u5 is the CLI surface. + parser.add_argument( + "--override-image", + dest="override_image_overrides", + action="append", + default=[], + metavar="IMAGE_ID=X,Y,W,H", + help=( + "user-content image 의 slide-absolute geometry 강제. IMAGE_ID = " + "src/image_id_stamper.py 가 stamp 한 `data-image-id` value " + "(e.g., img-1a2b3c4d5e). X,Y,W,H = percent-of-slide (0–100, " + "slide-absolute) — typed client ImageOverride shape 와 일치. " + "multiple flags: --override-image img-abc=10,15,30,25 " + "--override-image img-def=50,15,40,40" + ), + ) # IMP-46 u5 — auto-cache opt-in. When set, ``cache.save_proposal`` # bypasses the ``user_approved`` gate only (``visual_check_passed`` # is never bypassable). Source of truth is @@ -5943,6 +6004,54 @@ if __name__ == "__main__": _seen_sections_across_zones[sid] = zid overrides_section_assignments[zid] = section_ids + # IMP-51 (#79) u5 — parse --override-image into dict[str, dict[str, float]]. + # Mirrors --override-zone-geometry parsing pattern: each flag is + # IMAGE_ID=X,Y,W,H with 4 floats; multiple flags accumulate. Hard errors + # on missing `=` / wrong float count / non-numeric values / empty IMAGE_ID + # / duplicate IMAGE_ID. The on-disk schema (u1 KNOWN_AXES) and typed + # client (u3 ImageOverride) both expect percent-of-slide values in + # 0–100; the CLI accepts floats without range clamping here so the + # error remains the user's mistake to read rather than a silent shift. + overrides_images: dict[str, dict[str, float]] = {} + for ov in args.override_image_overrides: + if "=" not in ov: + print( + f"[error] --override-image must be IMAGE_ID=X,Y,W,H, got: '{ov}'", + file=sys.stderr, + ) + sys.exit(2) + iid, vals = ov.split("=", 1) + iid = iid.strip() + if not iid: + print( + f"[error] --override-image IMAGE_ID must be non-empty, got: '{ov}'", + file=sys.stderr, + ) + sys.exit(2) + if iid in overrides_images: + print( + f"[error] --override-image duplicate IMAGE_ID '{iid}' " + f"(first assignment kept). Provide each image only once.", + file=sys.stderr, + ) + sys.exit(2) + parts = vals.split(",") + if len(parts) != 4: + print( + f"[error] --override-image expects 4 floats X,Y,W,H, got: '{vals}'", + file=sys.stderr, + ) + sys.exit(2) + try: + x, y, w, h = (float(p) for p in parts) + except ValueError: + print( + f"[error] --override-image floats parse fail: '{vals}'", + file=sys.stderr, + ) + sys.exit(2) + overrides_images[iid] = {"x": x, "y": y, "w": w, "h": h} + # IMP-52 (#80) u2 — user_overrides.json persistence fallback. # After argparse fully parses CLI flags, fill ONLY the axes the user # did NOT pass on the command line. CLI payload always wins over the @@ -6013,6 +6122,30 @@ if __name__ == "__main__": if _sids: _accepted_sec[_zid] = _sids overrides_section_assignments = _accepted_sec + # image_overrides — CLI empty → fill from file (dict[str, dict]). + # IMP-51 (#79) u6 — mirrors zone_geometries validation: only accept + # mappings of {image_id: {x,y,w,h}} with float-coercible values. + if not overrides_images: + _file_images = _persisted.get("image_overrides") + if isinstance(_file_images, dict): + _accepted_img: dict[str, dict] = {} + for _iid, _g in _file_images.items(): + if ( + isinstance(_iid, str) + and _iid + and isinstance(_g, dict) + and all(k in _g for k in ("x", "y", "w", "h")) + ): + try: + _accepted_img[_iid] = { + "x": float(_g["x"]), + "y": float(_g["y"]), + "w": float(_g["w"]), + "h": float(_g["h"]), + } + except (TypeError, ValueError): + continue + overrides_images = _accepted_img run_phase_z2_mvp1( args.mdx_path, @@ -6021,4 +6154,5 @@ if __name__ == "__main__": override_frames=overrides_frames or None, override_zone_geometries=overrides_geoms or None, override_section_assignments=overrides_section_assignments or None, + override_image_overrides=overrides_images or None, ) diff --git a/src/user_overrides_io.py b/src/user_overrides_io.py index c461482..df8ca6b 100644 --- a/src/user_overrides_io.py +++ b/src/user_overrides_io.py @@ -1,19 +1,26 @@ """IMP-52 (#80) u1 — user_overrides.json persistence layer (backend IO). -Persists the four CLI-wired override axes per MDX so a subsequent render +Persists the CLI-wired override axes per MDX so a subsequent render auto-restores user choices without re-clicking. Source of truth = MDX-keyed file (stem of the MDX path), NOT ``data/runs//`` which mints a fresh run_id per ``/api/run`` invocation. -Schema (4 axes; stable order): +Schema (5 axes; stable order; IMP-51 #79 u1 added ``image_overrides``): { "layout": , "zone_geometries": {: {"x": float, "y": float, "w": float, "h": float}}, "zone_sections": {: [, ...]}, - "frames": {: } + "frames": {: }, + "image_overrides": {: {"x": float, "y": float, "w": float, "h": float}} } +``image_id`` is the stable identifier emitted by the user-content image +stamper (IMP-51 u4) and matched via the selector +``.slide img[data-image-role="user-content"]``. Coordinates are +percent-of-slide (zone-agnostic, slide-absolute) to match the SlideCanvas +edit-mode handle conventions in IMP-51 u8~u11. + ``unit_id`` is the convention already used by ``--override-frame`` : ``"+".join(source_section_ids)`` (e.g., ``"03-1"`` or ``"03-1+03-2"``). @@ -46,10 +53,17 @@ from typing import Any, Optional _PKG_ROOT = Path(__file__).resolve().parent.parent DEFAULT_OVERRIDES_ROOT = _PKG_ROOT / "data" / "user_overrides" -# The four in-scope axes. Any other top-level key in the file is preserved -# but ignored by callers — keeps the file forward-compatible with future -# axes (e.g., zone_sizes, image_overrides) without a schema bump here. -KNOWN_AXES: tuple[str, ...] = ("layout", "zone_geometries", "zone_sections", "frames") +# The five in-scope axes (IMP-51 #79 u1 added ``image_overrides``). Any +# other top-level key in the file is preserved but ignored by callers — +# keeps the file forward-compatible with future axes (e.g., zone_sizes) +# without a schema bump here. +KNOWN_AXES: tuple[str, ...] = ( + "layout", + "zone_geometries", + "zone_sections", + "frames", + "image_overrides", +) # Key validation — MDX stem must be safe for filesystem use. Allow # alphanumerics, underscore, hyphen, and dot in the middle (sample stems @@ -92,7 +106,7 @@ def load(key: str, root: Optional[Path] = None) -> dict[str, Any]: Missing file → ``{}``. Corrupt JSON → warning to stderr + ``{}``. Returns the raw mapping (including any foreign keys); callers should - pick the four KNOWN_AXES they care about. + pick the KNOWN_AXES they care about. """ path = override_path(key, root=root) if not path.exists(): diff --git a/tests/test_image_id_stamper.py b/tests/test_image_id_stamper.py new file mode 100644 index 0000000..3dc8b06 --- /dev/null +++ b/tests/test_image_id_stamper.py @@ -0,0 +1,400 @@ +"""IMP-51 (#79) u4 — tests for ``src.image_id_stamper``. + +Covers the stamping contract called out in the Stage 2 plan : + +1. ``USER_CONTENT_IMAGE_SELECTOR`` constant matches the canonical string + shared with the frontend (u3 typed client, u8 SlideCanvas). +2. ``stable_image_id`` is deterministic across calls and across runs + (same ``src`` → same id), and the ordinal suffix only appears for + occurrences > 0. +3. ``stamp_user_content_images`` is a pure no-op when ``sources`` is + empty / all-non-string (forward-compat invariant — current Phase Z + final.html has zero user-content imgs). +4. Allowlisted srcs are stamped; non-allowlisted (decorative) srcs are + left byte-for-byte unchanged. +5. Idempotent under re-stamping (the role-attr probe short-circuits). +6. Duplicate srcs in DOM order get an ordinal suffix. +7. Single-quoted ``src`` is recognized. +8. Self-closing XHTML ```` is preserved. +9. ```` tags without a ``src`` attribute are skipped (no crash). + +All tests are pure-Python — no filesystem, no Selenium, no fixtures. +""" +from __future__ import annotations + +import pytest + +from src.image_id_stamper import ( + IMAGE_ID_ATTR, + IMAGE_ROLE_ATTR, + IMAGE_ROLE_VALUE, + USER_CONTENT_IMAGE_SELECTOR, + build_image_overrides_style, + inject_image_overrides_style, + stable_image_id, + stamp_user_content_images, +) + + +# -- selector contract ------------------------------------------------------ + + +def test_selector_matches_canonical_string(): + # MUST stay verbatim in sync with the frontend mirror in + # Front/client/src/services/userOverridesApi.ts and the SlideCanvas + # query target in u8. Drift here breaks the persisted-override loop. + assert USER_CONTENT_IMAGE_SELECTOR == '.slide img[data-image-role="user-content"]' + + +def test_attribute_constants_match_selector_components(): + assert IMAGE_ROLE_ATTR == "data-image-role" + assert IMAGE_ROLE_VALUE == "user-content" + assert IMAGE_ID_ATTR == "data-image-id" + assert IMAGE_ROLE_ATTR in USER_CONTENT_IMAGE_SELECTOR + assert f'"{IMAGE_ROLE_VALUE}"' in USER_CONTENT_IMAGE_SELECTOR + + +# -- stable_image_id -------------------------------------------------------- + + +def test_stable_image_id_deterministic_same_src(): + a = stable_image_id("/uploads/photo.png") + b = stable_image_id("/uploads/photo.png") + assert a == b + assert a.startswith("img-") + # sha1[:10] + "img-" prefix → fixed length. + assert len(a) == len("img-") + 10 + + +def test_stable_image_id_differs_for_different_src(): + assert stable_image_id("/a.png") != stable_image_id("/b.png") + + +def test_stable_image_id_ordinal_zero_has_no_suffix(): + assert "-" not in stable_image_id("/x.png", ordinal=0)[4:] + + +@pytest.mark.parametrize("ordinal", [1, 2, 7]) +def test_stable_image_id_ordinal_suffix(ordinal): + base = stable_image_id("/x.png", ordinal=0) + suffixed = stable_image_id("/x.png", ordinal=ordinal) + assert suffixed == f"{base}-{ordinal}" + + +def test_stable_image_id_rejects_non_string_src(): + with pytest.raises(TypeError): + stable_image_id(None) # type: ignore[arg-type] + + +def test_stable_image_id_rejects_negative_ordinal(): + with pytest.raises(ValueError): + stable_image_id("/x.png", ordinal=-1) + + +# -- stamp_user_content_images : forward-compat no-op ----------------------- + + +def test_stamp_no_sources_is_pure_noop(): + html = '
' + out, ids = stamp_user_content_images(html, sources=()) + assert out == html + assert ids == [] + + +def test_stamp_all_non_string_sources_is_noop(): + html = '' + out, ids = stamp_user_content_images(html, sources=[None, 123, ""]) # type: ignore[list-item] + assert out == html + assert ids == [] + + +def test_stamp_empty_html_is_safe(): + out, ids = stamp_user_content_images("", sources=["/x.png"]) + assert out == "" + assert ids == [] + + +# -- stamp_user_content_images : allowlist semantics ------------------------ + + +def test_stamp_user_content_src_stamps_role_and_id(): + html = '
photo
' + out, ids = stamp_user_content_images(html, sources=["/u/p.png"]) + expected_id = stable_image_id("/u/p.png") + assert ids == [expected_id] + assert IMAGE_ROLE_ATTR in out + assert f'{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"' in out + assert f'{IMAGE_ID_ATTR}="{expected_id}"' in out + # Original attribute (alt) preserved. + assert 'alt="photo"' in out + + +def test_stamp_decorative_src_left_unchanged(): + html = ( + '
' + '' + '' + '
' + ) + out, ids = stamp_user_content_images(html, sources=["/u/photo.png"]) + # decorative img untouched (no data-image-role injected on it) + assert '' in out + # user-content img stamped + assert ids == [stable_image_id("/u/photo.png")] + # decorative bg.png must not appear with the role attr + assert '/figma/bg.png' in out + decorative_segment = out.split('', 1)[0] + assert IMAGE_ROLE_ATTR not in decorative_segment + + +def test_stamp_is_idempotent_on_second_invocation(): + html = '
' + once, ids1 = stamp_user_content_images(html, sources=["/u/p.png"]) + twice, ids2 = stamp_user_content_images(once, sources=["/u/p.png"]) + assert twice == once + assert ids1 == [stable_image_id("/u/p.png")] + # second invocation finds the role attr already present → no new id + assert ids2 == [] + + +def test_stamp_duplicate_src_gets_ordinal_suffix_in_dom_order(): + html = ( + '
' + '' + '' + '' + '
' + ) + _, ids = stamp_user_content_images(html, sources=["/u/dup.png"]) + base = stable_image_id("/u/dup.png", ordinal=0) + assert ids == [base, f"{base}-1", f"{base}-2"] + + +def test_stamp_recognizes_single_quoted_src(): + html = "
" + out, ids = stamp_user_content_images(html, sources=["/u/p.png"]) + assert ids == [stable_image_id("/u/p.png")] + assert IMAGE_ROLE_ATTR in out + + +def test_stamp_preserves_self_closing_xhtml_form(): + html = '
' + out, ids = stamp_user_content_images(html, sources=["/u/p.png"]) + assert ids == [stable_image_id("/u/p.png")] + # self-close slash retained + assert "/>" in out + # role injected before existing attrs + assert f' injection without DOM mutation. + assert build_image_overrides_style({}, []) == "" + assert build_image_overrides_style({}, ["img-abc"]) == "" + + +def test_build_style_no_stamped_ids_returns_empty_string(): + # Override present but no stamped imgs in the DOM (Q1 = A current + # Phase Z state) — no rules emitted. + out = build_image_overrides_style({"img-abc": {"x": 1, "y": 2, "w": 3, "h": 4}}, []) + assert out == "" + + +def test_build_style_emits_rule_for_stamped_id_present_in_overrides(): + iid = stable_image_id("/u/p.png") + out = build_image_overrides_style( + {iid: {"x": 10, "y": 20, "w": 30.5, "h": 25}}, + [iid], + ) + assert f'[{IMAGE_ID_ATTR}="{iid}"]' in out + assert f'[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]' in out + assert "position: absolute" in out + assert "left: 10" in out and "top: 20" in out + assert "width: 30.5" in out and "height: 25" in out + + +def test_build_style_drops_overrides_for_unstamped_ids(): + # Override exists for an id that was not stamped on this render + # → silently dropped (the SlideCanvas pathway cannot produce + # such keys; persisted-but-stale entries must NOT inject CSS). + iid_stamped = stable_image_id("/u/here.png") + iid_stale = stable_image_id("/u/removed.png") + out = build_image_overrides_style( + { + iid_stamped: {"x": 10, "y": 10, "w": 20, "h": 20}, + iid_stale: {"x": 90, "y": 90, "w": 5, "h": 5}, + }, + [iid_stamped], + ) + assert iid_stamped in out + assert iid_stale not in out + + +def test_build_style_emits_rules_in_stamped_id_order(): + # Deterministic CSS output across renders — rules sorted by DOM + # order (stamped_ids), not by dict insertion order. + iid_a = stable_image_id("/u/a.png") + iid_b = stable_image_id("/u/b.png") + out = build_image_overrides_style( + # dict insertion order: b then a + { + iid_b: {"x": 0, "y": 0, "w": 50, "h": 50}, + iid_a: {"x": 50, "y": 50, "w": 50, "h": 50}, + }, + # stamped order: a then b + [iid_a, iid_b], + ) + assert out.index(iid_a) < out.index(iid_b) + + +def test_build_style_drops_malformed_geometry_entries(): + iid_valid = stable_image_id("/u/ok.png") + iid_missing_axis = stable_image_id("/u/missing.png") + iid_non_numeric = stable_image_id("/u/bad.png") + iid_non_dict = stable_image_id("/u/list.png") + out = build_image_overrides_style( + { + iid_valid: {"x": 1, "y": 2, "w": 3, "h": 4}, + iid_missing_axis: {"x": 1, "y": 2, "w": 3}, # no h + iid_non_numeric: {"x": "abc", "y": 2, "w": 3, "h": 4}, + iid_non_dict: [1, 2, 3, 4], + }, + [iid_valid, iid_missing_axis, iid_non_numeric, iid_non_dict], + ) + assert iid_valid in out + assert iid_missing_axis not in out + assert iid_non_numeric not in out + assert iid_non_dict not in out + + +def test_build_style_coerces_int_geometry_to_float_rules(): + # JSON-loaded int values round-trip through float(...) so the + # emitted CSS uses numeric values acceptable to the browser parser. + iid = stable_image_id("/u/p.png") + out = build_image_overrides_style({iid: {"x": 1, "y": 2, "w": 3, "h": 4}}, [iid]) + assert "left: 1.0%" in out + assert "top: 2.0%" in out + assert "width: 3.0%" in out + assert "height: 4.0%" in out + + +# -- inject_image_overrides_style : ") < e + + +# -- end-to-end stamp → build → inject (u4 + u7 chained) ------------------ + + +def test_stamp_then_build_then_inject_round_trip(): + html = ( + "t" + '
' + "" + ) + stamped_html, ids = stamp_user_content_images(html, sources=["/u/p.png"]) + assert ids == [stable_image_id("/u/p.png")] + css = build_image_overrides_style( + {ids[0]: {"x": 12.5, "y": 25, "w": 40, "h": 30}}, ids, + ) + out = inject_image_overrides_style(stamped_html, css) + # The stamped attribute is still present on the AND the + # injected rule targets that same id. + assert f'{IMAGE_ID_ATTR}="{ids[0]}"' in out + assert f'[{IMAGE_ID_ATTR}="{ids[0]}"]' in out + assert "left: 12.5%" in out + assert "