feat(#79): IMP-51 image_overrides axis (u1~u11 backend stamp+CLI+CSS inject + frontend drag/resize+persistence + tests)

This commit is contained in:
2026-05-22 21:54:38 +09:00
parent bd8bcf748b
commit 6f1c7367e0
18 changed files with 2311 additions and 32 deletions

View File

@@ -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<string, { x: number; y: number; w: number; h: number }>
) => void;
/** IMP-51 (#79) u8 — persisted slide-absolute image geometries
* (image_id → {x,y,w,h} as percent of 1280×720, range 0100). 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 (0100 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<HTMLDivElement>(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<string | null>(null);
// IMP-51 (#79) u8 — measured user-content image bboxes inside iframe
// (slide-absolute percent of 1280×720, range 0100). 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<string, { x: number; y: number; w: number; h: number }>
>({});
// 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<string | null>(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<HTMLImageElement>(
'.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 (0100) — image_overrides axis (u3
// 타입 + u7 CSS `left/top/width/height: {value}%` 주입) 와
// 동일한 좌표계라서 측정 / 영구 저장 / emit 가 1:1 매칭됨.
const imageEls = doc.querySelectorAll<HTMLImageElement>(
'.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({
</div>
);
})}
{/* ── 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 (0100) 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<HTMLDivElement>,
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 (
<div
key={`img-overlay-${imageId}`}
role="button"
tabIndex={0}
data-image-overlay-id={imageId}
onMouseDown={(ev) => 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 = 비율 해제)"
: "클릭하여 선택"
}
>
<span className="absolute top-1 left-1 text-[9px] font-black uppercase tracking-tighter px-1.5 py-0.5 rounded bg-emerald-600/90 text-white shadow pointer-events-none">
IMG
</span>
{isSelected && (
<>
{/* edges */}
<div
onMouseDown={(ev) => 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="상단"
/>
<div
onMouseDown={(ev) => 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="하단"
/>
<div
onMouseDown={(ev) => 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="좌측"
/>
<div
onMouseDown={(ev) => 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 */}
<div
onMouseDown={(ev) => 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 = 비율 해제)"
/>
<div
onMouseDown={(ev) => 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 = 비율 해제)"
/>
<div
onMouseDown={(ev) => 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 = 비율 해제)"
/>
<div
onMouseDown={(ev) => 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 = 비율 해제)"
/>
</>
)}
</div>
);
}
)}
</div>
)}
</div>

View File

@@ -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);
});
});
});

View File

@@ -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 10921219 of the u8 patch).
// Slide-absolute percent coordinate space (0100 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 {

View File

@@ -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 (0100) 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}
/>
</main>

View File

