/** * Pure intent-mapping gate for `edit_elements`. * * Takes model-proposed element updates + the trusted slide inventory and either * returns validated `EditIntent`s (`element.update` / `element.updateMany`) or a * refusal. Malformed / out-of-contract proposals never partially apply. * * No I/O, no React, no store — fixture-testable in isolation. */ import type { PPTElement } from '@openmaic/dsl'; import sceneSchemaJson from '@openmaic/dsl/schema/scene.schema.json'; import type { EditIntent } from '@openmaic/editor/core'; import tinycolor from 'tinycolor2'; import { MIN_SIZE } from '@/configs/element'; const DEFAULT_MIN_SIZE = 20; const LINE_STROKE_MIN = 1; const LINE_STROKE_MAX = 100; const GROUP_TRANSLATION_TOLERANCE = 1e-6; const MERGED_STYLE_PROPS = new Set(['outline', 'shadow', 'filters']); /** Sanity bounds so model JSON cannot park elements at 1e15. */ const COORD_MIN = -5000; const COORD_MAX = 20000; const FILTER_UNITS: Record = { blur: 'px', brightness: '%', contrast: '%', grayscale: '%', saturate: '%', 'hue-rotate': 'deg', sepia: '%', invert: '%', opacity: '%', }; const FILTER_RANGES: Record = { blur: [0, 100], brightness: [0, 1000], contrast: [0, 1000], grayscale: [0, 100], saturate: [0, 1000], 'hue-rotate': [-36000, 36000], sepia: [0, 100], invert: [0, 100], opacity: [0, 100], }; const FILTER_IDENTITY: Record = { blur: 0, brightness: 100, contrast: 100, grayscale: 0, saturate: 100, 'hue-rotate': 0, sepia: 0, invert: 0, opacity: 100, }; /** Geometry + style props the AI may mutate. Content / identity are rejected. */ export const ALLOWED_EDIT_PROPS = new Set([ // geometry 'left', 'top', 'width', 'height', 'rotate', // shared chrome 'fill', 'opacity', 'outline', 'shadow', // text / shape text chrome (not HTML body) 'defaultColor', 'defaultFontName', 'lineHeight', 'wordSpace', 'paragraphSpace', 'vertical', 'vAlign', // line / latex / audio color 'color', // shape 'gradient', // image chrome 'filters', 'radius', 'flipH', 'flipV', 'colorMask', 'fixedRatio', // chart chrome 'themeColors', 'textColor', 'lineColor', // code chrome (not `lines` / not user-visible fileName) 'fontSize', 'showLineNumbers', ]); /** Props that must never be written by this vertical. */ export const FORBIDDEN_EDIT_PROPS = new Set([ 'id', 'type', 'lock', 'groupId', 'link', 'content', 'text', 'src', 'mediaRef', 'lines', 'latex', 'html', 'data', 'path', 'viewBox', 'pathFormula', 'keypoints', 'start', 'end', 'broken', 'broken2', 'curve', 'cubic', 'colWidths', 'rowHeights', 'cellMinHeight', 'animations', 'fileName', ]); /** Text-chrome keys that live under `shape.text` for shape elements. */ export const SHAPE_TEXT_CHROME_PROPS = new Set([ 'defaultColor', 'defaultFontName', 'lineHeight', 'wordSpace', 'paragraphSpace', ]); const TEXT_GLYPH_PROPS = new Set([...SHAPE_TEXT_CHROME_PROPS, 'vertical', 'shadow']); export type JsonSchema = { $ref?: string; anyOf?: JsonSchema[]; type?: string | string[]; const?: unknown; enum?: unknown[]; properties?: Record; required?: string[]; additionalProperties?: boolean | JsonSchema; items?: JsonSchema | JsonSchema[]; minimum?: number; maximum?: number; }; type SchemaDocument = { definitions?: Record; }; const sceneSchema = sceneSchemaJson as SchemaDocument; const schemaDefinitions = sceneSchema.definitions ?? {}; const ELEMENT_SCHEMA_DEFINITION_BY_TYPE = new Map([ ['text', 'PPTTextElement'], ['image', 'PPTImageElement'], ['shape', 'PPTShapeElement'], ['line', 'PPTLineElement'], ['chart', 'PPTChartElement'], ['table', 'PPTTableElement'], ['latex', 'PPTLatexElement'], ['video', 'PPTVideoElement'], ['audio', 'PPTAudioElement'], ['code', 'PPTCodeElement'], ]); const REQUIRED_SCHEMA_DEFINITIONS = [ 'Gradient', 'GradientColor', 'PPTElement', 'PPTElementOutline', 'PPTElementShadow', 'ShapeText', ...ELEMENT_SCHEMA_DEFINITION_BY_TYPE.values(), ]; const SHAPE_TEXT_SCHEMA_ALIASES = new Map([ ['defaultColor', 'defaultColor'], ['defaultFontName', 'defaultFontName'], ['lineHeight', 'lineHeight'], ['wordSpace', 'wordSpace'], ['paragraphSpace', 'paragraphSpace'], ['vAlign', 'align'], ]); function schemaSourceError(detail: string): Error { return new Error( `edit_elements gate schema sanity check failed: ${detail}. Rebuild @openmaic/dsl to regenerate dist/schema/scene.schema.json.`, ); } function assertGateSchemaSource(definitions: Record): void { for (const name of REQUIRED_SCHEMA_DEFINITIONS) { if (!definitions[name]) { throw schemaSourceError(`missing schema definition ${JSON.stringify(name)}`); } } const pptElementOptions = definitions.PPTElement?.anyOf ?? []; const pptElementRefs = new Set( pptElementOptions.map((option) => (option.$ref ? decodeRefName(option.$ref) : null)), ); for (const definitionName of ELEMENT_SCHEMA_DEFINITION_BY_TYPE.values()) { if (!pptElementRefs.has(definitionName)) { throw schemaSourceError(`PPTElement union missing ${JSON.stringify(definitionName)}`); } } } function decodeRefName(ref: string): string | null { const m = ref.match(/^#\/definitions\/(.+)$/); return m ? m[1].replace(/~1/g, '/').replace(/~0/g, '~') : null; } assertGateSchemaSource(schemaDefinitions); function resolveSchema(schema: JsonSchema, seen = new Set()): JsonSchema { if (!schema.$ref) return schema; const name = decodeRefName(schema.$ref); if (!name || seen.has(name)) return schema; const target = schemaDefinitions[name]; if (!target) return schema; seen.add(name); return resolveSchema(target, seen); } function elementTypeFromSchema(schema: JsonSchema): string | null { const typeSchema = resolveSchema(schema.properties?.type ?? {}); return typeof typeSchema.const === 'string' ? typeSchema.const : null; } /** * Lockstep with packages/@openmaic/dsl/scripts/gen-schema.mjs: the package * export @openmaic/dsl/schema/scene.schema.json is generated from slides.ts. */ function buildEditablePropSchemas(): Map> { const out = new Map>(); const elementUnion = resolveSchema(schemaDefinitions.PPTElement ?? {}); for (const option of elementUnion.anyOf ?? []) { const elementSchema = resolveSchema(option); const elementType = elementTypeFromSchema(elementSchema); if (!elementType || !elementSchema.properties) continue; out.set(elementType, new Map(Object.entries(elementSchema.properties))); } const shapeProps = out.get('shape'); const shapeTextSchema = shapeProps?.get('text'); const shapeTextProps = shapeTextSchema ? resolveSchema(shapeTextSchema).properties : undefined; if (shapeProps && shapeTextProps) { for (const [flatProp, schemaProp] of SHAPE_TEXT_SCHEMA_ALIASES) { const subschema = shapeTextProps[schemaProp]; if (subschema) shapeProps.set(flatProp, subschema); } } return out; } const EDITABLE_PROP_SCHEMAS_BY_TYPE = buildEditablePropSchemas(); for (const type of ELEMENT_SCHEMA_DEFINITION_BY_TYPE.keys()) { if (!EDITABLE_PROP_SCHEMAS_BY_TYPE.has(type)) { throw schemaSourceError(`unable to map editable schemas for ${JSON.stringify(type)} elements`); } } export function getEditablePropSchema(type: string, key: string): JsonSchema | null { if (!ALLOWED_EDIT_PROPS.has(key) || FORBIDDEN_EDIT_PROPS.has(key)) return null; return EDITABLE_PROP_SCHEMAS_BY_TYPE.get(type)?.get(key) ?? null; } /** Validate one complete element against the generated DSL schema. */ export function validateElementAgainstDslSchema(element: PPTElement): string | null { const definitionName = ELEMENT_SCHEMA_DEFINITION_BY_TYPE.get(element.type); const schema = definitionName ? schemaDefinitions[definitionName] : undefined; if (!schema) return `element type ${JSON.stringify(element.type)} is out of contract`; return validateJsonSchemaSubset(element, schema, 'element'); } export interface ElementInventoryItem { id: string; type: string; left: number; top: number; width: number; height?: number; rotate?: number; lock?: boolean; groupId?: string; /** Short human-readable label for the model (stripped text / name / type). */ label: string; /** Style props currently on the element that the DSL owns and AI may edit. */ style: Record; /** True when element-level defaultColor would be hidden by inline HTML color. */ hasInlineTextColor?: boolean; /** True when element-level defaultFontName would be hidden by inline HTML font-family. */ hasInlineFontFamily?: boolean; /** True when element-level lineHeight would be hidden by inline HTML line-height. */ hasInlineLineHeight?: boolean; /** True when element-level wordSpace would be hidden by inline HTML letter-spacing. */ hasInlineLetterSpacing?: boolean; /** True when element-level paragraphSpace would be hidden by inline HTML margin-bottom. */ hasInlineParagraphSpacing?: boolean; /** True when element-level vertical writing mode would be hidden by descendant CSS. */ hasInlineWritingMode?: boolean; /** True when text-element shadow would be hidden by descendant text-shadow CSS. */ hasInlineTextShadow?: boolean; /** Image clip shape used to decide whether radius is visually supported. */ imageClipShape?: string; /** True when a line has a non-zero start/end segment. */ lineHasVisiblePath?: boolean; /** True when a zero-length line can still paint an endpoint marker. */ lineHasEndpointMarker?: boolean; /** True when shape pattern paint is present and fill/gradient application would remove it. */ hasPattern?: boolean; /** True when a shape currently has visible label text. */ hasShapeText?: boolean; /** True when inline KaTeX HTML color would override the latex container color. */ hasInlineLatexColor?: boolean; /** True when imported cell-level borders make table outline cross-renderer inconsistent. */ hasCellBorders?: boolean; /** Chart subtype used for renderer capability checks. */ chartType?: string; /** True when chart data produces a renderer option instead of null. */ hasRenderableChartData?: boolean; /** Number of leading palette colors consumed by the current chart data. */ chartColorCount?: number; /** True when a text element currently has visible glyph content. */ hasTextGlyphs?: boolean; /** True when text has layout-visible characters, even if their paint is transparent. */ hasTextContent?: boolean; /** True when a latex element has a visible HTML or SVG rendering branch. */ hasRenderableLatex?: boolean; /** Active latex renderer branch. */ latexRenderMode?: 'html' | 'svg' | 'none'; } const TEXT_VISIBILITY_SOURCE = Symbol('textVisibilitySource'); type TextVisibilitySource = { content: string; colorProp: 'defaultColor' | 'color'; color?: unknown; opacity?: unknown; shadow?: { h?: unknown; v?: unknown; blur?: unknown; color?: unknown }; }; type InternalElementInventoryItem = ElementInventoryItem & { [TEXT_VISIBILITY_SOURCE]?: TextVisibilitySource; }; export interface ProposedElementUpdate { id: string; props: Record; } export type EditElementsGateResult = | { ok: true; intents: EditIntent[] } | { ok: false; reason: string }; function isFiniteNumber(v: unknown): v is number { return typeof v === 'number' && Number.isFinite(v); } function isColorString(v: unknown): boolean { if (typeof v !== 'string' || v.length > 64) return false; const value = v.trim(); const lower = value.toLowerCase(); const number = String.raw`[+-]?(?:\d+(?:\.\d*)?|\.\d+)`; const percentage = `${number}%`; const rgbNumbers = `${number}\\s*,\\s*${number}\\s*,\\s*${number}`; const rgbPercentages = `${percentage}\\s*,\\s*${percentage}\\s*,\\s*${percentage}`; const alpha = number; const hue = `${number}(?:deg)?`; const functional = new RegExp(`^rgb\\(\\s*(?:${rgbNumbers}|${rgbPercentages})\\s*\\)$`, 'i').test(value) || new RegExp( `^rgba\\(\\s*(?:${rgbNumbers}|${rgbPercentages})\\s*,\\s*${alpha}\\s*\\)$`, 'i', ).test(value) || new RegExp(`^hsl\\(\\s*${hue}\\s*,\\s*${percentage}\\s*,\\s*${percentage}\\s*\\)$`, 'i').test( value, ) || new RegExp( `^hsla\\(\\s*${hue}\\s*,\\s*${percentage}\\s*,\\s*${percentage}\\s*,\\s*${alpha}\\s*\\)$`, 'i', ).test(value); const syntaxIsCssColor = /^#[0-9a-f]{3,4}(?:[0-9a-f]{3,4})?$/i.test(value) || functional || lower === 'transparent' || Object.hasOwn(tinycolor.names, lower); return syntaxIsCssColor && tinycolor(value).isValid(); } function isNonEmptyString(v: unknown, max = 200): v is string { return typeof v === 'string' && v.length > 0 && v.length <= max; } function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value); } function deepEqual(a: unknown, b: unknown): boolean { if (Object.is(a, b)) return true; if (Array.isArray(a) && Array.isArray(b)) { return a.length === b.length && a.every((value, index) => deepEqual(value, b[index])); } if (isRecord(a) && isRecord(b)) { const aKeys = Object.keys(a); const bKeys = Object.keys(b); return ( aKeys.length === bKeys.length && aKeys.every((key) => Object.hasOwn(b, key) && deepEqual(a[key], b[key])) ); } return false; } const PRO_EDITOR_SUPPORTED_TYPES = new Set([ 'text', 'image', 'shape', 'line', 'chart', 'table', 'latex', 'video', ]); function schemaTypeMatches(value: unknown, type: string): boolean { switch (type) { case 'string': return typeof value === 'string'; case 'number': return isFiniteNumber(value); case 'boolean': return typeof value === 'boolean'; case 'object': return value !== null && typeof value === 'object' && !Array.isArray(value); case 'array': return Array.isArray(value); default: return true; } } function describeSchemaTypes(type: string | string[]): string { return Array.isArray(type) ? type.join('|') : type; } const SUPPORTED_SCHEMA_KEYWORDS = new Set([ '$ref', 'anyOf', 'type', 'const', 'enum', 'properties', 'required', 'additionalProperties', 'items', 'minimum', 'maximum', ]); function propPath(path: string): string { return path.startsWith('prop ') ? path : `prop ${path}`; } function unsupportedSchemaConstruct(path: string): string { return `${propPath(path)} uses a schema construct the gate cannot validate`; } function isUnsupportedSchemaConstruct(err: string | null): boolean { return err?.includes('uses a schema construct the gate cannot validate') ?? false; } function lacksSupportedSchemaKeywords(schema: JsonSchema): boolean { const keys = Object.keys(schema); return keys.length > 0 && !keys.some((key) => SUPPORTED_SCHEMA_KEYWORDS.has(key)); } export function validateJsonSchemaSubset( value: unknown, schema: JsonSchema, path: string, ): string | null { const s = resolveSchema(schema); if (s.$ref || lacksSupportedSchemaKeywords(s)) return unsupportedSchemaConstruct(path); if ('anyOf' in s) { if (!Array.isArray(s.anyOf) || s.anyOf.length === 0) { return unsupportedSchemaConstruct(path); } const errors = s.anyOf .map((option) => validateJsonSchemaSubset(value, option, path)) .filter(Boolean); if (errors.length !== s.anyOf.length) return null; return errors.find(isUnsupportedSchemaConstruct) ?? `${path} does not match any allowed schema`; } if ('const' in s && !Object.is(value, s.const)) { return `${path} must be ${JSON.stringify(s.const)}`; } if (s.enum && !s.enum.some((item) => Object.is(item, value))) { return `${path} must be one of ${s.enum.map((item) => JSON.stringify(item)).join('|')}`; } if (s.type) { const types = Array.isArray(s.type) ? s.type : [s.type]; if (!types.some((type) => schemaTypeMatches(value, type))) { return types.includes('number') ? `${path} must be a finite number` : `${path} must be ${describeSchemaTypes(s.type)}`; } } if (typeof value === 'number') { if (!Number.isFinite(value)) return `${path} must be a finite number`; if (typeof s.minimum === 'number' && value < s.minimum) { return `${path} out of bounds (${s.minimum}..${s.maximum ?? '∞'})`; } if (typeof s.maximum === 'number' && value > s.maximum) { return `${path} out of bounds (${s.minimum ?? '-∞'}..${s.maximum})`; } } if (s.properties) { if (value === null || typeof value !== 'object' || Array.isArray(value)) { return `${path} must be object`; } const obj = value as Record; for (const required of s.required ?? []) { if (!(required in obj)) return `${path}.${required} is required`; } if (s.additionalProperties === false) { for (const key of Object.keys(obj)) { if (!s.properties[key]) return `${path}.${key} is out of contract`; } } for (const [key, nested] of Object.entries(s.properties)) { if (key in obj) { const err = validateJsonSchemaSubset(obj[key], nested, `${path}.${key}`); if (err) return err; } } } if (s.items) { if (!Array.isArray(value)) return `${path} must be array`; if (Array.isArray(s.items)) { for (let i = 0; i < value.length; i++) { const itemSchema = s.items[Math.min(i, s.items.length - 1)]; if (!itemSchema) continue; const err = validateJsonSchemaSubset(value[i], itemSchema, `${path}[${i}]`); if (err) return err; } } else { for (let i = 0; i < value.length; i++) { const err = validateJsonSchemaSubset(value[i], s.items, `${path}[${i}]`); if (err) return err; } } } return null; } /** Signed angle in (-180, 180], matching the rotate gesture core. */ export function normalizeRotate(degrees: number): number { let r = degrees % 360; if (r > 180) r -= 360; if (r <= -180) r += 360; return r; } function minSizeFor(type: string): number { return MIN_SIZE[type] ?? DEFAULT_MIN_SIZE; } function clampCoord(n: number, label: string): number { if (n < COORD_MIN || n > COORD_MAX) { throw new Error(`${label} out of bounds (${COORD_MIN}..${COORD_MAX})`); } return n; } const COLOR_STRING_PROPS = new Set([ 'fill', 'defaultColor', 'color', 'textColor', 'lineColor', 'colorMask', ]); /** * Structural, enum, and ownership mismatches refuse so the model learns the * contract; numeric overshoot on a valid prop clamps later, matching gestures. */ function validatePolicyOverlay(key: string, value: unknown): string | null { if (COLOR_STRING_PROPS.has(key)) { return isColorString(value) ? null : `${key} must be a color string`; } switch (key) { case 'defaultFontName': return isNonEmptyString(value, 80) ? null : `${key} must be a non-empty string`; case 'opacity': return isFiniteNumber(value) ? null : 'opacity must be a finite number'; case 'lineHeight': if (!isFiniteNumber(value)) return `${key} must be a finite number`; if (value < 1 || value > 3) return `${key} out of bounds (1..3)`; return null; case 'wordSpace': case 'paragraphSpace': if (!isFiniteNumber(value)) return `${key} must be a finite number`; if (value < 0 || value > 100) return `${key} out of bounds (0..100)`; return null; case 'radius': if (!isFiniteNumber(value)) return `${key} must be a finite number`; if (value < 0 || value > 500) return `${key} out of bounds (0..500)`; return null; case 'fontSize': if (!isFiniteNumber(value)) return `${key} must be a finite number`; if (value < 8 || value > 200) return `${key} out of bounds (8..200)`; return null; case 'outline': { const o = value as Record; if (Object.keys(o).length === 0) return 'outline must contain at least one property'; if ('width' in o && (!isFiniteNumber(o.width) || o.width < 0 || o.width > LINE_STROKE_MAX)) { return 'outline.width must be a finite number in range'; } if ('color' in o && !isColorString(o.color)) return 'outline.color must be a color string'; return null; } case 'shadow': { const o = value as Record; if (!isColorString(o.color)) return 'shadow.color must be a color string'; if (!isFiniteNumber(o.blur) || o.blur < 0) { return 'shadow.blur must be a non-negative finite number'; } return null; } case 'gradient': { const o = value as { colors?: unknown[] }; if (!Array.isArray(o.colors) || o.colors.length === 0 || o.colors.length > 10) { return 'gradient.colors must contain 1..10 stops'; } for (let i = 0; i < o.colors.length; i++) { const stop = o.colors[i] as Record; if (!isFiniteNumber(stop.pos) || stop.pos < 0 || stop.pos > 100) { return `gradient.colors[${i}].pos must be a number in [0,100]`; } if (!isColorString(stop.color)) { return `gradient.colors[${i}].color must be a color string`; } } return null; } case 'filters': { if (Object.keys(value as Record).length === 0) { return 'filters must contain at least one property'; } for (const [filterKey, filterValue] of Object.entries(value as Record)) { if (typeof filterValue !== 'string') return `filters.${filterKey} must be a string`; if (filterValue.length > 40) return `filters.${filterKey} must be at most 40 chars`; const unit = FILTER_UNITS[filterKey]; const match = filterValue.trim().match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+))(px|%|deg)?$/); if (!match || (match[2] && match[2] !== unit)) { return `filters.${filterKey} must be a numeric string${unit ? ` with optional ${unit}` : ''}`; } const numeric = Number(match[1]); const [min, max] = FILTER_RANGES[filterKey] ?? [ Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY, ]; if (numeric < min || numeric > max) { return `filters.${filterKey} out of bounds (${min}..${max})`; } } return null; } case 'themeColors': if (!Array.isArray(value) || value.length === 0 || !value.every(isColorString)) { return 'themeColors must be a non-empty array of color strings'; } return null; default: return null; } } /** * Validate non-geometry prop values. Returns an error string or null. * Geometry is handled by clampUpdateProps. */ export function validatePropValue(key: string, value: unknown, type: string): string | null { const schema = getEditablePropSchema(type, key); if (!schema) return `${key} is not valid on ${type} elements`; const schemaErr = validateJsonSchemaSubset(value, schema, key); if (schemaErr) return schemaErr; return validatePolicyOverlay(key, value); } /** * Clamp geometry the same way gesture cores do: min size per type; rotate * normalized to (-180, 180]. Line `width` is stroke thickness (min 1), not box size. */ export function clampUpdateProps( type: string, props: Record, ): Record { const out: Record = { ...props }; if ('width' in out) { if (!isFiniteNumber(out.width)) throw new Error(`width must be a finite number`); const width = out.width; if (type === 'line') { out.width = Math.min(LINE_STROKE_MAX, Math.max(LINE_STROKE_MIN, width)); } else { const clampedWidth = Math.max(minSizeFor(type), width); out.width = clampCoord(clampedWidth, 'width'); } } if ('height' in out) { if (!isFiniteNumber(out.height)) throw new Error(`height must be a finite number`); if (type === 'line') throw new Error(`line elements have no height`); const height = out.height; const clampedHeight = Math.max(minSizeFor(type), height); out.height = clampCoord(clampedHeight, 'height'); } if ('left' in out) { if (!isFiniteNumber(out.left)) throw new Error(`left must be a finite number`); out.left = clampCoord(out.left, 'left'); } if ('top' in out) { if (!isFiniteNumber(out.top)) throw new Error(`top must be a finite number`); out.top = clampCoord(out.top, 'top'); } if ('rotate' in out) { if (type === 'line') throw new Error(`line elements have no rotate`); if (!isFiniteNumber(out.rotate)) throw new Error(`rotate must be a finite number`); out.rotate = normalizeRotate(out.rotate); } if ('opacity' in out) { if (!isFiniteNumber(out.opacity)) throw new Error(`opacity must be a finite number`); out.opacity = Math.min(1, Math.max(0, out.opacity)); } if ('filters' in out) { const filters = out.filters as Record; out.filters = Object.fromEntries( Object.entries(filters).map(([key, value]) => { const unit = FILTER_UNITS[key]; const trimmed = value.trim(); return [key, unit && trimmed.endsWith(unit) ? trimmed.slice(0, -unit.length) : trimmed]; }), ); } return out; } function validatePropsKeys(props: Record): string | null { const keys = Object.keys(props); if (keys.length === 0) return 'update has empty props'; for (const key of keys) { if (FORBIDDEN_EDIT_PROPS.has(key)) { return `prop ${JSON.stringify(key)} is not editable via edit_elements`; } if (!ALLOWED_EDIT_PROPS.has(key)) { return `prop ${JSON.stringify(key)} is out of contract`; } } return null; } function enforceGroupCohesion( updates: Array<{ id: string; props: Partial }>, inventory: ElementInventoryItem[], ): string | null { const byId = new Map(inventory.map((el) => [el.id, el])); const updateById = new Map(updates.map((update) => [update.id, update])); const targeted = new Set(updates.map((u) => u.id)); const checkedGroups = new Set(); for (const id of targeted) { const el = byId.get(id); if (!el?.groupId || checkedGroups.has(el.groupId)) continue; checkedGroups.add(el.groupId); const members = inventory.filter((x) => x.groupId === el.groupId); const missing = members.filter((member) => !targeted.has(member.id)).map((member) => member.id); if (missing.length > 0) { return `group ${JSON.stringify(el.groupId)} must be edited as a unit (missing ${missing.map((m) => JSON.stringify(m)).join(', ')})`; } for (const member of members) { const props = updateById.get(member.id)?.props ?? {}; if ('height' in props || 'rotate' in props || (member.type !== 'line' && 'width' in props)) { return `group ${JSON.stringify(el.groupId)} does not support resize or rotate edits`; } } for (const axis of ['left', 'top'] as const) { const deltas = members.map((member) => { const next = updateById.get(member.id)?.props[axis]; return (typeof next === 'number' ? next : member[axis]) - member[axis]; }); if (deltas.some((delta) => Math.abs(delta - deltas[0]) > GROUP_TRANSLATION_TOLERANCE)) { return `group ${JSON.stringify(el.groupId)} must use one rigid translation delta for ${axis}`; } } } return null; } function contextualProposalError( element: ElementInventoryItem, props: Record, composedTextVisible?: boolean, replacedProps?: ReadonlySet, ): string | null { if (!PRO_EDITOR_SUPPORTED_TYPES.has(element.type)) { return `${element.type} elements are not editable in the active Pro editor`; } if (element.type === 'text' && 'vAlign' in props) { return 'vAlign is not rendered for text elements in the active Pro editor'; } if (element.type === 'latex' && element.latexRenderMode === 'none') { return 'latex element has no renderable HTML or SVG branch'; } if ( element.type === 'text' && element.hasTextGlyphs === false && Object.keys(props).some((key) => TEXT_GLYPH_PROPS.has(key)) ) { if (composedTextVisible !== true) { return 'text glyph chrome is not visible without painted text content'; } } const hasIndividuallyEffectiveProp = Object.entries(props).some(([key, value]) => propChangesEffectiveState(element, key, value, replacedProps?.has(key)), ); if (element.type === 'text' && element.hasTextGlyphs === false && hasIndividuallyEffectiveProp) { const currentVisible = textBoxPaintVisible(element.style, {}); const nextVisible = composedTextVisible === true || textBoxPaintVisible(element.style, props); if (!currentVisible && !nextVisible) { return 'text element has no visible paint before or after the proposed update'; } } if ( (element.type === 'shape' || element.type === 'line' || element.type === 'latex') && hasIndividuallyEffectiveProp && !elementPaintVisible(element, {}) && !elementPaintVisible(element, props) ) { return `${element.type} element has no visible paint before or after the proposed update`; } if ( element.type === 'shape' && !element.hasShapeText && composedTextVisible !== true && Object.keys(props).some((key) => SHAPE_TEXT_CHROME_PROPS.has(key) || key === 'vAlign') ) { return 'shape text chrome is not visible without a shape label'; } if (element.type === 'latex' && 'color' in props && element.hasInlineLatexColor) { return 'inline KaTeX color overrides the latex element color'; } if ( element.type === 'latex' && element.latexRenderMode !== 'svg' && ('width' in props || 'height' in props) ) { return 'latex resizing is not rendered consistently across app and package surfaces'; } if (element.type === 'shape' && 'fill' in props && 'gradient' in props) { return 'fill and gradient are mutually exclusive shape paint edits'; } if (element.type === 'table' && 'outline' in props) { if (element.hasCellBorders) { return 'table outline is not consistent across renderers when cells have explicit borders'; } const patch = props.outline as Record; if (patch.style === 'dotted') { return 'dotted table outlines are not rendered distinctly from solid outlines'; } } if ( element.type === 'chart' && 'lineColor' in props && (element.chartType === 'pie' || element.chartType === 'ring') ) { return `lineColor is not rendered for ${element.chartType} charts`; } if ( element.type === 'chart' && element.hasRenderableChartData === false && Object.keys(props).some( (key) => key === 'themeColors' || key === 'textColor' || key === 'lineColor', ) ) { return 'chart colors are not visible without a non-empty data series'; } if ('outline' in props) { const hasCurrentOutline = isRecord(element.style.outline); const current: Record = hasCurrentOutline ? (element.style.outline as Record) : {}; const patch = props.outline as Record; const next = replacedProps?.has('outline') ? patch : { ...current, ...patch }; const currentWidth = isFiniteNumber(current.width) ? current.width : element.type === 'table' && hasCurrentOutline ? 1 : 0; const nextWidth = isFiniteNumber(next.width) ? next.width : element.type === 'table' ? 1 : 0; if (currentWidth === 0 && nextWidth === 0) { return 'outline edit has no visible width'; } } if ('radius' in props && element.type === 'image') { const clipShape = element.imageClipShape ?? 'rect'; if (clipShape !== 'rect' && clipShape !== 'roundRect') { return `radius is not rendered for image clip ${JSON.stringify(clipShape)}`; } } return null; } function visibleColor(value: unknown): boolean { return isColorString(value) && canonicalColor(value) !== canonicalColor('transparent'); } function visibleOutline( current: Record, patch: Record | undefined, ): boolean { const outline = patch ? { ...current, ...patch } : current; return ( isFiniteNumber(outline.width) && outline.width > 0 && visibleColor(outline.color ?? '#d14424') ); } function textBoxPaintVisible( style: Record, props: Record, ): boolean { const opacity = 'opacity' in props ? props.opacity : (style.opacity ?? 1); if (!isFiniteNumber(opacity) || Math.min(1, Math.max(0, opacity)) === 0) return false; const fill = 'fill' in props ? props.fill : style.fill; const currentOutline = isRecord(style.outline) ? style.outline : {}; const outlinePatch = isRecord(props.outline) ? props.outline : undefined; return visibleColor(fill) || visibleOutline(currentOutline, outlinePatch); } function visibleGradient(value: unknown): boolean { return ( isRecord(value) && Array.isArray(value.colors) && value.colors.some((stop) => isRecord(stop) && visibleColor(stop.color)) ); } function elementPaintVisible( element: ElementInventoryItem, props: Record, ): boolean { if (element.type === 'line') { const width = 'width' in props && isFiniteNumber(props.width) ? Math.min(LINE_STROKE_MAX, Math.max(LINE_STROKE_MIN, props.width)) : element.width; return ( width > 0 && (element.lineHasVisiblePath === true || element.lineHasEndpointMarker === true) && visibleColor('color' in props ? props.color : (element.style.color ?? '#333333')) ); } if (element.type === 'latex') { if (element.latexRenderMode === 'html') return textVisibleWithProposal(element, props) === true; return element.latexRenderMode === 'svg' ? visibleColor('color' in props ? props.color : element.style.color) : false; } if (element.type !== 'shape') return true; const opacity = 'opacity' in props ? props.opacity : (element.style.opacity ?? 1); if (!isFiniteNumber(opacity) || Math.min(1, Math.max(0, opacity)) === 0) return false; const currentOutline = isRecord(element.style.outline) ? element.style.outline : {}; const outlinePatch = isRecord(props.outline) ? props.outline : undefined; if ( visibleOutline(currentOutline, outlinePatch) || textVisibleWithProposal(element, props) === true ) { return true; } if (!('fill' in props) && !('gradient' in props) && element.hasPattern) return true; if ('fill' in props) return visibleColor(props.fill); const gradient = 'gradient' in props ? props.gradient : element.style.gradient; if (gradient !== undefined) return visibleGradient(gradient); return visibleColor(element.style.fill); } function currentInventoryValue(element: ElementInventoryItem, key: string): unknown { if (key === 'left' || key === 'top' || key === 'width') return element[key]; if (key === 'height') return element.height; if (key === 'rotate') return element.rotate ?? 0; if (key === 'radius' && element.type === 'image' && element.style.radius === undefined) { return element.imageClipShape === 'roundRect' ? 10 : 0; } const current = element.style[key]; if (current !== undefined) return current; if (key === 'opacity') return 1; if (key === 'flipH' || key === 'flipV' || key === 'fixedRatio' || key === 'vertical') { return false; } if (key === 'wordSpace') return 0; if (key === 'paragraphSpace' && (element.type === 'text' || element.type === 'shape')) return 5; if (key === 'vAlign' && element.type === 'shape') return 'middle'; return undefined; } function canonicalColor(value: unknown): unknown { if (!isColorString(value)) return value; const color = tinycolor(String(value)); return color.getAlpha() === 0 ? 'rgba(0, 0, 0, 0)' : color.toRgbString(); } function normalizeThemeColors(value: unknown[], count: number): unknown[] { if (value.length === 0 || count === 0) return []; let colors: unknown[]; if (value.length >= 10) { colors = value; } else if (value.length === 1) { colors = tinycolor(String(value[0])) .analogous(10) .map((color) => color.toRgbString()); } else { const supplement = tinycolor(String(value[value.length - 1])) .analogous(11 - value.length) .map((color) => color.toRgbString()); colors = [...value.slice(0, -1), ...supplement]; } return colors.slice(0, count).map(canonicalColor); } function normalizeFilters(value: unknown): Record { if (!isRecord(value)) return {}; const normalized: Record = {}; for (const [key, raw] of Object.entries(value)) { if (typeof raw !== 'string') { normalized[key] = raw; continue; } const unit = FILTER_UNITS[key]; const trimmed = raw.trim(); const numericText = unit && trimmed.endsWith(unit) ? trimmed.slice(0, -unit.length) : trimmed; let numeric = Number(numericText); if (!Number.isFinite(numeric)) { normalized[key] = raw; continue; } if (key === 'hue-rotate') numeric = ((numeric % 360) + 360) % 360; if (numeric !== FILTER_IDENTITY[key]) normalized[key] = numeric; } return normalized; } function normalizeEffectiveValue( element: ElementInventoryItem, key: string, value: unknown, ): unknown { if ((key === 'fill' || key === 'colorMask') && value === undefined) { return canonicalColor('transparent'); } if (COLOR_STRING_PROPS.has(key)) return canonicalColor(value); if (key === 'rotate' && isFiniteNumber(value)) return normalizeRotate(value); if (key === 'radius' && isFiniteNumber(value) && element.type === 'image') { return Math.min(value, Math.min(element.width, element.height ?? element.width) / 2); } if (key === 'themeColors' && Array.isArray(value)) { return normalizeThemeColors(value, element.chartColorCount ?? value.length); } if (key === 'filters') return normalizeFilters(value); if (key === 'outline') { if (!isRecord(value)) return { width: 0 }; const width = isFiniteNumber(value.width) ? value.width : element.type === 'table' ? 1 : 0; if (width === 0) return { width: 0 }; const defaultColor = element.type === 'table' ? '#000000' : '#d14424'; const color = canonicalColor(value.color ?? defaultColor); if (color === canonicalColor('transparent')) return { width: 0 }; const style = value.style === 'dashed' || (element.type !== 'table' && value.style === 'dotted') ? value.style : 'solid'; return { width, color, style, }; } if (key === 'shadow') { if (!isRecord(value)) return null; const color = canonicalColor(value.color); if (color === canonicalColor('transparent')) return null; return { ...value, color }; } if (key === 'gradient' && isRecord(value) && Array.isArray(value.colors)) { const type = value.type; const colors = value.colors.map((stop) => isRecord(stop) ? { ...stop, color: canonicalColor(stop.color) } : stop, ); const canonicalColors = colors .filter(isRecord) .map((stop) => stop.color) .filter((color) => color !== undefined); const monochrome = canonicalColors.length > 0 && canonicalColors.every((color) => color === canonicalColors[0]); if (monochrome) { return { type: 'solid-gradient', colors: [{ pos: 0, color: canonicalColors[0] }] }; } const rotate = type === 'radial' ? undefined : isFiniteNumber(value.rotate) ? ((value.rotate % 360) + 360) % 360 : value.rotate; return { ...value, ...(type === 'radial' ? { rotate: undefined } : { rotate }), colors, }; } return value; } function propChangesEffectiveState( element: ElementInventoryItem, key: string, value: unknown, replace = false, ): boolean { if (element.type === 'shape' && (key === 'fill' || key === 'gradient') && element.hasPattern) { return true; } if (key === 'fill' && element.type === 'shape' && element.style.gradient !== undefined) { const currentGradient = normalizeEffectiveValue(element, 'gradient', element.style.gradient); if ( isRecord(currentGradient) && currentGradient.type === 'solid-gradient' && Array.isArray(currentGradient.colors) && isRecord(currentGradient.colors[0]) && currentGradient.colors[0].color === canonicalColor(value) ) { return false; } return true; } if (key === 'gradient' && element.type === 'shape' && element.style.gradient === undefined) { const nextGradient = normalizeEffectiveValue(element, key, value); if ( isRecord(nextGradient) && nextGradient.type === 'solid-gradient' && Array.isArray(nextGradient.colors) && isRecord(nextGradient.colors[0]) && nextGradient.colors[0].color === normalizeEffectiveValue(element, 'fill', element.style.fill) ) { return false; } } const current = currentInventoryValue(element, key); if (MERGED_STYLE_PROPS.has(key) && isRecord(value) && !replace) { const currentRecord = isRecord(current) ? current : {}; const next = { ...currentRecord, ...value }; return !deepEqual( normalizeEffectiveValue(element, key, current), normalizeEffectiveValue(element, key, next), ); } return !deepEqual( normalizeEffectiveValue(element, key, current), normalizeEffectiveValue(element, key, value), ); } /** * Validate and coerce model proposals into EditIntents. * All-or-nothing: any single bad update refuses the whole batch. */ export function mapProposalsToEditIntents( proposals: ProposedElementUpdate[], inventory: ElementInventoryItem[], options: { replacedPropsById?: ReadonlyMap>; additionalGroupTargetIds?: ReadonlySet; } = {}, ): EditElementsGateResult { if (!Array.isArray(proposals) || proposals.length === 0) { return { ok: false, reason: 'no element updates proposed' }; } const byId = new Map(inventory.map((el) => [el.id, el])); const seen = new Set(); const updates: Array<{ id: string; props: Partial }> = []; for (const proposal of proposals) { if (!proposal || typeof proposal !== 'object') { return { ok: false, reason: 'malformed update entry' }; } const { id, props } = proposal; if (typeof id !== 'string' || !id) { return { ok: false, reason: 'update missing element id' }; } if (seen.has(id)) { return { ok: false, reason: `duplicate update for element ${JSON.stringify(id)}` }; } seen.add(id); const el = byId.get(id); if (!el) { return { ok: false, reason: `unknown element id ${JSON.stringify(id)}` }; } if (el.lock) { return { ok: false, reason: `element ${JSON.stringify(id)} is locked` }; } if (!props || typeof props !== 'object' || Array.isArray(props)) { return { ok: false, reason: `malformed props for element ${JSON.stringify(id)}` }; } const replacedProps = options.replacedPropsById?.get(id); const keyErr = validatePropsKeys(props); if (keyErr) return { ok: false, reason: keyErr }; for (const [key, value] of Object.entries(props)) { const valueErr = validatePropValue(key, value, el.type); if (valueErr) return { ok: false, reason: valueErr }; } const composedTextVisible = textVisibleWithProposal(el, props); const revealsUnpaintedTextWithDefaultColor = textPropContributesToVisibility( el, props, 'defaultColor', composedTextVisible, ); if ('defaultColor' in props && el.hasInlineTextColor && !revealsUnpaintedTextWithDefaultColor) { return { ok: false, reason: `element ${JSON.stringify(id)} has inline text color that defaultColor cannot override`, }; } if ('defaultFontName' in props && el.hasInlineFontFamily) { return { ok: false, reason: `element ${JSON.stringify(id)} has inline font-family that defaultFontName cannot override`, }; } if ('lineHeight' in props && el.hasInlineLineHeight) { return { ok: false, reason: `element ${JSON.stringify(id)} has inline line-height that lineHeight cannot override`, }; } if ('wordSpace' in props && el.hasInlineLetterSpacing) { return { ok: false, reason: `element ${JSON.stringify(id)} has inline letter-spacing that wordSpace cannot override`, }; } if ('paragraphSpace' in props && el.hasInlineParagraphSpacing) { return { ok: false, reason: `element ${JSON.stringify(id)} has inline margin-bottom that paragraphSpace cannot override`, }; } if ('vertical' in props && el.hasInlineWritingMode) { return { ok: false, reason: `element ${JSON.stringify(id)} has inline writing-mode that vertical cannot override`, }; } const revealsUnpaintedTextWithShadow = textPropContributesToVisibility( el, props, 'shadow', composedTextVisible, ); if ( 'shadow' in props && el.type === 'text' && el.hasInlineTextShadow && !revealsUnpaintedTextWithShadow ) { return { ok: false, reason: `element ${JSON.stringify(id)} has inline text-shadow that shadow cannot override`, }; } const contextErr = contextualProposalError(el, props, composedTextVisible, replacedProps); if (contextErr) return { ok: false, reason: contextErr }; if (el.type === 'text') { const vertical = 'vertical' in props ? props.vertical === true : el.style.vertical === true; if (vertical && 'width' in props) { return { ok: false, reason: `element ${JSON.stringify(id)} has automatic width in vertical text mode`, }; } if (!vertical && 'height' in props) { return { ok: false, reason: `element ${JSON.stringify(id)} has automatic height in horizontal text mode`, }; } } let clamped: Record; try { clamped = clampUpdateProps(el.type, props); } catch (err) { return { ok: false, reason: err instanceof Error ? err.message : `invalid geometry for ${JSON.stringify(id)}`, }; } if ( !Object.entries(clamped).some(([key, value]) => propChangesEffectiveState(el, key, value, replacedProps?.has(key)), ) ) { return { ok: false, reason: `element ${JSON.stringify(id)} update has no effective change` }; } updates.push({ id, props: clamped as Partial }); } const groupUpdates = [...updates]; for (const id of options.additionalGroupTargetIds ?? []) { if (!groupUpdates.some((update) => update.id === id)) groupUpdates.push({ id, props: {} }); } const groupErr = enforceGroupCohesion(groupUpdates, inventory); if (groupErr) return { ok: false, reason: groupErr }; if (updates.length === 1) { return { ok: true, intents: [{ type: 'element.update', id: updates[0].id, props: updates[0].props }], }; } return { ok: true, intents: [{ type: 'element.updateMany', updates }], }; } function textVisibleWithProposal( element: ElementInventoryItem, props: Record, ): boolean | undefined { const source = (element as InternalElementInventoryItem)[TEXT_VISIBILITY_SOURCE]; if (!source) return undefined; return hasVisibleHtmlText(source.content, { color: source.colorProp in props ? props[source.colorProp] : source.color, opacity: 'opacity' in props ? props.opacity : source.opacity, shadow: 'shadow' in props && isRecord(props.shadow) ? (props.shadow as TextVisibilitySource['shadow']) : source.shadow, }); } function textPropContributesToVisibility( element: ElementInventoryItem, props: Record, property: string, composedTextVisible: boolean | undefined, ): boolean { if (composedTextVisible !== true || !(property in props)) return false; const withoutProperty = { ...props }; delete withoutProperty[property]; return textVisibleWithProposal(element, withoutProperty) === false; } /** Build the model-visible inventory from trusted slide elements. */ export function buildElementInventory(elements: PPTElement[]): ElementInventoryItem[] { return elements.map((el) => { const style: Record = {}; for (const key of ALLOWED_EDIT_PROPS) { if ( key === 'left' || key === 'top' || key === 'width' || key === 'height' || key === 'rotate' ) { continue; } if (el.type === 'shape' && (SHAPE_TEXT_CHROME_PROPS.has(key) || key === 'vAlign')) { const text = (el as { text?: Record }).text; const mappedKey = key === 'vAlign' ? 'align' : key; const v = text?.[mappedKey]; if (v !== undefined) style[key] = v; continue; } const v = (el as unknown as Record)[key]; if (v !== undefined) style[key] = v; } const label = elementLabel(el); const base: ElementInventoryItem = { id: el.id, type: el.type, left: el.left, top: el.top, width: el.width, lock: !!el.lock, label, style, }; if (typeof el.groupId === 'string' && el.groupId) base.groupId = el.groupId; if (el.type === 'image') { base.imageClipShape = (el as { clip?: { shape?: string } }).clip?.shape?.trim() || 'rect'; } if (el.type === 'line') { const line = el as { start?: unknown; end?: unknown; points?: unknown; broken?: unknown; broken2?: unknown; curve?: unknown; cubic?: unknown; }; const start = line.start; const end = line.end; const validEndpoints = Array.isArray(start) && Array.isArray(end) && start.length >= 2 && end.length >= 2; const differsFromStart = (point: unknown) => Array.isArray(point) && point.length >= 2 && validEndpoints && (point[0] !== start[0] || point[1] !== start[1]); const broken2Visible = Array.isArray(line.broken2) && line.broken2.length >= 2 && validEndpoints && (Math.max(Number(start[0]), Number(end[0])) >= Math.max(Number(start[1]), Number(end[1])) ? line.broken2[0] !== start[0] : line.broken2[1] !== start[1]); const controlPathVisible = line.broken ? differsFromStart(line.broken) : line.broken2 ? broken2Visible : line.curve ? differsFromStart(line.curve) : line.cubic ? Array.isArray(line.cubic) && line.cubic.some(differsFromStart) : false; base.lineHasVisiblePath = validEndpoints && (start[0] !== end[0] || start[1] !== end[1] || controlPathVisible); base.lineHasEndpointMarker = Array.isArray(line.points) && line.points.some((point) => typeof point === 'string' && point); } if (el.type === 'shape') { const shape = el as { pattern?: unknown; opacity?: unknown; text?: { content?: unknown; defaultColor?: unknown }; }; base.hasPattern = !!shape.pattern; base.hasShapeText = typeof shape.text?.content === 'string' && hasVisibleHtmlText(shape.text.content, { color: shape.text.defaultColor, opacity: shape.opacity, }); if (typeof shape.text?.content === 'string') { Object.defineProperty(base, TEXT_VISIBILITY_SOURCE, { value: { content: shape.text.content, colorProp: 'defaultColor', color: shape.text.defaultColor, opacity: shape.opacity, } satisfies TextVisibilitySource, }); } } if (el.type === 'latex') { const latex = el as { html?: unknown; path?: unknown; viewBox?: unknown; color?: unknown }; const html = latex.html; const hasHtml = typeof html === 'string' && hasVisibleHtmlText(html, { color: latex.color, ignorePaint: true }); const hasPath = typeof latex.path === 'string' && latex.path.trim().length > 0 && Array.isArray(latex.viewBox) && latex.viewBox.length >= 2 && latex.viewBox.every((value) => isFiniteNumber(value) && value > 0); base.latexRenderMode = typeof html === 'string' && html.length > 0 ? hasHtml ? 'html' : 'none' : hasPath ? 'svg' : 'none'; base.hasRenderableLatex = base.latexRenderMode !== 'none'; if (typeof html === 'string') { Object.defineProperty(base, TEXT_VISIBILITY_SOURCE, { value: { content: html, colorProp: 'color', color: latex.color, } satisfies TextVisibilitySource, }); const inline = collectInlineStyleProperties(html); base.hasInlineLatexColor = inline.has('all') || inline.has('color') || inline.has('-webkit-text-fill-color'); } } if (el.type === 'table') { const data = (el as { data?: unknown }).data; base.hasCellBorders = Array.isArray(data) && data.some( (row) => Array.isArray(row) && row.some((cell) => { if (!isRecord(cell) || !isRecord(cell.borders)) return false; const borders = cell.borders; return ['top', 'bottom', 'left', 'right'].some((side) => !!borders[side]); }), ); } if (el.type === 'chart') { const chart = el as { chartType?: unknown; data?: { series?: unknown } }; const chartType = chart.chartType; if (typeof chartType === 'string') base.chartType = chartType; const series = chart.data?.series; const seriesArray: unknown[] = Array.isArray(series) ? series : []; base.hasRenderableChartData = seriesArray.length > 0 && (chartType === 'pie' || chartType === 'ring' ? Array.isArray(seriesArray[0]) && seriesArray[0].length > 0 : true); base.chartColorCount = chartType === 'pie' || chartType === 'ring' ? Array.isArray(seriesArray[0]) ? seriesArray[0].length : 0 : chartType === 'scatter' ? Array.isArray(seriesArray[0]) && seriesArray[0].length > 0 ? 1 : 0 : seriesArray.length === 1 && Array.isArray(seriesArray[0]) && seriesArray[0].length === 0 ? 0 : seriesArray.length; } const textContent = el.type === 'text' ? (el as { content?: unknown }).content : el.type === 'shape' ? (el as { text?: { content?: unknown } }).text?.content : undefined; if (typeof textContent === 'string') { if (el.type === 'text') { const text = el as { defaultColor?: unknown; opacity?: unknown; shadow?: { h?: unknown; v?: unknown; blur?: unknown; color?: unknown }; }; const visibilityOptions = { color: text.defaultColor, opacity: text.opacity, shadow: text.shadow, }; base.hasTextGlyphs = hasVisibleHtmlText(textContent, visibilityOptions); base.hasTextContent = hasVisibleHtmlText(textContent, { ...visibilityOptions, ignorePaint: true, }); Object.defineProperty(base, TEXT_VISIBILITY_SOURCE, { value: { content: textContent, colorProp: 'defaultColor', ...visibilityOptions, } satisfies TextVisibilitySource, }); } const inline = collectInlineStyleProperties(textContent); const has = (...properties: string[]) => properties.some((property) => inline.has(property)); if (has('color', '-webkit-text-fill-color')) base.hasInlineTextColor = true; if (has('font-family', 'font')) base.hasInlineFontFamily = true; if (has('line-height', 'font')) base.hasInlineLineHeight = true; if (has('letter-spacing')) base.hasInlineLetterSpacing = true; if (has('margin-bottom', 'margin', 'margin-block', 'margin-block-end', '--paragraphSpace')) { base.hasInlineParagraphSpacing = true; } if (has('writing-mode', '-webkit-writing-mode')) base.hasInlineWritingMode = true; if (has('text-shadow')) base.hasInlineTextShadow = true; } if (el.type !== 'line') { base.height = (el as { height: number }).height; base.rotate = (el as { rotate: number }).rotate; } return base; }); } function hasVisibleHtmlText( content: string, options: { color?: unknown; opacity?: unknown; shadow?: { h?: unknown; v?: unknown; blur?: unknown; color?: unknown }; ignorePaint?: boolean; } = {}, ): boolean { type VisibilityState = { hidden: boolean; visibilityHidden: boolean; fontSizeZero: boolean; colorTransparent: boolean; textFillTransparent?: boolean; textShadowValue?: string; }; const rootColorTransparent = typeof options.color === 'string' ? (cssColorTransparency(options.color) ?? false) : false; const rootShadow = options.shadow; const rootShadowValue = rootShadow && isFiniteNumber(rootShadow.h) && isFiniteNumber(rootShadow.v) && isFiniteNumber(rootShadow.blur) && typeof rootShadow.color === 'string' ? `${rootShadow.h}px ${rootShadow.v}px ${rootShadow.blur}px ${rootShadow.color}` : undefined; const rootState: VisibilityState = { hidden: !options.ignorePaint && isFiniteNumber(options.opacity) ? Math.min(1, Math.max(0, options.opacity)) === 0 : false, visibilityHidden: false, fontSizeZero: false, colorTransparent: rootColorTransparent, textFillTransparent: undefined, textShadowValue: rootShadowValue, }; const visibilityStack: VisibilityState[] = []; let visibleText = ''; const tokens = tokenizeHtml(content); const voidTags = new Set([ 'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta', 'source', 'track', 'wbr', ]); for (const token of tokens) { if (!token.startsWith('<')) { const state = visibilityStack.at(-1) ?? rootState; const transparentPaint = state?.textFillTransparent === true || (state?.textFillTransparent === undefined && state?.colorTransparent); const shadowVisible = state?.textShadowValue ? textShadowIsVisible(state.textShadowValue, state.colorTransparent) : false; if ( !state?.hidden && !state?.visibilityHidden && !state?.fontSizeZero && (options.ignorePaint || !transparentPaint || shadowVisible) ) { visibleText += token; } continue; } if (/^<\s*[!/]/.test(token)) { if (/^<\s*\//.test(token)) visibilityStack.pop(); continue; } const tag = token.match(/^<\s*([\w:-]+)/)?.[1]?.toLowerCase(); if (!tag) continue; const parent = visibilityStack.at(-1) ?? rootState; const attributes = htmlAttributeVisibility(token); const colorTransparent = attributes.colorTransparent ?? parent.colorTransparent; const state: VisibilityState = { hidden: parent.hidden || attributes.hidden, visibilityHidden: attributes.visibilityHidden ?? parent.visibilityHidden, fontSizeZero: attributes.fontSizeZero ?? parent.fontSizeZero, colorTransparent, textFillTransparent: attributes.resetTextFill ? undefined : (attributes.textFillTransparent ?? parent.textFillTransparent), textShadowValue: attributes.textShadowValue ?? parent.textShadowValue, }; if (!token.endsWith('/>') && !voidTags.has(tag)) visibilityStack.push(state); } return ( visibleText .replace(/<[^>]*>/g, ' ') .replace(/&(?:nbsp|#160|#x0*a0);/gi, ' ') .replace(/\s+/g, '').length > 0 ); } function tokenizeHtml(content: string): string[] { const tokens: string[] = []; let index = 0; while (index < content.length) { if (content[index] !== '<') { const next = content.indexOf('<', index); const end = next < 0 ? content.length : next; tokens.push(content.slice(index, end)); index = end; continue; } let end = index + 1; let quote = ''; while (end < content.length) { const char = content[end]; if (quote) { if (char === quote) quote = ''; } else if (char === '"' || char === "'") { quote = char; } else if (char === '>') { end++; break; } end++; } tokens.push(content.slice(index, end)); index = end; } return tokens; } function htmlAttributeVisibility(tag: string): { hidden: boolean; visibilityHidden?: boolean; fontSizeZero?: boolean; colorTransparent?: boolean; textFillTransparent?: boolean; resetTextFill?: boolean; textShadowValue?: string; } { const hiddenAttribute = /\shidden(?:\s*=\s*(?:["'][^"']*["']|[^\s>]+))?(?=\s|\/?>)/i.test(tag); const className = tag.match(/\bclass\s*=\s*(["'])(.*?)\1/i)?.[2] ?? ''; const classNames = className.split(/\s+/); let displayHidden = classNames.includes('hidden'); let opacityHidden = false; let visibilityHidden = classNames.includes('invisible') ? true : classNames.includes('visible') ? false : undefined; let fontSizeZero: boolean | undefined; const style = tag.match(/\bstyle\s*=\s*(["'])(.*?)\1/i)?.[2]; let colorTransparent: boolean | undefined; let textFillTransparent: boolean | undefined; let resetTextFill = false; let textShadowValue: string | undefined; if (!style) return { hidden: hiddenAttribute || displayHidden, visibilityHidden }; for (const [property, value] of winningInlineDeclarations(style)) { if (property === 'display') { if (value === 'none') displayHidden = true; else if (value === 'revert-layer') { // Preserve the lower author-layer class winner. } else if (isValidVisibleDisplayValue(value)) displayHidden = false; } if (property === 'visibility') { if (value === 'hidden' || value === 'collapse') visibilityHidden = true; else if (value === 'visible' || value === 'initial') { visibilityHidden = false; } else if (value === 'inherit' || value === 'unset' || value === 'revert') { visibilityHidden = undefined; } else if (value === 'revert-layer') { // Preserve the lower author-layer class winner. } } if (property === 'opacity') { const opacity = value.match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+))%?$/); opacityHidden = opacity ? Number(opacity[1]) === 0 : /^(?:initial|inherit|unset|revert|revert-layer)$/.test(value) ? false : true; } if (property === 'font-size') { const size = value.match(/^([+-]?(?:\d+(?:\.\d*)?|\.\d+))(?:px|pt|pc|em|rem|%)?$/i); if (size) fontSizeZero = Number(size[1]) === 0; else if (value === 'initial') fontSizeZero = false; else if ( value === 'inherit' || value === 'unset' || value === 'revert' || value === 'revert-layer' ) { fontSizeZero = undefined; } else { fontSizeZero = true; } } if (property === 'color') { colorTransparent = value === 'initial' ? false : cssColorTransparency(value); if ( value === 'inherit' || value === 'unset' || value === 'revert' || value === 'revert-layer' || value === 'currentcolor' ) { colorTransparent = undefined; } else if (colorTransparent === undefined) { colorTransparent = true; } } if (property === '-webkit-text-fill-color') { if (value === 'initial' || value === 'currentcolor') { resetTextFill = true; textFillTransparent = undefined; } else if ( value === 'inherit' || value === 'unset' || value === 'revert' || value === 'revert-layer' ) { resetTextFill = false; textFillTransparent = undefined; } else { textFillTransparent = cssColorTransparency(value) ?? true; } } if (property === 'text-shadow') { textShadowValue = value === 'initial' || value === 'none' ? 'none' : value === 'inherit' || value === 'unset' || value === 'revert' || value === 'revert-layer' ? undefined : value; } } return { hidden: hiddenAttribute || displayHidden || opacityHidden, visibilityHidden, fontSizeZero, colorTransparent, textFillTransparent, resetTextFill, textShadowValue, }; } function winningInlineDeclarations(style: string): Map { const allProperties = [ 'display', 'opacity', 'visibility', 'font-size', 'color', '-webkit-text-fill-color', 'text-shadow', 'font-family', 'line-height', 'letter-spacing', 'margin', 'margin-bottom', 'margin-block', 'margin-block-end', 'writing-mode', '-webkit-writing-mode', ]; const cssWideValues = new Set(['initial', 'inherit', 'unset', 'revert', 'revert-layer']); const winners = new Map(); for (const declaration of style.split(';')) { const colon = declaration.indexOf(':'); if (colon < 0) continue; const property = declaration.slice(0, colon).trim().toLowerCase(); if (!property) continue; const rawValue = declaration.slice(colon + 1).trim(); const important = /!important\s*$/i.test(rawValue); const value = rawValue .replace(/!important\s*$/i, '') .trim() .toLowerCase(); if (property !== 'all' && !isValidTrackedCssDeclaration(property, value, cssWideValues)) { continue; } const targets: Array<[string, string]> = property === 'all' ? cssWideValues.has(value) ? allProperties.map((target) => [target, value]) : [] : property === 'font' ? [ ['font-size', fontShorthandSize(value) ?? value], ['font-family', value], ['line-height', value], ] : [[property, value]]; for (const [target, targetValue] of targets) { const current = winners.get(target); if (!current || important || !current.important) { winners.set(target, { value: targetValue, important }); } } } return new Map(Array.from(winners, ([property, winner]) => [property, winner.value])); } function isValidTrackedCssDeclaration( property: string, value: string, cssWideValues: ReadonlySet, ): boolean { if (cssWideValues.has(value)) return true; if (property === 'display') return value === 'none' || isValidVisibleDisplayValue(value); if (property === 'visibility') return /^(?:visible|hidden|collapse)$/.test(value); if (property === 'opacity') { return ( /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)%?$/.test(value) || /^(?:calc|min|max|clamp|var)\(.+\)$/.test(value) ); } if (property === 'font-size') return fontSizeValue(value) !== undefined; if (property === 'font') return fontShorthandSize(value) !== undefined; if (property === 'color' || property === '-webkit-text-fill-color') { return ( value === 'currentcolor' || isColorString(value) || cssColorTransparency(value) !== undefined || /^(?:color|color-mix|lab|lch|oklab|oklch|var)\(.+\)$/.test(value) ); } if (property === 'writing-mode' || property === '-webkit-writing-mode') { return /^(?:horizontal-tb|vertical-rl|vertical-lr|sideways-rl|sideways-lr)$/.test(value); } if (property === 'text-shadow') { return value === 'none' || isValidTextShadowValue(value); } return value.length > 0; } function fontSizeValue(value: string): string | undefined { return /^(?:xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large|larger|smaller|0|[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:px|pt|pc|in|cm|mm|q|em|rem|ex|ch|lh|rlh|vw|vh|vmin|vmax|%)|(?:calc|min|max|clamp|var)\(.+\))$/.test( value, ) ? value : undefined; } function fontShorthandSize(value: string): string | undefined { if (/^(?:initial|inherit|unset|revert|revert-layer)$/.test(value)) return value; if (/^(?:caption|icon|menu|message-box|small-caption|status-bar)$/.test(value)) return 'initial'; const match = value.match( /(?:^|\s)(xx-small|x-small|small|medium|large|x-large|xx-large|xxx-large|larger|smaller|0|[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:px|pt|pc|in|cm|mm|q|em|rem|ex|ch|lh|rlh|vw|vh|vmin|vmax|%))(?=\s|\/)/, ); return match?.[1]; } function isValidVisibleDisplayValue(value: string): boolean { if ( /^(?:block|inline|run-in|flow|flow-root|table|flex|grid|ruby|list-item|contents|inline-(?:block|table|flex|grid)|table-(?:row-group|header-group|footer-group|row|cell|column-group|column|caption)|ruby-(?:base|text|base-container|text-container)|initial|inherit|unset|revert)$/i.test( value, ) ) { return true; } const tokens = value.split(/\s+/); const listItem = tokens.at(-1) === 'list-item'; if (listItem) tokens.pop(); const outside = new Set(['block', 'inline', 'run-in']); const inside = new Set(['flow', 'flow-root', 'table', 'flex', 'grid', 'ruby']); const validPair = (tokens.length === 1 && (outside.has(tokens[0]) || inside.has(tokens[0]))) || (tokens.length === 2 && outside.has(tokens[0]) && inside.has(tokens[1])); if (!listItem) return validPair; const listInside = new Set(['flow', 'flow-root']); return ( tokens.length === 0 || (tokens.length === 1 && (outside.has(tokens[0]) || listInside.has(tokens[0]))) || (tokens.length === 2 && outside.has(tokens[0]) && listInside.has(tokens[1])) ); } function isValidTextShadowValue(value: string): boolean { if (/^var\(.+\)$/.test(value)) return true; return splitCssList(value).every((layer) => { let lengthCount = 0; let colorCount = 0; for (const token of splitCssTokens(layer.trim())) { if ( token === '0' || /^[+-]?(?:\d+(?:\.\d*)?|\.\d+)(?:px|pt|pc|in|cm|mm|q|em|rem|ex|ch|vw|vh|vmin|vmax)$/.test( token, ) || /^(?:calc|min|max|clamp)\(.+\)$/.test(token) ) { lengthCount++; continue; } if ( token === 'currentcolor' || isColorString(token) || cssColorTransparency(token) !== undefined || /^(?:color|color-mix|lab|lch|oklab|oklch|var)\(.+\)$/.test(token) ) { colorCount++; if (colorCount > 1) return false; continue; } return false; } return lengthCount >= 2 && lengthCount <= 3; }); } function splitCssTokens(value: string): string[] { const tokens: string[] = []; let depth = 0; let start = 0; for (let index = 0; index <= value.length; index++) { const char = value[index]; if (char === '(') depth++; else if (char === ')') depth = Math.max(0, depth - 1); if ((index === value.length || /\s/.test(char)) && depth === 0) { if (index > start) tokens.push(value.slice(start, index)); start = index + 1; } } return tokens; } function cssColorTransparency(value: string): boolean | undefined { if (isColorString(value)) return tinycolor(value).getAlpha() === 0; const alpha = value.match(/^(?:rgba|hsla)\([^)]*(?:,|\/)\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))%\s*\)$/i) ?? value.match(/^[a-z][\w-]*\([^)]*\/\s*([+-]?(?:\d+(?:\.\d*)?|\.\d+))%?\s*\)$/i); if (alpha) return Number(alpha[1]) === 0; return undefined; } function textShadowIsVisible(value: string, currentColorTransparent: boolean): boolean { const normalized = value.trim().toLowerCase(); if (normalized === 'none' || normalized === 'initial' || normalized.startsWith('revert')) { return false; } if (/\b(?:var|color|color-mix|lab|lch|oklab|oklch)\(/.test(normalized)) return false; for (const shadow of splitCssList(value)) { const candidates = shadow.match(/(?:rgba?|hsla?)\([^)]*\)|#[0-9a-f]{3,8}|\b[a-z]+\b/gi) ?? []; let foundColor = false; let layerVisible = false; for (const candidate of candidates) { const lower = candidate.toLowerCase(); if (lower === 'currentcolor') { foundColor = true; layerVisible ||= !currentColorTransparent; continue; } const transparency = cssColorTransparency(candidate); if (!isColorString(candidate) && transparency === undefined) continue; if ( !lower.startsWith('rgb') && !lower.startsWith('hsl') && !lower.startsWith('#') && !Object.hasOwn(tinycolor.names, lower) && lower !== 'transparent' ) { continue; } foundColor = true; layerVisible ||= transparency === false; } if (layerVisible || (!foundColor && !currentColorTransparent)) return true; } return false; } function splitCssList(value: string): string[] { const items: string[] = []; let depth = 0; let start = 0; for (let index = 0; index < value.length; index++) { const char = value[index]; if (char === '(') depth++; else if (char === ')') depth = Math.max(0, depth - 1); else if (char === ',' && depth === 0) { items.push(value.slice(start, index)); start = index + 1; } } items.push(value.slice(start)); return items; } function collectInlineStyleProperties(content: string): Set { const properties = new Set(); const styleAttribute = /\bstyle\s*=\s*(["'])(.*?)\1/gi; for (const match of content.matchAll(styleAttribute)) { for (const [property, value] of winningInlineDeclarations(match[2])) { const inheritsElementValue = value === 'inherit' || value === 'unset' || value === 'revert' || value === 'revert-layer'; if ( inheritsElementValue && [ 'color', '-webkit-text-fill-color', 'font-family', 'font', 'line-height', 'letter-spacing', 'writing-mode', '-webkit-writing-mode', 'text-shadow', ].includes(property) ) { continue; } if ( (property === 'color' && value === 'currentcolor') || (property === '-webkit-text-fill-color' && (value === 'initial' || value === 'currentcolor')) ) { continue; } properties.add(property); } for (const declaration of match[2].split(';')) { const colon = declaration.indexOf(':'); if (colon < 0) continue; const property = declaration.slice(0, colon).trim(); if (property.startsWith('--')) properties.add(property); } } return properties; } /** Stable snapshot of the model-visible mutable state used for apply-time drift checks. */ export function elementInventoryFingerprint(element: PPTElement): string { const item = buildElementInventory([element])[0]; return JSON.stringify({ item, applyDependencies: elementApplyDependencies(element) }); } /** Stable snapshot of every prompt-visible element, including order and membership. */ export function elementInventorySnapshotFingerprint(elements: PPTElement[]): string { const inventory = buildElementInventory(elements); return JSON.stringify( inventory.map((item, index) => ({ item, applyDependencies: elementApplyDependencies(elements[index]), })), ); } function elementApplyDependencies(element: PPTElement): unknown { if (element.type === 'text') { return { content: (element as { content?: unknown }).content }; } if (element.type === 'shape') { const shape = element as { pattern?: unknown; pathFormula?: unknown; keypoints?: unknown; text?: { content?: unknown }; }; return { pattern: shape.pattern, pathFormula: shape.pathFormula, keypoints: shape.keypoints, textContent: shape.text?.content, }; } if (element.type === 'latex') { return { html: (element as { html?: unknown }).html }; } if (element.type === 'table') { const table = element as { data?: unknown; cellMinHeight?: unknown; rowHeights?: unknown; }; return { rowCount: Array.isArray(table.data) ? table.data.length : undefined, cellMinHeight: table.cellMinHeight, rowHeights: table.rowHeights, }; } return undefined; } function elementLabel(el: PPTElement): string { if (typeof el.name === 'string' && el.name.trim()) return el.name.trim().slice(0, 80); const e = el as { type: string; content?: unknown; text?: { content?: unknown }; textType?: string; }; if (e.type === 'text' && typeof e.content === 'string') { const plain = e.content .replace(/<[^>]*>/g, ' ') .replace(/\s+/g, ' ') .trim(); if (plain) return plain.slice(0, 80); if (e.textType) return e.textType; } if (e.type === 'shape' && typeof e.text?.content === 'string') { const plain = e.text.content .replace(/<[^>]*>/g, ' ') .replace(/\s+/g, ' ') .trim(); if (plain) return plain.slice(0, 80); } return e.type; } /** Collect target element ids from a batch of EditIntents. */ export function collectIntentTargetIds(intents: EditIntent[]): string[] { const ids: string[] = []; for (const intent of intents) { if (intent.type === 'element.update') ids.push(intent.id); else if (intent.type === 'element.updateMany') { for (const u of intent.updates) ids.push(u.id); } else if (intent.type === 'element.removeProps') { ids.push(intent.id); } else if (intent.type === 'text.updateContent') { ids.push(intent.id); } } return ids; } /** * Apply-time revalidation against the live slide content. * Ensures the batch is still fully applicable (ids present, unlocked, group-cohesive). */ export function revalidateIntentsAgainstElements( elements: PPTElement[], intents: EditIntent[], targetElementTypes?: Record, targetElementFingerprints?: Record, ): EditElementsGateResult { const inventory = buildElementInventory(elements); const ids = collectIntentTargetIds(intents); if (ids.length === 0) return { ok: false, reason: 'no element updates proposed' }; const byId = new Map(inventory.map((el) => [el.id, el])); for (const id of ids) { const el = byId.get(id); if (!el) return { ok: false, reason: `unknown element id ${JSON.stringify(id)}` }; if (el.lock) return { ok: false, reason: `element ${JSON.stringify(id)} is locked` }; const expectedType = targetElementTypes?.[id]; if (expectedType && el.type !== expectedType) { return { ok: false, reason: `element ${JSON.stringify(id)} changed type from ${expectedType} to ${el.type}`, }; } const expectedFingerprint = targetElementFingerprints?.[id]; const liveElement = elements.find((item) => item.id === id); if ( expectedFingerprint && liveElement && elementInventoryFingerprint(liveElement) !== expectedFingerprint ) { return { ok: false, reason: `element ${JSON.stringify(id)} changed while the edit was being prepared`, }; } } const groupUpdatesById = new Map>(); const includeGroupTarget = (id: string, props: Record = {}) => { groupUpdatesById.set(id, { ...groupUpdatesById.get(id), ...props }); }; for (const intent of intents) { if (intent.type === 'element.update') { includeGroupTarget(intent.id, intent.props as Record); } else if (intent.type === 'element.updateMany') { for (const update of intent.updates) { includeGroupTarget(update.id, update.props as Record); } } else if (intent.type === 'element.removeProps' || intent.type === 'text.updateContent') { includeGroupTarget(intent.id); } } const groupErr = enforceGroupCohesion( [...groupUpdatesById].map(([id, props]) => ({ id, props: props as Partial })), inventory, ); if (groupErr) return { ok: false, reason: groupErr }; return { ok: true, intents }; }