export type DocsGraphicType = "image" | "shape" | "gradient" export type DocsGraphicPlacement = "inline" | "block" | "absolute" /** 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 DocsGraphicAttrs = { graphicType: DocsGraphicType src: string | null alt: string assetId: string | null shapeType: DocsShapeType fill: string stroke: string strokeWidth: number gradientCss: string gradientAngle: number gradientColor1: string gradientColor2: string width: number height: number placement: DocsGraphicPlacement wrap: DocsGraphicWrap floatSide: DocsGraphicFloatSide x: number y: number rotationDeg: number zIndex: number /** Normalized crop region 0–1 relative to source image */ cropX: number cropY: number cropWidth: number cropHeight: number cropShape: DocsCropShape opacity: number shadow: string } export const DOCS_GRAPHIC_DEFAULTS: DocsGraphicAttrs = { graphicType: "image", src: null, alt: "", assetId: null, shapeType: "rect", fill: "#4285f4", stroke: "#1a73e8", strokeWidth: 2, gradientCss: "", gradientAngle: 180, gradientColor1: "#4285f4", gradientColor2: "#34a853", width: 240, height: 160, placement: "block", wrap: "square", floatSide: "left", x: 0, y: 0, rotationDeg: 0, zIndex: 0, cropX: 0, cropY: 0, cropWidth: 1, cropHeight: 1, cropShape: "rect", opacity: 1, shadow: "", } 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 function buildGradientCss( angle: number, color1: string, color2: string ): string { 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 gradientCss = str("gradientCss", "") || (raw.graphicType === "gradient" ? buildGradientCss(gradientAngle, gradientColor1, gradientColor2) : "") return { graphicType: raw.graphicType === "shape" || raw.graphicType === "gradient" || raw.graphicType === "image" ? 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, 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: num("x", 0), y: num("y", 0), 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", opacity: clamp01(num("opacity", 1), 1), shadow: str("shadow", ""), } } function clamp01(value: number, fallback = 0): number { if (!Number.isFinite(value)) return fallback return Math.min(1, Math.max(0, value)) } /** CSS styles to render a cropped image inside its frame. */ export function computeCropImageStyle(attrs: Pick< DocsGraphicAttrs, "cropX" | "cropY" | "cropWidth" | "cropHeight" | "cropShape" >): { img: Record; clipPath?: string } { const hasCrop = attrs.cropX > 0 || attrs.cropY > 0 || attrs.cropWidth < 1 || attrs.cropHeight < 1 if (!hasCrop) return { img: {} } const scaleX = 1 / Math.max(attrs.cropWidth, 0.01) const scaleY = 1 / Math.max(attrs.cropHeight, 0.01) const offsetX = -(attrs.cropX * scaleX * 100) const offsetY = -(attrs.cropY * scaleY * 100) const img: Record = { width: `${scaleX * 100}%`, height: `${scaleY * 100}%`, maxWidth: "none", objectFit: "cover", objectPosition: `${offsetX}% ${offsetY}%`, } const clipPath = attrs.cropShape === "ellipse" ? "ellipse(50% 50% at 50% 50%)" : undefined return { img, clipPath } } 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 ?? "", }) }