export type DocsGraphicType = "image" | "shape" | "gradient" | "draw" export type DocsGradientType = "linear" | "radial" export type DocsGraphicPlacement = "inline" | "block" | "absolute" export type DocsGraphicPositionMode = "move-with-text" | "fixed-on-page" /** Text wrap / layering relative to body text */ export type DocsGraphicWrap = | "inline" | "square" | "tight" | "through" | "top-bottom" | "behind" | "in-front" export type DocsGraphicFloatSide = "left" | "right" | "center" export type DocsShapeType = "rect" | "ellipse" | "line" | "arrow" export type DocsCropShape = "rect" | "ellipse" export type DocsImageFit = "contain" | "cover" | "crop" export type DocsImageLayoutFit = "contain" | "cover" export type DocsImageFitAnchor = 0 | 0.5 | 1 export type DocsGraphicAttrs = { graphicType: DocsGraphicType src: string | null alt: string assetId: string | null shapeType: DocsShapeType fill: string stroke: string strokeWidth: number gradientCss: string gradientType: DocsGradientType gradientAngle: number gradientColor1: string gradientColor2: string width: number height: number placement: DocsGraphicPlacement wrap: DocsGraphicWrap floatSide: DocsGraphicFloatSide x: number y: number positionMode: DocsGraphicPositionMode /** ProseMirror doc position; -1 when unset */ anchorPos: number pageIndex: number pageX: number pageY: number wrapMarginMm: number rotationDeg: number zIndex: number /** Normalized crop region 0–1 relative to source image */ cropX: number cropY: number cropWidth: number cropHeight: number cropShape: DocsCropShape /** When true, corner resize keeps width/height ratio (images). */ lockAspectRatio: boolean /** How the image fits its frame when no crop transform is applied. */ imageFit: DocsImageFit /** Horizontal focal point for contain/cover (0 = left, 0.5 = center, 1 = right). */ imageFitAnchorH: DocsImageFitAnchor /** Vertical focal point for contain/cover (0 = top, 0.5 = center, 1 = bottom). */ imageFitAnchorV: DocsImageFitAnchor opacity: number shadow: string /** -1..1, 0 = neutral */ brightness: number /** -1..1, 0 = neutral */ contrast: number /** Recolor preset id, "" = none */ recolor: string altTitle: string /** Serialized Excalidraw scene JSON for re-editing vector drawings. */ drawScene: string | null /** UltiDraw file id when the drawing is linked to Drive (edit opens UltiDraw). */ drawDriveFileId: string | null } export const DOCS_GRAPHIC_DEFAULTS: DocsGraphicAttrs = { graphicType: "image", src: null, alt: "", assetId: null, shapeType: "rect", fill: "#4285f4", stroke: "#1a73e8", strokeWidth: 2, gradientCss: "", gradientType: "linear", gradientAngle: 180, gradientColor1: "#4285f4", gradientColor2: "#34a853", width: 240, height: 160, placement: "block", wrap: "square", floatSide: "left", x: 0, y: 0, positionMode: "move-with-text", anchorPos: -1, pageIndex: 0, pageX: 0, pageY: 0, wrapMarginMm: 3, rotationDeg: 0, zIndex: 0, cropX: 0, cropY: 0, cropWidth: 1, cropHeight: 1, cropShape: "rect", lockAspectRatio: true, imageFit: "contain", imageFitAnchorH: 0.5, imageFitAnchorV: 0.5, opacity: 1, shadow: "", brightness: 0, contrast: 0, recolor: "", altTitle: "", drawScene: null, drawDriveFileId: null, } export type DocsGraphicRecolorPreset = { id: string label: string /** CSS filter chain approximating the Google Docs recolor tint */ filter: string } export const DOCS_GRAPHIC_RECOLOR_PRESETS: DocsGraphicRecolorPreset[] = [ { id: "", label: "Aucune recolorisation", filter: "" }, { id: "grayscale", label: "Niveaux de gris", filter: "grayscale(1)" }, { id: "sepia", label: "Sépia", filter: "sepia(1)" }, { id: "washout", label: "Délavé", filter: "grayscale(0.4) brightness(1.4) saturate(0.6)" }, { id: "negative", label: "Négatif", filter: "invert(1)" }, { id: "blue-light", label: "Bleu clair", filter: "grayscale(1) sepia(1) hue-rotate(175deg) saturate(2.4) brightness(1.15)" }, { id: "blue-dark", label: "Bleu foncé", filter: "grayscale(1) sepia(1) hue-rotate(185deg) saturate(3.2) brightness(0.75)" }, { id: "teal", label: "Bleu canard", filter: "grayscale(1) sepia(1) hue-rotate(130deg) saturate(2.2)" }, { id: "green", label: "Vert", filter: "grayscale(1) sepia(1) hue-rotate(60deg) saturate(2.4)" }, { id: "yellow", label: "Jaune", filter: "grayscale(1) sepia(1) hue-rotate(10deg) saturate(2.8) brightness(1.1)" }, { id: "orange", label: "Orange", filter: "grayscale(1) sepia(1) hue-rotate(-15deg) saturate(3)" }, { id: "red", label: "Rouge", filter: "grayscale(1) sepia(1) hue-rotate(-45deg) saturate(3.4)" }, { id: "purple", label: "Violet", filter: "grayscale(1) sepia(1) hue-rotate(220deg) saturate(2.4)" }, ] /** CSS filter string combining recolor preset + brightness/contrast. */ export function computeGraphicFilterCss( attrs: Pick ): string { const parts: string[] = [] const preset = DOCS_GRAPHIC_RECOLOR_PRESETS.find((p) => p.id === attrs.recolor) if (preset?.filter) parts.push(preset.filter) if (attrs.brightness !== 0) { parts.push(`brightness(${clampRange(1 + attrs.brightness, 0, 2)})`) } if (attrs.contrast !== 0) { parts.push(`contrast(${clampRange(1 + attrs.contrast, 0, 2)})`) } return parts.join(" ") } function clampRange(value: number, min: number, max: number): number { return Math.min(max, Math.max(min, value)) } export const DOCS_GRAPHIC_WRAP_LABELS: Record = { inline: "En ligne avec le texte", square: "Carré", tight: "Rapproché", through: "À travers", "top-bottom": "Haut et bas", behind: "Derrière le texte", "in-front": "Devant le texte", } export const DOCS_GRAPHIC_PLACEMENT_LABELS: Record = { inline: "En ligne", block: "Bloc", absolute: "Position absolue", } export const DOCS_GRAPHIC_POSITION_MODE_LABELS: Record = { "move-with-text": "Déplacer avec le texte", "fixed-on-page": "Position fixe sur la page", } export function buildGradientCss( angle: number, color1: string, color2: string, type: DocsGradientType = "linear" ): string { if (type === "radial") { return `radial-gradient(circle, ${color1}, ${color2})` } return `linear-gradient(${angle}deg, ${color1}, ${color2})` } export function parseGraphicAttrs(raw: Record): DocsGraphicAttrs { const num = (key: keyof DocsGraphicAttrs, fallback: number) => { const value = raw[key] return typeof value === "number" && Number.isFinite(value) ? value : fallback } const str = (key: keyof DocsGraphicAttrs, fallback: string) => { const value = raw[key] return typeof value === "string" ? value : fallback } const gradientAngle = num("gradientAngle", DOCS_GRAPHIC_DEFAULTS.gradientAngle) const gradientColor1 = str("gradientColor1", DOCS_GRAPHIC_DEFAULTS.gradientColor1) const gradientColor2 = str("gradientColor2", DOCS_GRAPHIC_DEFAULTS.gradientColor2) const gradientCssRaw = str("gradientCss", "") const gradientType: DocsGradientType = raw.gradientType === "radial" || raw.gradientType === "linear" ? raw.gradientType : gradientCssRaw.includes("radial-gradient") ? "radial" : DOCS_GRAPHIC_DEFAULTS.gradientType const gradientCss = gradientCssRaw || (raw.graphicType === "gradient" ? buildGradientCss(gradientAngle, gradientColor1, gradientColor2, gradientType) : "") // Absolute placement and behind/in-front wraps always live on the page layer. const forcedFixed = raw.placement === "absolute" || raw.wrap === "behind" || raw.wrap === "in-front" const positionMode: DocsGraphicPositionMode = forcedFixed ? "fixed-on-page" : raw.positionMode === "fixed-on-page" || raw.positionMode === "move-with-text" ? raw.positionMode : DOCS_GRAPHIC_DEFAULTS.positionMode const anchorRaw = raw.anchorPos const anchorPos = typeof anchorRaw === "number" && Number.isFinite(anchorRaw) ? anchorRaw : -1 const legacyX = num("x", 0) const legacyY = num("y", 0) // Node attrs always carry pageX/pageY defaults (0), so presence alone can't // signal migration: treat (0,0) page coords with non-zero legacy x/y as // unmigrated legacy positioning. const rawPageX = typeof raw.pageX === "number" && Number.isFinite(raw.pageX) ? raw.pageX : null const rawPageY = typeof raw.pageY === "number" && Number.isFinite(raw.pageY) ? raw.pageY : null const pageCoordsProvided = (rawPageX != null && rawPageX !== 0) || (rawPageY != null && rawPageY !== 0) const pageX = pageCoordsProvided ? (rawPageX ?? 0) : legacyX || (rawPageX ?? 0) const pageY = pageCoordsProvided ? (rawPageY ?? 0) : legacyY || (rawPageY ?? 0) return { graphicType: raw.graphicType === "shape" || raw.graphicType === "gradient" || raw.graphicType === "image" || raw.graphicType === "draw" ? raw.graphicType : DOCS_GRAPHIC_DEFAULTS.graphicType, src: typeof raw.src === "string" ? raw.src : null, alt: str("alt", ""), shapeType: raw.shapeType === "ellipse" || raw.shapeType === "line" || raw.shapeType === "arrow" || raw.shapeType === "rect" ? raw.shapeType : DOCS_GRAPHIC_DEFAULTS.shapeType, fill: str("fill", DOCS_GRAPHIC_DEFAULTS.fill), stroke: str("stroke", DOCS_GRAPHIC_DEFAULTS.stroke), strokeWidth: num("strokeWidth", DOCS_GRAPHIC_DEFAULTS.strokeWidth), gradientCss, gradientType, gradientAngle, gradientColor1, gradientColor2, width: Math.max(24, num("width", DOCS_GRAPHIC_DEFAULTS.width)), height: Math.max(24, num("height", DOCS_GRAPHIC_DEFAULTS.height)), placement: raw.placement === "inline" || raw.placement === "block" || raw.placement === "absolute" ? raw.placement : DOCS_GRAPHIC_DEFAULTS.placement, wrap: raw.wrap === "inline" || raw.wrap === "square" || raw.wrap === "tight" || raw.wrap === "through" || raw.wrap === "top-bottom" || raw.wrap === "behind" || raw.wrap === "in-front" ? raw.wrap : DOCS_GRAPHIC_DEFAULTS.wrap, floatSide: raw.floatSide === "left" || raw.floatSide === "right" || raw.floatSide === "center" ? raw.floatSide : DOCS_GRAPHIC_DEFAULTS.floatSide, assetId: typeof raw.assetId === "string" && raw.assetId ? raw.assetId : null, x: legacyX, y: legacyY, positionMode, anchorPos, pageIndex: Math.max(0, num("pageIndex", 0)), pageX, pageY, wrapMarginMm: Math.max(0, num("wrapMarginMm", DOCS_GRAPHIC_DEFAULTS.wrapMarginMm)), rotationDeg: num("rotationDeg", 0), zIndex: num("zIndex", 0), cropX: clamp01(num("cropX", 0)), cropY: clamp01(num("cropY", 0)), cropWidth: clamp01(num("cropWidth", 1), 1), cropHeight: clamp01(num("cropHeight", 1), 1), cropShape: raw.cropShape === "ellipse" ? "ellipse" : "rect", lockAspectRatio: raw.lockAspectRatio === false ? false : true, imageFit: raw.imageFit === "cover" || raw.imageFit === "crop" ? raw.imageFit : "contain", imageFitAnchorH: parseImageFitAnchor(raw.imageFitAnchorH, DOCS_GRAPHIC_DEFAULTS.imageFitAnchorH), imageFitAnchorV: parseImageFitAnchor(raw.imageFitAnchorV, DOCS_GRAPHIC_DEFAULTS.imageFitAnchorV), opacity: clamp01(num("opacity", 1), 1), shadow: str("shadow", ""), brightness: clampSigned(num("brightness", 0)), contrast: clampSigned(num("contrast", 0)), recolor: str("recolor", ""), altTitle: str("altTitle", ""), drawScene: typeof raw.drawScene === "string" && raw.drawScene ? raw.drawScene : null, drawDriveFileId: typeof raw.drawDriveFileId === "string" && raw.drawDriveFileId ? raw.drawDriveFileId : null, } } function clampSigned(value: number): number { if (!Number.isFinite(value)) return 0 return Math.min(1, Math.max(-1, value)) } function clamp01(value: number, fallback = 0): number { if (!Number.isFinite(value)) return fallback return Math.min(1, Math.max(0, value)) } function parseImageFitAnchor(value: unknown, fallback: DocsImageFitAnchor): DocsImageFitAnchor { if (value === 0 || value === 0.5 || value === 1) return value if (typeof value === "number" && Number.isFinite(value)) { if (value <= 0.25) return 0 if (value >= 0.75) return 1 return 0.5 } return fallback } function anchorOffset(anchor: DocsImageFitAnchor, frameSize: number, contentSize: number): number { return anchor * (frameSize - contentSize) } export function layoutFitForImage( fit: DocsImageFit ): DocsImageLayoutFit { return fit === "cover" ? "cover" : "contain" } export function usesCropImageFit(fit: DocsImageFit): boolean { return fit === "crop" } export function computeImageFitStyle( attrs: Pick ): { objectFit: DocsImageLayoutFit; objectPosition: string } { const layoutFit = layoutFitForImage(attrs.imageFit) return { objectFit: layoutFit, objectPosition: `${attrs.imageFitAnchorH * 100}% ${attrs.imageFitAnchorV * 100}%`, } } export function hasActiveCrop( attrs: Pick ): boolean { return ( attrs.cropX > 0 || attrs.cropY > 0 || attrs.cropWidth < 1 || attrs.cropHeight < 1 ) } /** Pixel rect where object-fit contain/cover image draws inside its frame. */ export function computeImageContentRect( frameWidth: number, frameHeight: number, naturalWidth: number, naturalHeight: number, fit: DocsImageLayoutFit = "contain", anchorH: DocsImageFitAnchor = 0.5, anchorV: DocsImageFitAnchor = 0.5 ): { left: number; top: number; width: number; height: number } { if (frameWidth <= 0 || frameHeight <= 0 || naturalWidth <= 0 || naturalHeight <= 0) { return { left: 0, top: 0, width: Math.max(frameWidth, 1), height: Math.max(frameHeight, 1) } } const frameAspect = frameWidth / frameHeight const imageAspect = naturalWidth / naturalHeight if (fit === "cover") { if (imageAspect > frameAspect) { const width = frameHeight * imageAspect const height = frameHeight return { left: anchorOffset(anchorH, frameWidth, width), top: 0, width, height, } } const width = frameWidth const height = frameWidth / imageAspect return { left: 0, top: anchorOffset(anchorV, frameHeight, height), width, height, } } if (imageAspect > frameAspect) { const width = frameWidth const height = frameWidth / imageAspect return { left: 0, top: anchorOffset(anchorV, frameHeight, height), width, height, } } const width = frameHeight * imageAspect const height = frameHeight return { left: anchorOffset(anchorH, frameWidth, width), top: 0, width, height: frameHeight, } } const MIN_CROP_SIZE = 0.05 /** Resize normalized crop region from pointer delta; uses drag origin, not live attrs. */ export function resizeCropRegion( handle: string, origin: Pick, dxNorm: number, dyNorm: number ): Pick { let cropX = origin.cropX let cropY = origin.cropY let cropWidth = origin.cropWidth let cropHeight = origin.cropHeight if (handle.includes("w")) { const dx = Math.max(-origin.cropX, Math.min(origin.cropWidth - MIN_CROP_SIZE, dxNorm)) cropX = origin.cropX + dx cropWidth = origin.cropWidth - dx } else if (handle.includes("e")) { cropWidth = Math.max(MIN_CROP_SIZE, Math.min(1 - origin.cropX, origin.cropWidth + dxNorm)) } if (handle.includes("n")) { const dy = Math.max(-origin.cropY, Math.min(origin.cropHeight - MIN_CROP_SIZE, dyNorm)) cropY = origin.cropY + dy cropHeight = origin.cropHeight - dy } else if (handle.includes("s")) { cropHeight = Math.max(MIN_CROP_SIZE, Math.min(1 - origin.cropY, origin.cropHeight + dyNorm)) } return { cropX: roundCrop(cropX), cropY: roundCrop(cropY), cropWidth: roundCrop(cropWidth), cropHeight: roundCrop(cropHeight), } } function roundCrop(value: number): number { return Math.round(value * 10000) / 10000 } export type CropDisplayGeometry = { /** Full source image draw rect (may extend past the frame in cover mode). */ imageRect: { left: number; top: number; width: number; height: number } /** Crop window matching the image as currently shown in the document. */ windowRect: { left: number; top: number; width: number; height: number } cropRect: { left: number; top: number; width: number; height: number } } /** Crop window = displayed image bounds; crop handles shrink from that origin. */ export function computeCropEditWindowRect( attrs: Pick, frameWidth: number, frameHeight: number, naturalWidth: number, naturalHeight: number ): { left: number; top: number; width: number; height: number } { if (attrs.imageFit === "crop") { return { left: 0, top: 0, width: frameWidth, height: frameHeight } } const layoutFit = layoutFitForImage(attrs.imageFit) const imageRect = computeImageContentRect( frameWidth, frameHeight, naturalWidth, naturalHeight, layoutFit, attrs.imageFitAnchorH, attrs.imageFitAnchorV ) if (layoutFit === "cover") { return { left: 0, top: 0, width: frameWidth, height: frameHeight } } return imageRect } /** Full source image rect when a baked crop is shown in the frame. */ export function computeBakedCropImageRect( sourceCrop: Pick, frameWidth: number, _frameHeight: number, naturalWidth: number, naturalHeight: number ): { left: number; top: number; width: number; height: number } { const cropWpx = Math.max(sourceCrop.cropWidth, 0.01) * naturalWidth const scale = frameWidth / cropWpx return { left: -(sourceCrop.cropX * naturalWidth * scale), top: -(sourceCrop.cropY * naturalHeight * scale), width: naturalWidth * scale, height: naturalHeight * scale, } } /** Image draw area + crop window in frame pixel coordinates. */ export function computeCropDisplayGeometry( attrs: Pick< DocsGraphicAttrs, | "cropX" | "cropY" | "cropWidth" | "cropHeight" | "imageFit" | "imageFitAnchorH" | "imageFitAnchorV" >, frameWidth: number, frameHeight: number, naturalWidth: number, naturalHeight: number, baseSource?: Pick | null ): CropDisplayGeometry { if (baseSource && hasActiveCrop(baseSource)) { const imageRect = computeBakedCropImageRect( baseSource, frameWidth, frameHeight, naturalWidth, naturalHeight ) const windowRect = imageRect return { imageRect, windowRect, cropRect: { left: windowRect.left + attrs.cropX * windowRect.width, top: windowRect.top + attrs.cropY * windowRect.height, width: Math.max(1, attrs.cropWidth * windowRect.width), height: Math.max(1, attrs.cropHeight * windowRect.height), }, } } const editFit: DocsImageFit = attrs.imageFit === "crop" ? "contain" : attrs.imageFit const layoutFit = layoutFitForImage(editFit) const imageRect = computeImageContentRect( frameWidth, frameHeight, naturalWidth, naturalHeight, layoutFit, attrs.imageFitAnchorH, attrs.imageFitAnchorV ) const windowRect = computeCropEditWindowRect( { ...attrs, imageFit: editFit }, frameWidth, frameHeight, naturalWidth, naturalHeight ) return { imageRect, windowRect, cropRect: { left: windowRect.left + attrs.cropX * windowRect.width, top: windowRect.top + attrs.cropY * windowRect.height, width: Math.max(1, attrs.cropWidth * windowRect.width), height: Math.max(1, attrs.cropHeight * windowRect.height), }, } } /** Map window-relative crop (edit session) to source-normalized 0–1 coords. */ export function windowCropInGeometryToSource( windowCrop: Pick, imageRect: { left: number; top: number; width: number; height: number }, windowRect: { left: number; top: number; width: number; height: number } ): Pick { const cropFrameLeft = windowRect.left + windowCrop.cropX * windowRect.width const cropFrameTop = windowRect.top + windowCrop.cropY * windowRect.height const cropFrameW = Math.max(windowCrop.cropWidth * windowRect.width, 1) const cropFrameH = Math.max(windowCrop.cropHeight * windowRect.height, 1) const cropX = (cropFrameLeft - imageRect.left) / Math.max(imageRect.width, 1) const cropY = (cropFrameTop - imageRect.top) / Math.max(imageRect.height, 1) const cropWidth = cropFrameW / Math.max(imageRect.width, 1) const cropHeight = cropFrameH / Math.max(imageRect.height, 1) const clampedX = Math.min(Math.max(cropX, 0), 1) const clampedY = Math.min(Math.max(cropY, 0), 1) return { cropX: roundCrop(clampedX), cropY: roundCrop(clampedY), cropWidth: roundCrop(Math.min(Math.max(cropWidth, MIN_CROP_SIZE), 1 - clampedX)), cropHeight: roundCrop(Math.min(Math.max(cropHeight, MIN_CROP_SIZE), 1 - clampedY)), } } /** Initial window-relative crop when re-opening edit on a baked crop. */ export function computeCropReeditInitialRegion( baseSource: Pick, frameWidth: number, frameHeight: number, naturalWidth: number, naturalHeight: number ): Pick { const imageRect = computeBakedCropImageRect( baseSource, frameWidth, frameHeight, naturalWidth, naturalHeight ) const cropX = Math.min(Math.max(-imageRect.left / Math.max(imageRect.width, 1), 0), 1) const cropY = Math.min(Math.max(-imageRect.top / Math.max(imageRect.height, 1), 0), 1) const cropWidth = Math.min( Math.max(frameWidth / Math.max(imageRect.width, 1), MIN_CROP_SIZE), 1 - cropX ) const cropHeight = Math.min( Math.max(frameHeight / Math.max(imageRect.height, 1), MIN_CROP_SIZE), 1 - cropY ) return { cropX: roundCrop(cropX), cropY: roundCrop(cropY), cropWidth: roundCrop(cropWidth), cropHeight: roundCrop(cropHeight), } } /** Narrow an existing source crop with a window-relative edit crop. */ export function compositeSourceCrop( base: Pick, window: Pick ): Pick { return { cropX: roundCrop(base.cropX + window.cropX * base.cropWidth), cropY: roundCrop(base.cropY + window.cropY * base.cropHeight), cropWidth: roundCrop(base.cropWidth * window.cropWidth), cropHeight: roundCrop(base.cropHeight * window.cropHeight), } } /** Merge session pan into persisted crop origin on apply. */ export function mergeCropPanIntoAttrs( attrs: Pick, panX: number, panY: number ): Pick { return { cropX: roundCrop( Math.min(Math.max(attrs.cropX + panX, 0), Math.max(0, 1 - attrs.cropWidth)) ), cropY: roundCrop( Math.min(Math.max(attrs.cropY + panY, 0), Math.max(0, 1 - attrs.cropHeight)) ), } } /** Frame size + attrs after applying a crop edit (window becomes output size). */ export function computeCropApplyPatch( attrs: Pick< DocsGraphicAttrs, | "cropX" | "cropY" | "cropWidth" | "cropHeight" | "imageFit" | "imageFitAnchorH" | "imageFitAnchorV" | "width" | "height" >, panX: number, panY: number, naturalWidth: number, naturalHeight: number, baseSource?: Pick | null ): Pick { const merged = { ...attrs, ...mergeCropPanIntoAttrs(attrs, panX, panY) } const geometry = computeCropDisplayGeometry( merged, attrs.width, attrs.height, naturalWidth, naturalHeight, baseSource ) const sourceCrop = windowCropInGeometryToSource( merged, geometry.imageRect, geometry.windowRect ) return { cropX: sourceCrop.cropX, cropY: sourceCrop.cropY, cropWidth: sourceCrop.cropWidth, cropHeight: sourceCrop.cropHeight, width: Math.max(24, Math.round(geometry.cropRect.width)), height: Math.max(24, Math.round(geometry.cropRect.height)), imageFit: "crop", } } /** Crop region maps 1:1 to frame (frame aspect matches crop after apply). */ export function computeCropImageStyle( attrs: Pick< DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight" | "cropShape" >, frameWidth: number, frameHeight: number, naturalWidth: number, naturalHeight: number ): { img: Record; clipPath?: string } { if (!hasActiveCrop(attrs)) return { img: {} } if (frameWidth <= 0 || frameHeight <= 0 || naturalWidth <= 0 || naturalHeight <= 0) { return { img: {} } } const imageRect = computeBakedCropImageRect( attrs, frameWidth, frameHeight, naturalWidth, naturalHeight ) const img: Record = { position: "absolute", left: imageRect.left, top: imageRect.top, width: imageRect.width, height: imageRect.height, maxWidth: "none", } const clipPath = attrs.cropShape === "ellipse" ? "ellipse(50% 50% at 50% 50%)" : undefined return { img, clipPath } } /** Pan the source image under a fixed crop window (drag follows pointer). */ export function panCropRegion( origin: Pick, dxNorm: number, dyNorm: number ): Pick { return { cropX: roundCrop( Math.min(Math.max(origin.cropX + dxNorm, 0), Math.max(0, 1 - origin.cropWidth)) ), cropY: roundCrop( Math.min(Math.max(origin.cropY + dyNorm, 0), Math.max(0, 1 - origin.cropHeight)) ), } } export function imageAttrsToGraphic(raw: Record): Partial { const width = typeof raw.width === "number" ? raw.width : typeof raw.width === "string" ? Number(raw.width) || DOCS_GRAPHIC_DEFAULTS.width : DOCS_GRAPHIC_DEFAULTS.width const height = typeof raw.height === "number" ? raw.height : typeof raw.height === "string" ? Number(raw.height) || DOCS_GRAPHIC_DEFAULTS.height : DOCS_GRAPHIC_DEFAULTS.height return parseGraphicAttrs({ graphicType: "image", src: raw.src, alt: raw.alt ?? "", width, height, placement: raw.placement ?? "inline", wrap: raw.wrap ?? "inline", floatSide: raw.floatSide ?? "left", x: raw.x ?? 0, y: raw.y ?? 0, rotationDeg: raw.rotationDeg ?? 0, zIndex: raw.zIndex ?? 0, cropX: raw.cropX ?? 0, cropY: raw.cropY ?? 0, cropWidth: raw.cropWidth ?? 1, cropHeight: raw.cropHeight ?? 1, cropShape: raw.cropShape ?? "rect", assetId: raw.assetId ?? null, opacity: raw.opacity ?? 1, shadow: raw.shadow ?? "", }) }