@@ -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<UserOverrides>` from
// the GET endpoint. Missing / corrupt / non-object payloads degrade to
@@ -44,12 +50,28 @@ export type ZoneGeometriesOverride = Record<string, ZoneGeometryOverride>;
/** zone_id → ordered list of section_ids assigned to that zone. */
export type ZoneSectionsOverride = Record<string, string[]>;
/**
* 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 (0100) 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<string, ImageOverride>;
/** 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). */

View File

@@ -206,6 +206,13 @@ export interface UserSelection {
zone_sections: Record<string, string[]>; // zoneId -> sectionIds[]
zone_sizes: Record<string, number[]>; // layoutGroupId -> [size1, size2, ...]
zone_geometries: Record<string, { x: number; y: number; w: number; h: number }>; // zone_id -> geometry
// IMP-51 (#79) u11 — image_id → slide-absolute percent geometry (0100
// on each axis). image_id is stamped by `src/image_id_stamper.py` (u4)
// on user-content `<img>` 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<string, { x: number; y: number; w: number; h: number }>;
};
}

View File

@@ -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,6 +155,10 @@ 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: {},
},
};
}
@@ -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,

View File

@@ -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" });

View File

@@ -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<UserSelection["overrides"]>): 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);
});
});

View File

@@ -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<string, unknown>;
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 } },
});
});
});

View File

@@ -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(

264
src/image_id_stamper.py Normal file
View File

@@ -0,0 +1,264 @@
"""IMP-51 (#79) u4 — user-content image stamper for Phase Z final.html.
Annotates user-content ``<img>`` 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
``<img>`` 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 ``<img ...>`` 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"<img\b([^>]*?)(/?)>",
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 ``<img>`` 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
``<img>`` whose ``src`` value is in ``sources`` is rewritten to include
``data-image-role="user-content"`` and ``data-image-id="<stable_id>"``.
Other ``<img>`` 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-<hash>``, ``img-<hash>-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"<img{injected}{attrs}{self_close}>"
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 ``<style>`` block so re-injection on a
# previously-injected document is idempotent (the wrapper is found by a
# simple substring probe and the inner CSS is replaced in place).
_IMP51_STYLE_MARKER_OPEN: str = "<!-- IMP-51 image_overrides start -->"
_IMP51_STYLE_MARKER_CLOSE: str = "<!-- IMP-51 image_overrides end -->"
_IMP51_STYLE_BLOCK_RE = re.compile(
re.escape(_IMP51_STYLE_MARKER_OPEN) + r".*?" + re.escape(_IMP51_STYLE_MARKER_CLOSE),
flags=re.DOTALL,
)
_HEAD_CLOSE_RE = re.compile(r"</head\s*>", flags=re.IGNORECASE)
_BODY_OPEN_RE = re.compile(r"<body\b[^>]*>", flags=re.IGNORECASE)
def build_image_overrides_style(
image_overrides: dict,
stamped_ids: Iterable[str],
) -> str:
"""Build CSS rule text for persisted ``image_overrides``.
For every ``image_id`` that appears in BOTH ``stamped_ids`` (the DOM
order of stamps returned by :func:`stamp_user_content_images`) AND
``image_overrides`` (the persisted geometry mapping from ``u1``
``user_overrides_io``), emit one absolute-position rule of the form ::
.slide img[data-image-role="user-content"][data-image-id="<id>"] {
position: absolute;
left: <x>%; top: <y>%;
width: <w>%; height: <h>%;
}
Coordinates are ``%`` of the slide bounding box (slide-absolute, per
Stage 2 scope-lock). ``.slide`` already declares ``position: relative``
in ``templates/phase_z2/slide_base.html`` so the absolute coordinates
resolve against the slide frame.
Rules are emitted in ``stamped_ids`` order so the output is
byte-deterministic across renders (critical for diff-based verifiers).
Override entries for ids NOT in ``stamped_ids`` are silently dropped —
those keys cannot be produced via the SlideCanvas pathway (the
frontend only knows the ids actually present in the DOM). Per-entry
malformed geometries (non-dict / missing axis / non-coercible value)
are dropped silently; the whole batch is never rejected.
Returns ``""`` when no rules are emitted so the caller can skip
``<style>`` injection entirely (forward-compat no-op when Phase Z
final.html still emits zero user-content imgs).
"""
if not image_overrides:
return ""
rules: list[str] = []
for iid in stamped_ids:
geom = image_overrides.get(iid)
if not isinstance(geom, dict):
continue
try:
x = float(geom["x"])
y = float(geom["y"])
w = float(geom["w"])
h = float(geom["h"])
except (KeyError, TypeError, ValueError):
continue
rules.append(
f'.slide img[{IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"]'
f'[{IMAGE_ID_ATTR}="{iid}"] {{ '
f"position: absolute; "
f"left: {x}%; top: {y}%; "
f"width: {w}%; height: {h}%; "
f"}}"
)
return "\n".join(rules)
def inject_image_overrides_style(html: str, css: str) -> str:
"""Inject a marker-wrapped ``<style>`` block carrying ``css`` into ``html``.
Empty ``css`` → ``html`` returned unchanged (no DOM mutation). This
preserves the byte-for-byte identity of forward-compat renders where
no overrides apply.
When a previously-injected marker block is present, its inner CSS is
replaced in place (idempotent re-injection — second call with the
same overrides produces an identical document).
Injection precedence when no existing marker is found :
1. Before the first ``</head>`` (case-insensitive)
2. Immediately after the first ``<body ...>`` open tag
3. At the start of the document
Phase Z ``slide_base.html`` always emits ``</head>`` so path 1 wins
for production renders; paths 2/3 are defensive fallbacks for
unusual fragment inputs (tests, partials).
"""
if not css:
return html
block = (
f"{_IMP51_STYLE_MARKER_OPEN}\n"
f"<style>\n{css}\n</style>\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

View File

@@ -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 `<img>` tags by
``src/image_id_stamper.py`` (u4). x/y/w/h are percent-of-slide
coordinates (0100, 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 `<img>` tags by src/image_id_stamper.py (u4). X,Y,W,H =
# percent-of-slide coordinates (0100, 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 (0100, "
"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
# 0100; 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,
)

View File

@@ -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/<run_id>/`` 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": <string|null>,
"zone_geometries": {<zone_id>: {"x": float, "y": float, "w": float, "h": float}},
"zone_sections": {<zone_id>: [<section_id>, ...]},
"frames": {<unit_id>: <template_id>}
"frames": {<unit_id>: <template_id>},
"image_overrides": {<image_id>: {"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():

View File

@@ -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 ``<img />`` is preserved.
9. ``<img>`` 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 = '<div class="slide"><img src="/decorative.png"></div>'
out, ids = stamp_user_content_images(html, sources=())
assert out == html
assert ids == []
def test_stamp_all_non_string_sources_is_noop():
html = '<img src="/x.png">'
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 = '<div class="slide"><img src="/u/p.png" alt="photo"></div>'
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 = (
'<div class="slide">'
'<img src="/figma/bg.png">'
'<img src="/u/photo.png">'
'</div>'
)
out, ids = stamp_user_content_images(html, sources=["/u/photo.png"])
# decorative img untouched (no data-image-role injected on it)
assert '<img src="/figma/bg.png">' 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('<img', 1)[1].split('>', 1)[0]
assert IMAGE_ROLE_ATTR not in decorative_segment
def test_stamp_is_idempotent_on_second_invocation():
html = '<div class="slide"><img src="/u/p.png"></div>'
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 = (
'<div class="slide">'
'<img src="/u/dup.png">'
'<img src="/u/dup.png">'
'<img src="/u/dup.png">'
'</div>'
)
_, 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 = "<div class=\"slide\"><img src='/u/p.png'></div>"
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 = '<div class="slide"><img src="/u/p.png" /></div>'
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'<img {IMAGE_ROLE_ATTR}="{IMAGE_ROLE_VALUE}"' in out
def test_stamp_img_without_src_is_left_unchanged():
html = '<div class="slide"><img alt="no src"></div>'
out, ids = stamp_user_content_images(html, sources=["/u/p.png"])
assert out == html
assert ids == []
def test_stamp_returned_ids_persist_across_renders():
# Same allowlist + same DOM order → same id sequence on a fresh
# render. This is the invariant that lets user_overrides.json keys
# re-apply on the next pipeline run without re-clicking.
html = '<div class="slide"><img src="/u/a.png"><img src="/u/b.png"></div>'
_, ids_first = stamp_user_content_images(html, sources=["/u/a.png", "/u/b.png"])
_, ids_second = stamp_user_content_images(html, sources=["/u/a.png", "/u/b.png"])
assert ids_first == ids_second
assert ids_first == [stable_image_id("/u/a.png"), stable_image_id("/u/b.png")]
# -- build_image_overrides_style : CSS builder (u7) -----------------------
def test_build_style_empty_overrides_returns_empty_string():
# Forward-compat invariant — None / {} produces "" so the caller
# can short-circuit the <style> 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 : <style> block injector (u7) -----------
def test_inject_style_empty_css_returns_html_unchanged():
html = "<html><head></head><body>x</body></html>"
assert inject_image_overrides_style(html, "") == html
def test_inject_style_inserts_before_head_close():
html = "<html><head><title>t</title></head><body>x</body></html>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<style>" in out
# injected block sits before </head>, NOT after <body>
head_idx = out.lower().index("</head>")
body_idx = out.lower().index("<body")
style_idx = out.index("<style>")
assert style_idx < head_idx < body_idx
def test_inject_style_case_insensitive_head_close():
html = "<HTML><HEAD></HEAD><BODY>x</BODY></HTML>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<style>" in out
# injection before </HEAD>
assert out.index("<style>") < out.upper().index("</HEAD>")
def test_inject_style_falls_back_to_body_open_when_no_head():
html = "<body>x</body>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<style>" in out
# injected right after the <body> open tag
body_open_end = out.index(">", out.index("<body")) + 1
assert out[body_open_end:].lstrip().startswith("<!-- IMP-51")
def test_inject_style_falls_back_to_document_start_when_no_head_or_body():
html = "<div>fragment</div>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert out.startswith("<!-- IMP-51 image_overrides start -->")
assert "<style>" in out
assert out.rstrip().endswith("</div>")
def test_inject_style_is_idempotent_on_second_call():
html = "<html><head></head><body>x</body></html>"
css = ".x { color: red; }"
once = inject_image_overrides_style(html, css)
twice = inject_image_overrides_style(once, css)
assert twice == once
# Marker block appears exactly once after both invocations.
assert once.count("<!-- IMP-51 image_overrides start -->") == 1
assert twice.count("<!-- IMP-51 image_overrides start -->") == 1
def test_inject_style_replaces_existing_block_with_new_css():
# Re-injection with different CSS replaces the previous block in
# place — the marker pair is found and its body is swapped out.
html = "<html><head></head><body>x</body></html>"
first = inject_image_overrides_style(html, ".old { color: red; }")
second = inject_image_overrides_style(first, ".new { color: blue; }")
assert ".old" not in second
assert ".new" in second
assert second.count("<!-- IMP-51 image_overrides start -->") == 1
def test_inject_style_wraps_block_with_marker_comments():
html = "<html><head></head><body>x</body></html>"
out = inject_image_overrides_style(html, ".x { color: red; }")
assert "<!-- IMP-51 image_overrides start -->" in out
assert "<!-- IMP-51 image_overrides end -->" in out
# Open marker precedes close marker, with the <style> tag between.
s = out.index("<!-- IMP-51 image_overrides start -->")
e = out.index("<!-- IMP-51 image_overrides end -->")
assert s < out.index("<style>") < out.index("</style>") < e
# -- end-to-end stamp → build → inject (u4 + u7 chained) ------------------
def test_stamp_then_build_then_inject_round_trip():
html = (
"<html><head><title>t</title></head>"
'<body><div class="slide"><img src="/u/p.png"></div></body>'
"</html>"
)
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 <img> 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 "<style>" in out

View File

@@ -0,0 +1,348 @@
"""IMP-51 (#79) u5 — focused tests for the ``--override-image`` CLI surface.
Stage 2 u5 scope (per the Exit Report):
- Successful parse: single flag + multiple flags accumulate.
- Forwarding: parsed mapping reaches ``run_phase_z2_mvp1`` as
``override_image_overrides={image_id: {"x", "y", "w", "h"}}``.
- Empty payload: omitting ``--override-image`` forwards ``None``
(CLI ``or None`` collapse, sibling pattern to other axes).
- Hard-error cases (each must ``sys.exit(2)`` with a stderr message):
* missing ``=``
* empty ``IMAGE_ID``
* duplicate ``IMAGE_ID``
* wrong float count (not 4)
* non-numeric float component
The harness mirrors ``tests/test_user_overrides_pipeline_fallback.py`` —
the ``if __name__ == "__main__"`` block of ``src.phase_z2_pipeline`` is
exec'd inside the module's namespace after monkeypatching
``run_phase_z2_mvp1`` with a recording stub. This exercises the actual
production parser without invoking the real pipeline.
The persistence fallback is silenced by redirecting
``src.user_overrides_io.DEFAULT_OVERRIDES_ROOT`` to a clean tmp directory
so persisted state from prior runs cannot bleed into the parser-only
assertions here.
"""
from __future__ import annotations
import ast
import sys
from pathlib import Path
from typing import Any
import pytest
import src.phase_z2_pipeline as _pz2
import src.user_overrides_io as _io
# -- harness ---------------------------------------------------------------
def _exec_main_block(
captured: dict[str, Any], argv: list[str], monkeypatch
) -> None:
"""Run the ``__main__`` body of phase_z2_pipeline.py with a fake
``run_phase_z2_mvp1`` so its kwargs are observable."""
def _fake_run(
mdx_path,
run_id,
*,
override_layout=None,
override_frames=None,
override_zone_geometries=None,
override_section_assignments=None,
override_image_overrides=None,
):
captured["mdx_path"] = mdx_path
captured["run_id"] = run_id
captured["override_layout"] = override_layout
captured["override_frames"] = override_frames
captured["override_zone_geometries"] = override_zone_geometries
captured["override_section_assignments"] = override_section_assignments
captured["override_image_overrides"] = override_image_overrides
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
monkeypatch.setattr(sys, "argv", argv)
src_path = Path(_pz2.__file__)
source = src_path.read_text(encoding="utf-8")
tree = ast.parse(source)
for node in tree.body:
if (
isinstance(node, ast.If)
and isinstance(node.test, ast.Compare)
and isinstance(node.test.left, ast.Name)
and node.test.left.id == "__name__"
):
block = ast.Module(body=node.body, type_ignores=[])
exec(compile(block, str(src_path), "exec"), _pz2.__dict__)
return
raise AssertionError("no `if __name__ == '__main__'` block found")
def _redirect_overrides_root(tmp_path: Path, monkeypatch) -> None:
"""Isolate the persistence fallback so file state never leaks in."""
monkeypatch.setattr(_io, "DEFAULT_OVERRIDES_ROOT", tmp_path)
# -- success paths --------------------------------------------------------
def test_no_image_override_forwards_none(tmp_path, monkeypatch):
"""When ``--override-image`` is omitted, the kwarg must be ``None``
(the parser's accumulator stays empty → ``overrides_images or None``)."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
assert captured["override_image_overrides"] is None
def test_single_image_override_parses_and_forwards(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30.5,25",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.5, "h": 25.0},
}
def test_multiple_image_overrides_accumulate(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25",
"--override-image",
"img-def=50,15,40,40",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
"img-def": {"x": 50.0, "y": 15.0, "w": 40.0, "h": 40.0},
}
def test_image_override_strips_whitespace_in_image_id(tmp_path, monkeypatch):
"""``iid.strip()`` is intentional — match sibling --override-frame and
--override-zone-geometry leniency on surrounding whitespace."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
" img-pad =5,5,10,10",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-pad": {"x": 5.0, "y": 5.0, "w": 10.0, "h": 10.0},
}
# -- hard-error paths -----------------------------------------------------
def test_image_override_missing_equals_exits(tmp_path, monkeypatch, capsys):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "--override-image must be IMAGE_ID=X,Y,W,H" in err
def test_image_override_empty_image_id_exits(tmp_path, monkeypatch, capsys):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"=10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "IMAGE_ID must be non-empty" in err
def test_image_override_whitespace_only_image_id_exits(
tmp_path, monkeypatch, capsys
):
"""``iid.strip()`` must collapse whitespace-only IDs into the empty-ID
error path (otherwise a spurious key would land in the mapping)."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
" =10,15,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "IMAGE_ID must be non-empty" in err
def test_image_override_duplicate_image_id_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25",
"--override-image",
"img-abc=20,25,30,35",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "duplicate IMAGE_ID 'img-abc'" in err
def test_image_override_wrong_float_count_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "expects 4 floats X,Y,W,H" in err
def test_image_override_too_many_floats_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25,99",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "expects 4 floats X,Y,W,H" in err
def test_image_override_non_numeric_value_exits(
tmp_path, monkeypatch, capsys
):
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
with pytest.raises(SystemExit) as excinfo:
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,abc,30,25",
],
monkeypatch,
)
assert excinfo.value.code == 2
err = capsys.readouterr().err
assert "floats parse fail" in err
# -- isolation guard ------------------------------------------------------
def test_image_override_does_not_leak_into_sibling_axes(tmp_path, monkeypatch):
"""A populated image override must not perturb the other four axes."""
_redirect_overrides_root(tmp_path, monkeypatch)
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-abc=10,15,30,25",
],
monkeypatch,
)
assert captured["override_image_overrides"] == {
"img-abc": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
}
assert captured["override_layout"] is None
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None

View File

@@ -1,8 +1,9 @@
"""IMP-52 (#80) u8 — backend tests for ``src.user_overrides_io``.
Covers the four axes called out in the Stage 2 plan:
Covers the persisted axes called out in the Stage 2 plan
(IMP-51 #79 u1 extended this to 5 axes by adding ``image_overrides``):
1. Round-trip ``save`` → ``load`` (4 KNOWN_AXES + foreign top-level keys).
1. Round-trip ``save`` → ``load`` (5 KNOWN_AXES + foreign top-level keys).
2. Unknown-key passthrough (foreign axes preserved across partial merges).
3. Missing / corrupt / non-object behavior (graceful ``{}`` + stderr warning).
4. Invalid keys (``InvalidOverrideKey`` raised on traversal / separators /
@@ -115,9 +116,18 @@ def _full_payload() -> dict:
},
"zone_sections": {"zone-top": ["03-1", "03-2"]},
"frames": {"03-1+03-2": "frame_two_way_compare"},
"image_overrides": {
"img-1": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 25.0},
},
}
def test_known_axes_includes_image_overrides():
"""IMP-51 #79 u1 — ``image_overrides`` is a known axis (5 total)."""
assert "image_overrides" in KNOWN_AXES
assert len(KNOWN_AXES) == 5
def test_save_then_load_round_trip(tmp_path):
key = "03"
payload = _full_payload()
@@ -141,6 +151,28 @@ def test_save_partial_payload_preserves_other_axes(tmp_path):
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
assert loaded["frames"] == _full_payload()["frames"]
assert loaded["image_overrides"] == _full_payload()["image_overrides"]
def test_save_partial_image_overrides_preserves_other_axes(tmp_path):
"""IMP-51 #79 u1 — partial ``image_overrides`` write preserves siblings."""
key = "03"
save(key, _full_payload(), root=tmp_path)
save(
key,
{"image_overrides": {"img-9": {"x": 5.0, "y": 5.0, "w": 50.0, "h": 50.0}}},
root=tmp_path,
)
loaded = load(key, root=tmp_path)
assert loaded["image_overrides"] == {
"img-9": {"x": 5.0, "y": 5.0, "w": 50.0, "h": 50.0}
}
assert loaded["layout"] == _full_payload()["layout"]
assert loaded["zone_geometries"] == _full_payload()["zone_geometries"]
assert loaded["zone_sections"] == _full_payload()["zone_sections"]
assert loaded["frames"] == _full_payload()["frames"]
def test_save_axis_replaces_not_deep_merges(tmp_path):
@@ -162,14 +194,15 @@ def test_save_none_clears_axis(tmp_path):
def test_save_preserves_foreign_top_level_keys(tmp_path):
"""Forward-compat: axes outside KNOWN_AXES (zone_sizes, image_overrides,
schema_version, ...) must survive a partial merge on a known axis."""
"""Forward-compat: axes outside KNOWN_AXES (zone_sizes, schema_version,
...) must survive a partial merge on a known axis. (IMP-51 #79 u1
promoted ``image_overrides`` to a known axis, so it is no longer
exercised here as a foreign key.)"""
key = "03"
path = override_path(key, root=tmp_path)
path.parent.mkdir(parents=True, exist_ok=True)
pre_seed = {
"layout": "single-column",
"image_overrides": {"img-1": {"position": "right", "size": "small"}},
"zone_sizes": {"zone-top": "tall"},
"schema_version": "experimental-1",
}
@@ -179,7 +212,6 @@ def test_save_preserves_foreign_top_level_keys(tmp_path):
loaded = load(key, root=tmp_path)
assert loaded["layout"] == "sidebar-right"
assert loaded["image_overrides"] == pre_seed["image_overrides"]
assert loaded["zone_sizes"] == pre_seed["zone_sizes"]
assert loaded["schema_version"] == pre_seed["schema_version"]
@@ -197,10 +229,11 @@ def test_save_writes_pretty_sorted_json_for_diffability(tmp_path):
raw = (tmp_path / "03.json").read_text(encoding="utf-8")
# sort_keys=True → KNOWN_AXES come out alphabetically
pos_frames = raw.index('"frames"')
pos_image_overrides = raw.index('"image_overrides"')
pos_layout = raw.index('"layout"')
pos_zg = raw.index('"zone_geometries"')
pos_zs = raw.index('"zone_sections"')
assert pos_frames < pos_layout < pos_zg < pos_zs
assert pos_frames < pos_image_overrides < pos_layout < pos_zg < pos_zs
def test_save_leaves_no_tmp_file_on_success(tmp_path):

View File

@@ -47,6 +47,7 @@ def _exec_main_block(
override_frames=None,
override_zone_geometries=None,
override_section_assignments=None,
override_image_overrides=None,
):
captured["mdx_path"] = mdx_path
captured["run_id"] = run_id
@@ -54,6 +55,7 @@ def _exec_main_block(
captured["override_frames"] = override_frames
captured["override_zone_geometries"] = override_zone_geometries
captured["override_section_assignments"] = override_section_assignments
captured["override_image_overrides"] = override_image_overrides
monkeypatch.setattr(_pz2, "run_phase_z2_mvp1", _fake_run)
monkeypatch.setattr(sys, "argv", argv)
@@ -95,6 +97,10 @@ def _write_full_payload(tmp_path: Path, stem: str = "03") -> Path:
"top": ["03-1"],
"bottom": ["03-2", "03-3"],
},
"image_overrides": {
"img-file-a": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
"img-file-b": {"x": 50.0, "y": 50.0, "w": 40.0, "h": 40.0},
},
}
),
encoding="utf-8",
@@ -114,6 +120,7 @@ def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch):
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# MDX path / run_id propagate untouched.
assert captured["mdx_path"] == Path("03.mdx")
assert captured["run_id"] is None
@@ -122,7 +129,7 @@ def test_no_overrides_file_passes_none_overrides(tmp_path, monkeypatch):
# -- 2. file fills every axis when CLI is empty ----------------------------
def test_file_only_fills_all_four_axes_when_cli_empty(tmp_path, monkeypatch):
def test_file_only_fills_all_five_axes_when_cli_empty(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
_write_full_payload(tmp_path, "03")
@@ -142,6 +149,10 @@ def test_file_only_fills_all_four_axes_when_cli_empty(tmp_path, monkeypatch):
"top": ["03-1"],
"bottom": ["03-2", "03-3"],
}
assert captured["override_image_overrides"] == {
"img-file-a": {"x": 10.0, "y": 15.0, "w": 30.0, "h": 25.0},
"img-file-b": {"x": 50.0, "y": 50.0, "w": 40.0, "h": 40.0},
}
# -- 3. CLI beats file on the same axis -----------------------------------
@@ -190,6 +201,37 @@ def test_cli_frames_overrides_file_frames(tmp_path, monkeypatch):
assert captured["override_layout"] == "sidebar-right"
assert captured["override_zone_geometries"] is not None
assert captured["override_section_assignments"] is not None
assert captured["override_image_overrides"] is not None
# -- 3b. CLI image override beats file image override (IMP-51 #79 u6) -----
def test_cli_image_override_overrides_file_image_overrides(tmp_path, monkeypatch):
_redirect_overrides_root(tmp_path, monkeypatch)
_write_full_payload(tmp_path, "03")
captured: dict[str, Any] = {}
_exec_main_block(
captured,
[
"src.phase_z2_pipeline",
"03.mdx",
"--override-image",
"img-cli=70,80,20,15",
],
monkeypatch,
)
# CLI ``image_overrides`` payload wholly replaces file payload (per-axis).
assert captured["override_image_overrides"] == {
"img-cli": {"x": 70.0, "y": 80.0, "w": 20.0, "h": 15.0},
}
# Other axes still come from the file.
assert captured["override_layout"] == "sidebar-right"
assert captured["override_frames"] is not None
assert captured["override_zone_geometries"] is not None
assert captured["override_section_assignments"] is not None
# -- 4. corrupt / non-object file warns and skips fallback ----------------
@@ -209,6 +251,7 @@ def test_corrupt_json_warns_and_skips_fallback(tmp_path, monkeypatch, capsys):
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
def test_non_object_top_level_warns_and_skips_fallback(
@@ -226,6 +269,7 @@ def test_non_object_top_level_warns_and_skips_fallback(
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# -- 5. invalid MDX stem warns and skips fallback wholesale ---------------
@@ -251,6 +295,7 @@ def test_invalid_mdx_stem_warns_and_skips_fallback(
assert captured["override_frames"] is None
assert captured["override_zone_geometries"] is None
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# -- 6. per-axis partial fill (file fills only what CLI omits) ------------
@@ -294,3 +339,72 @@ def test_per_axis_partial_fill_mixes_cli_and_file(tmp_path, monkeypatch):
"top": {"x": 0.0, "y": 0.0, "w": 1.0, "h": 0.5},
}
assert captured["override_section_assignments"] is None
assert captured["override_image_overrides"] is None
# -- 7. image_overrides fallback edge cases (IMP-51 #79 u6) ---------------
def test_image_overrides_fallback_drops_malformed_entries(tmp_path, monkeypatch):
"""File carries a mix of valid + malformed image_overrides entries.
Expected: valid entry survives; malformed entries (non-string id,
empty id, non-dict value, missing key, non-numeric value) are silently
dropped — no exception propagates.
"""
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text(
json.dumps(
{
"image_overrides": {
"img-valid": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
"": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
"img-not-dict": "oops",
"img-missing-h": {"x": 1.0, "y": 2.0, "w": 3.0},
"img-bad-value": {"x": "abc", "y": 2.0, "w": 3.0, "h": 4.0},
}
}
),
encoding="utf-8",
)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
assert captured["override_image_overrides"] == {
"img-valid": {"x": 1.0, "y": 2.0, "w": 3.0, "h": 4.0},
}
def test_image_overrides_fallback_non_dict_axis_is_ignored(tmp_path, monkeypatch):
"""File ``image_overrides`` is a non-dict (list); fallback silently skips."""
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text(
json.dumps({"image_overrides": ["not", "a", "dict"]}),
encoding="utf-8",
)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
# ``overrides_images`` stays empty; ``or None`` collapses on call site.
assert captured["override_image_overrides"] is None
def test_image_overrides_fallback_coerces_int_values_to_float(tmp_path, monkeypatch):
"""JSON-loaded ints (e.g. ``10`` not ``10.0``) must coerce to float."""
_redirect_overrides_root(tmp_path, monkeypatch)
(tmp_path / "03.json").write_text(
json.dumps(
{"image_overrides": {"img-int": {"x": 10, "y": 20, "w": 30, "h": 40}}}
),
encoding="utf-8",
)
captured: dict[str, Any] = {}
_exec_main_block(captured, ["src.phase_z2_pipeline", "03.mdx"], monkeypatch)
coerced = captured["override_image_overrides"]
assert coerced == {"img-int": {"x": 10.0, "y": 20.0, "w": 30.0, "h": 40.0}}
for axis_value in coerced["img-int"].values():
assert isinstance(axis_value, float)