/*
 * ai-dom.js — Adobe Illustrator scripting DOM, implemented over Method Draw / svgcanvas.
 *
 * Goal: run *unmodified* Illustrator ExtendScript (.jsx) inside the WKWebView.
 * This file defines the object model scripts talk to: app, Document, Layer,
 * PathItem, PathPoint, GroupItem, TextFrame, RasterItem, colors and enums. Everything maps
 * onto the live SVG DOM through svgCanvas. Features SVG cannot represent are
 * present as SAFE STUBS so scripts do not crash (see illustrator-scripting-gaps.md).
 *
 * Coordinate system: modern Illustrator uses origin at the artboard top-left with
 * y increasing downward — identical to SVG — so the default mapping is the
 * identity. Legacy (y-up) scripts can flip AIConfig.coordinateSystem = 'bottomup'.
 * Units: 1 Illustrator point maps to 1 SVG user unit (AIConfig.pointsPerUnit).
 */
(function (global) {
  'use strict';

  // ----------------------------------------------------------------------------
  // Config + low-level helpers
  // ----------------------------------------------------------------------------
  var AIConfig = {
    pointsPerUnit: 1,
    coordinateSystem: 'topdown' // 'topdown' (modern AI = SVG) | 'bottomup' (legacy)
  };

  var SVGNS = 'http://www.w3.org/2000/svg';

  var AI = {
    config: AIConfig,
    _batch: null,
    _created: [],
    _fallbackId: 0,

    canvas: function () { return global.svgCanvas; },
    root: function () { return document.getElementById('svgcontent'); },

    nextId: function () {
      try { return AI.canvas().getNextId(); }
      catch (e) { return 'aidom_' + (AI._fallbackId++); }
    },

    docWidth: function () {
      try { return AI.canvas().getResolution().w; }
      catch (e) { return parseFloat(AI.root().getAttribute('width')) || 0; }
    },
    docHeight: function () {
      try { return AI.canvas().getResolution().h; }
      catch (e) { return parseFloat(AI.root().getAttribute('height')) || 0; }
    },

    // Where art created without an explicit container goes — Illustrator's ACTIVE LAYER.
    // The `|| AI.root()` is not cosmetic (svgfeedback SVGE-1439): an engine that answers
    // the call but has no layer to point at (a document with none) would return null, and
    // AI.create's `var host = parent || AI.currentLayerGroup(); if (host && …)` would then
    // silently leave the new element DETACHED — a script that "succeeded" while drawing
    // nothing. #svgcontent is the honest last resort.
    currentLayerGroup: function () {
      try { return AI.canvas().getCurrentDrawing().getCurrentLayer() || AI.root(); }
      catch (e) { return AI.root(); }
    },

    // ---- installed fonts (svgfeedback SVGE-1315) ----
    // The faces `app.textFonts` enumerates. It used to be hard-coded EMPTY, so every
    // ExtendScript that picks a font ("assign a random font per character") could only hit
    // its own "no font available" guard and stop — the reported bug. Two REAL sources, never
    // an invented list:
    //   • native/JSC (Skia master): `svgCanvas.fontList()` — CoreText's installed faces
    //     (scripting_dom.js → __scene.fontList), the same set the Character palette offers;
    //   • inside the WKWebView twin: the editor's OWN font popup (#font_family_dropdown),
    //     which the app fills with the families it can actually render.
    // Neither present ⇒ an empty (but well-formed) collection, never a throw. Resolved once
    // per run and cached: enumerating installed faces is a font-table scan.
    _fonts: null,
    fonts: function () {
      if (AI._fonts) return AI._fonts;
      var out = [], seen = Object.create(null);
      function add(name, family, style) {
        name = String(name == null ? '' : name).trim();
        if (!name || seen[name]) return;
        seen[name] = 1;
        out.push(makeTextFont(name, family || name, style || 'Regular'));
      }
      try {
        var cv = AI.canvas();
        var lst = (cv && typeof cv.fontList === 'function') ? cv.fontList() : null;
        for (var i = 0; lst && i < lst.length; i++) add(lst[i].name, lst[i].family, lst[i].style);
      } catch (e) {}
      if (!out.length) {
        try {
          var opts = document.querySelectorAll('#font_family_dropdown option') || [];
          for (var j = 0; j < opts.length; j++) {
            var v = opts[j].value || opts[j].textContent;
            add(v, v, 'Regular');
          }
        } catch (e) {}
      }
      AI._fonts = out;
      return out;
    },

    // ---- coordinate conversion (AI space <-> SVG user space) ----
    xToSvg: function (x) { return x * AIConfig.pointsPerUnit; },
    xToAi:  function (x) { return x / AIConfig.pointsPerUnit; },
    yToSvg: function (y) {
      var v = y * AIConfig.pointsPerUnit;
      return AIConfig.coordinateSystem === 'bottomup' ? (AI.docHeight() - v) : v;
    },
    yToAi: function (y) {
      var v = AIConfig.coordinateSystem === 'bottomup' ? (AI.docHeight() - y) : y;
      return v / AIConfig.pointsPerUnit;
    },

    // ---- element creation + undo bookkeeping ----
    create: function (tag, attrs, parent) {
      attrs = attrs || {};
      if (!attrs.id) attrs.id = AI.nextId();
      var el;
      try {
        el = AI.canvas().addSvgElementFromJson({ element: tag, attr: attrs });
      } catch (e) {
        el = document.createElementNS(SVGNS, tag);
        for (var k in attrs) if (attrs.hasOwnProperty(k)) el.setAttribute(k, attrs[k]);
      }
      var host = parent || AI.currentLayerGroup();
      if (host && el.parentNode !== host) host.appendChild(el);
      AI.register(el);
      return el;
    },

    register: function (el) {
      if (AI._batch && global.svgedit && svgedit.history) {
        try { AI._batch.addSubCommand(new svgedit.history.InsertElementCommand(el)); }
        catch (e) {}
      }
      AI._created.push(el);
      return el;
    },

    begin: function () {
      AI._created = [];
      if (global.svgedit && svgedit.history) {
        try { AI._batch = new svgedit.history.BatchCommand('Run script'); }
        catch (e) { AI._batch = null; }
      }
    },

    commit: function () {
      try {
        var cv = AI.canvas();
        if (AI._batch && cv && cv.undoMgr && AI._batch.getText) {
          // only push if it actually did something
          try {
            if (typeof AI._batch.isEmpty === 'function' ? !AI._batch.isEmpty() : true) {
              cv.undoMgr.addCommandToHistory(AI._batch);
            }
          } catch (e) { cv.undoMgr.addCommandToHistory(AI._batch); }
        }
        if (cv && typeof cv.call === 'function' && AI._created.length) {
          try { cv.call('changed', AI._created); } catch (e) {}
        }
      } finally {
        AI._batch = null;
        AI._created = [];
      }
    },

    // Run `fn` as its OWN atomic, IMMEDIATELY-committed undo step (feature #663).
    // The end-of-script commit() folds everything a .jsx does into ONE "Run
    // script" step, which is wrong for the scripted-input test hooks: a test that
    // does `SVGETest.createRect(...)` then `SVGETest.nextUndoLabel()` in the SAME
    // do-script run must already SEE the create on the undo stack, and a following
    // `SVGETest.undo()` must remove just that shape. So each hook wraps its work
    // here: we swap in a private BatchCommand, run fn (AI.create/_setAttr/etc.
    // record their sub-commands into it), then push it to undoMgr right away and
    // fire "changed". The outer ai-runner batch (if any) is saved and restored, so
    // this composes with a running .jsx without double-recording — sub-commands go
    // to the inner batch, not the outer one. Returns fn()'s value.
    undoable: function (label, fn) {
      var prevBatch = AI._batch, prevCreated = AI._created;
      var batch = null;
      if (global.svgedit && svgedit.history) {
        try { batch = new svgedit.history.BatchCommand(label || 'script edit'); }
        catch (e) { batch = null; }
      }
      AI._batch = batch;
      AI._created = [];
      var result, created;
      try {
        result = fn();
      } finally {
        created = AI._created;
        var cv = AI.canvas();
        try {
          if (batch && cv && cv.undoMgr) {
            if (typeof batch.isEmpty === 'function' ? !batch.isEmpty() : true) {
              cv.undoMgr.addCommandToHistory(batch);
            }
          }
        } catch (e2) {
          try { if (cv && cv.undoMgr) cv.undoMgr.addCommandToHistory(batch); } catch (e3) {}
        }
        if (cv && typeof cv.call === 'function' && created && created.length) {
          try { cv.call('changed', created); } catch (e4) {}
        }
        AI._batch = prevBatch;
        AI._created = prevCreated;
      }
      return result;
    },

    // ---- attribute edit + undo bookkeeping ----
    // Set/remove an attribute and, if a script batch is open, record a
    // ChangeElementCommand so script-driven paint edits become undoable.
    _setAttr: function (el, name, value) {
      if (el == null) return;
      var old = el.getAttribute(name);
      var nv = (value == null) ? null : String(value);
      var cur = (old == null) ? null : String(old);
      if (nv === cur) return;                 // no-op
      if (nv == null) el.removeAttribute(name); else el.setAttribute(name, nv);
      try {
        if (AI._batch && global.svgedit && svgedit.history) {
          var oldObj = {}; oldObj[name] = old;   // ChangeElementCommand restores old on undo (null removes)
          AI._batch.addSubCommand(new svgedit.history.ChangeElementCommand(el, oldObj, 'script edit'));
        }
      } catch (e) {}
    },

    // ---- gradient / pattern application (fillColor = GradientColor|PatternColor) ----
    // Both route through the add.js entry points (js_set_gradient_fill /
    // js_set_pattern_fill: undo, def dedup, paint-box sync) which operate on the
    // SELECTION — so: save selection → select [el] → apply → restore. On the
    // native/JSC engine (no `main`, no defs support in the shim) they degrade to
    // a flat colour: the first stop (gradient) / the tile colour (pattern) —
    // nicer than the old #808080 and never a crash.
    _withOnlySelected: function (el, fn) {
      var cv = AI.canvas(), prev = [];
      try { prev = cv.getSelectedElems().filter(function (e) { return !!e; }); } catch (e) {}
      try { cv.clearSelection(); cv.addToSelection([el]); } catch (e) {}
      try { return fn(); }
      finally {
        try {
          cv.clearSelection();
          if (prev.length) cv.addToSelection(prev);
        } catch (e) {}
      }
    },
    applyGradient: function (el, gc, target) {
      target = (target === 'stroke') ? 'stroke' : 'fill';
      var g = gc && gc.gradient;
      var stops = (g && g.gradientStops) || [];
      var spec = {
        target: target,
        type: (g && g.type === GradientType.RADIAL) ? 'radial' : 'linear',
        angle: +(gc.angle || 0),
        stops: []
      };
      for (var i = 0; i < stops.length; i++) {
        var s = stops[i];
        spec.stops.push({
          offset: Math.max(0, Math.min(1, (+(s.rampPoint || 0)) / 100)),
          color: AI.colorToCss(s.color),
          opacity: (s.opacity == null ? 100 : +s.opacity) / 100
        });
      }
      // The gradient routes require >= 2 stops (real AI gradients always have
      // them); pad a degenerate single-stop gradient into a flat-looking one.
      if (spec.stops.length === 1) spec.stops.push({ offset: 1, color: spec.stops[0].color, opacity: spec.stops[0].opacity });
      if (!spec.stops.length) { AI._setAttr(el, target, '#808080'); return ''; }

      var M = global.main;
      if (M && typeof M.js_set_gradient_fill === 'function' && typeof global.wkutil !== 'undefined') {
        // Webview route (the fork's engine applies def + url to the selection).
        return AI._withOnlySelected(el, function () {
          return M.js_set_gradient_fill(wkutil.b64EncodeUnicode(JSON.stringify(spec)));
        });
      }
      // NATIVE runner route (svgfeedback SVGE-1024): no webview main bridge on
      // JavaScriptCore — the shim's svgCanvas.authorGradientDef authors the def
      // inside the open "Run script" tx and returns its id; we point the paint
      // at it through the normal (staging-aware) attribute path.
      var CV = global.svgCanvas;
      if (CV && typeof CV.authorGradientDef === 'function') {
        var gid = CV.authorGradientDef(JSON.stringify(spec));
        if (gid) { AI._setAttr(el, target, 'url(#' + gid + ')'); return gid; }
      }
      // Last resort: flat colour of the first stop.
      AI._setAttr(el, target, AI.colorToCss(stops[0].color));
      return '';
    },
    applyPattern: function (el, pc, target) {
      target = (target === 'stroke') ? 'stroke' : 'fill';
      var p = pc && pc.pattern;
      var M = global.main;
      if (!p) { AI._setAttr(el, target, '#808080'); return ''; }
      if (!M || typeof M.js_set_pattern_fill !== 'function' || typeof global.wkutil === 'undefined') {
        AI._setAttr(el, target, p.color || '#808080');
        return '';
      }
      var spec = {
        target: target,
        tile: p.tile, href: p.href, width: p.width, height: p.height,
        color: p.color, background: p.background, size: p.size,
        repeat: p.repeat, x: p.x, y: p.y,
        patternTransform: p.patternTransform
      };
      // AI-style rotation/scaleFactor (PatternColor, %) → patternTransform.
      if (!spec.patternTransform && (pc.rotation || (pc.scaleFactor != null && +pc.scaleFactor !== 100))) {
        var t = [];
        if (pc.rotation) t.push('rotate(' + (+pc.rotation) + ')');
        if (pc.scaleFactor != null && +pc.scaleFactor !== 100) t.push('scale(' + (+pc.scaleFactor / 100) + ')');
        spec.patternTransform = t.join(' ');
      }
      return AI._withOnlySelected(el, function () {
        return M.js_set_pattern_fill(wkutil.b64EncodeUnicode(JSON.stringify(spec)));
      });
    },

    // ---- color helpers ----
    clamp255: function (v) { v = Math.round(+v || 0); return v < 0 ? 0 : v > 255 ? 255 : v; },
    toHex2: function (v) { var s = AI.clamp255(v).toString(16); return s.length < 2 ? '0' + s : s; },
    colorToCss: function (c) {
      if (!c || c.typename === 'NoColor') return 'none';
      switch (c.typename) {
        case 'RGBColor':
          return '#' + AI.toHex2(c.red) + AI.toHex2(c.green) + AI.toHex2(c.blue);
        case 'GrayColor': {
          var g = Math.round(255 * (1 - (c.gray || 0) / 100));
          return '#' + AI.toHex2(g) + AI.toHex2(g) + AI.toHex2(g);
        }
        case 'CMYKColor': {
          var C = (c.cyan || 0) / 100, M = (c.magenta || 0) / 100,
              Y = (c.yellow || 0) / 100, K = (c.black || 0) / 100;
          return '#' + AI.toHex2(255 * (1 - C) * (1 - K)) +
                       AI.toHex2(255 * (1 - M) * (1 - K)) +
                       AI.toHex2(255 * (1 - Y) * (1 - K));
        }
        case 'SpotColor':
          return c.spot && c.spot.color ? AI.colorToCss(c.spot.color) : '#000000';
        case 'GradientColor':
          return '#808080'; // gradients handled elsewhere; fallback fill
        default:
          return '#000000';
      }
    },
    cssToRGB: function (css) {
      var c = new global.RGBColor();
      if (!css || css === 'none') { return new global.NoColor(); }
      css = css.trim();
      var m;
      if (css[0] === '#') {
        var h = css.slice(1);
        if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];
        c.red = parseInt(h.slice(0, 2), 16);
        c.green = parseInt(h.slice(2, 4), 16);
        c.blue = parseInt(h.slice(4, 6), 16);
      } else if ((m = css.match(/rgba?\(([^)]+)\)/))) {
        var p = m[1].split(',');
        c.red = parseFloat(p[0]); c.green = parseFloat(p[1]); c.blue = parseFloat(p[2]);
      }
      return c;
    }
  };

  // ----------------------------------------------------------------------------
  // Collections — array-like, live over the SVG DOM. Proxy gives numeric access.
  // ----------------------------------------------------------------------------
  function makeCollection(spec) {
    // spec: { list: () => [wrappers], typename, extra: { method: fn, ... } }
    var base = {
      typename: spec.typename || 'Collection',
      get length() { return spec.list().length; },
      getByName: function (name) {
        var a = spec.list();
        for (var i = 0; i < a.length; i++) if (a[i].name === name) return a[i];
        throw new Error('No element named "' + name + '"');
      },
      index: function (item) {
        var a = spec.list();
        for (var i = 0; i < a.length; i++) if (a[i]._el === item._el) return i;
        return -1;
      },
      removeAll: function () { spec.list().forEach(function (w) { w.remove(); }); },
      toArray: function () { return spec.list(); }
    };
    if (spec.extra) for (var k in spec.extra) if (spec.extra.hasOwnProperty(k)) base[k] = spec.extra[k];

    return new Proxy(base, {
      get: function (target, prop) {
        if (typeof prop === 'string' && /^\d+$/.test(prop)) return spec.list()[+prop];
        return target[prop];
      },
      has: function (target, prop) {
        if (typeof prop === 'string' && /^\d+$/.test(prop)) return +prop < spec.list().length;
        return prop in target;
      }
    });
  }

  function wrapAll(elements, wrapFn) {
    var out = [];
    for (var i = 0; i < elements.length; i++) { var w = wrapFn(elements[i]); if (w) out.push(w); }
    return out;
  }

  // Direct SVG element children of a container matching a predicate.
  function childElements(container, predicate) {
    var out = [];
    if (!container) return out;
    for (var i = 0; i < container.childNodes.length; i++) {
      var n = container.childNodes[i];
      if (n.nodeType === 1 && (!predicate || predicate(n))) out.push(n);
    }
    return out;
  }
  // Deep query (used for pageItems-style collections).
  function descendants(container, tags) {
    if (!container) return [];
    var sel = tags.join(',');
    return Array.prototype.slice.call(container.querySelectorAll(sel));
  }

  var GEOM_TAGS = ['path', 'rect', 'ellipse', 'circle', 'line', 'polygon', 'polyline'];

  function isGeom(n) { return GEOM_TAGS.indexOf(n.tagName) !== -1; }
  function isGroup(n) { return n.tagName === 'g' && n.getAttribute('class') !== 'layer'; }
  function isText(n) { return n.tagName === 'text'; }
  function isLayer(n) { return n.tagName === 'g' && n.getAttribute('class') === 'layer'; }
  // A placed bitmap. Deliberately NOT in GEOM_TAGS: an <image> is not a path, so it must not
  // leak into doc.pathItems — it gets its own RasterItem wrapper and its own collections.
  function isRaster(n) { return n.tagName === 'image'; }

  // ----------------------------------------------------------------------------
  // Wrapper cache — one JS wrapper per SVG element (identity stability).
  // ----------------------------------------------------------------------------
  var WRAP = new WeakMap();
  function wrapItem(el) {
    if (!el) return null;
    if (WRAP.has(el)) return WRAP.get(el);
    var w;
    if (isText(el)) w = new global.TextFrame(el);
    else if (isGroup(el)) w = new global.GroupItem(el);
    else if (isRaster(el)) w = new global.RasterItem(el);
    else if (isGeom(el)) w = new global.PathItem(el);
    else return null;
    WRAP.set(el, w);
    return w;
  }

  // ----------------------------------------------------------------------------
  // PageItem base — shared geometry/order/state for path/group/text.
  // ----------------------------------------------------------------------------
  function definePageItem(proto) {
    Object.defineProperty(proto, 'name', {
      get: function () {
        var t = this._el.querySelector(':scope > title');
        return t ? t.textContent : (this._el.getAttribute('id') || '');
      },
      set: function (v) {
        var t = this._el.querySelector(':scope > title');
        if (!t) { t = document.createElementNS(SVGNS, 'title'); this._el.insertBefore(t, this._el.firstChild); }
        t.textContent = v;
      }
    });
    Object.defineProperty(proto, 'note', {
      get: function () { return this._el.getAttribute('data-ai-note') || ''; },
      set: function (v) { this._el.setAttribute('data-ai-note', v); }
    });
    Object.defineProperty(proto, 'uuid', { get: function () { return this._el.getAttribute('id'); } });
    Object.defineProperty(proto, 'typename', { get: function () { return this._typename; } });
    Object.defineProperty(proto, 'parent', {
      get: function () {
        var p = this._el.parentNode;
        if (!p) return null;
        if (isLayer(p)) return new global.Layer(p);
        if (isGroup(p)) return wrapItem(p);
        return app.activeDocument;
      }
    });
    Object.defineProperty(proto, 'layer', {
      get: function () {
        var p = this._el;
        while (p && !isLayer(p)) p = p.parentNode;
        return p ? new global.Layer(p) : null;
      }
    });

    // ---- geometric bounds ----
    proto._bbox = function () {
      try { return this._el.getBBox(); }
      catch (e) { return { x: 0, y: 0, width: 0, height: 0 }; }
    };
    Object.defineProperty(proto, 'width', {
      get: function () { return AI.xToAi(this._bbox().width); },
      set: function (v) { var b = this._bbox(); this.resize(AI.xToSvg(v) / (b.width || 1) * 100, 100); }
    });
    Object.defineProperty(proto, 'height', {
      get: function () { return AI.yToAi(this._bbox().height); },
      set: function (v) { var b = this._bbox(); this.resize(100, AI.yToSvg(v) / (b.height || 1) * 100); }
    });
    // position = [left, top] of the geometric bounds, in AI coordinates
    Object.defineProperty(proto, 'position', {
      get: function () { var b = this._bbox(); return [AI.xToAi(b.x), AI.yToAi(b.y)]; },
      set: function (p) {
        var b = this._bbox();
        this.translate(AI.xToSvg(p[0]) - b.x, AI.yToSvg(p[1]) - b.y);
      }
    });
    Object.defineProperty(proto, 'left', {
      get: function () { return AI.xToAi(this._bbox().x); },
      set: function (v) { var b = this._bbox(); this.translate(AI.xToSvg(v) - b.x, 0); }
    });
    Object.defineProperty(proto, 'top', {
      get: function () { return AI.yToAi(this._bbox().y); },
      set: function (v) { var b = this._bbox(); this.translate(0, AI.yToSvg(v) - b.y); }
    });
    Object.defineProperty(proto, 'geometricBounds', {
      // AI order: [left, top, right, bottom]
      get: function () {
        var b = this._bbox();
        return [AI.xToAi(b.x), AI.yToAi(b.y), AI.xToAi(b.x + b.width), AI.yToAi(b.y + b.height)];
      }
    });
    Object.defineProperty(proto, 'visibleBounds', {
      get: function () { return this.geometricBounds; }
    });

    // ---- visibility / lock / opacity ----
    Object.defineProperty(proto, 'hidden', {
      get: function () { return this._el.getAttribute('display') === 'none'; },
      set: function (v) { if (v) this._el.setAttribute('display', 'none'); else this._el.removeAttribute('display'); }
    });
    Object.defineProperty(proto, 'locked', {
      get: function () { return this._el.getAttribute('data-ai-locked') === 'true'; },
      set: function (v) { this._el.setAttribute('data-ai-locked', v ? 'true' : 'false'); }
    });
    Object.defineProperty(proto, 'opacity', {
      get: function () { var o = this._el.getAttribute('opacity'); return o == null ? 100 : parseFloat(o) * 100; },
      set: function (v) { this._el.setAttribute('opacity', Math.max(0, Math.min(100, v)) / 100); }
    });
    Object.defineProperty(proto, 'blendingMode', {
      get: function () { return this._el.style.mixBlendMode || 'normal'; },
      set: function (v) { this._el.style.mixBlendMode = v; }
    });

    // ---- selection ----
    Object.defineProperty(proto, 'selected', {
      get: function () {
        try { return AI.canvas().getSelectedElems().indexOf(this._el) !== -1; } catch (e) { return false; }
      },
      set: function (v) {
        try {
          if (v) AI.canvas().addToSelection([this._el]);
          else AI.canvas().removeFromSelection([this._el]);
        } catch (e) {}
      }
    });

    // ---- z-order ----
    Object.defineProperty(proto, 'zOrderPosition', {
      get: function () {
        var p = this._el.parentNode, i = 0, n = p.firstChild;
        while (n) { if (n.nodeType === 1) { if (n === this._el) return i; i++; } n = n.nextSibling; }
        return -1;
      }
    });
    proto.zOrder = function (cmd) {
      var p = this._el.parentNode; if (!p) return;
      switch (cmd) {
        case ZOrderMethod.BRINGTOFRONT: p.appendChild(this._el); break;
        case ZOrderMethod.SENDTOBACK: p.insertBefore(this._el, p.firstChild); break;
        case ZOrderMethod.BRINGFORWARD:
          if (this._el.nextElementSibling) p.insertBefore(this._el.nextElementSibling, this._el); break;
        case ZOrderMethod.SENDBACKWARD:
          if (this._el.previousElementSibling) p.insertBefore(this._el, this._el.previousElementSibling); break;
      }
    };

    // ---- transforms ----
    proto._appendTransform = function (matrixStr) {
      var old = this._el.getAttribute('transform');
      var cur = old || '';
      this._el.setAttribute('transform', (matrixStr + ' ' + cur).trim());
      // Record the attribute change so script-driven transforms are undoable
      // (feature #663). ai-dom never BAKES a transform into x/y/w/h — it only
      // appends to the `transform` attribute — so a single ChangeElementCommand
      // on `transform` is the complete, correct inverse (restores the prior
      // transform, or removes it when there was none). No baking hazard here.
      try {
        if (AI._batch && global.svgedit && svgedit.history) {
          var o = { transform: old };   // null restores => removes the attribute
          AI._batch.addSubCommand(new svgedit.history.ChangeElementCommand(this._el, o, 'script transform'));
        }
      } catch (e) {}
    };
    proto.translate = function (dx, dy) {
      // AI translate args are in points; positive dy is downward in topdown.
      var sdy = AIConfig.coordinateSystem === 'bottomup' ? -dy : dy;
      this._appendTransform('translate(' + (dx) + ',' + (sdy) + ')');
      return this;
    };
    proto.rotate = function (angle, aboutX, aboutY) {
      var b = this._bbox();
      var cx = (aboutX != null) ? AI.xToSvg(aboutX) : (b.x + b.width / 2);
      var cy = (aboutY != null) ? AI.yToSvg(aboutY) : (b.y + b.height / 2);
      // AI angles are counter-clockwise; SVG rotate is clockwise → negate.
      this._appendTransform('rotate(' + (-angle) + ',' + cx + ',' + cy + ')');
      return this;
    };
    proto.resize = function (scaleX, scaleY, changePositions, aboutX, aboutY) {
      var b = this._bbox();
      var cx = (aboutX != null) ? AI.xToSvg(aboutX) : b.x;
      var cy = (aboutY != null) ? AI.yToSvg(aboutY) : b.y;
      var sx = (scaleX == null ? 100 : scaleX) / 100, sy = (scaleY == null ? 100 : scaleY) / 100;
      this._appendTransform('translate(' + cx + ',' + cy + ') scale(' + sx + ',' + sy + ') translate(' + (-cx) + ',' + (-cy) + ')');
      return this;
    };
    proto.transform = function (m) {
      if (!m) return this;
      this._appendTransform('matrix(' + m.mValueA + ',' + m.mValueB + ',' + m.mValueC + ',' +
        m.mValueD + ',' + m.mValueTX + ',' + m.mValueTY + ')');
      return this;
    };

    // ---- lifecycle ----
    proto.remove = function () {
      if (this._el.parentNode) {
        if (AI._batch && global.svgedit && svgedit.history) {
          try { AI._batch.addSubCommand(new svgedit.history.RemoveElementCommand(this._el, this._el.nextSibling, this._el.parentNode)); } catch (e) {}
        }
        this._el.parentNode.removeChild(this._el);
      }
    };
    proto.duplicate = function (relativeObject, insertLocation) {
      var clone = this._el.cloneNode(true);
      clone.setAttribute('id', AI.nextId());
      var host = this._el.parentNode;
      host.appendChild(clone);
      AI.register(clone);
      return wrapItem(clone);
    };
    proto.move = function (relativeObject, insertLocation) {
      // simplified: reparent into relativeObject if it is a group/layer container
      var target = relativeObject && relativeObject._el ? relativeObject._el : null;
      if (target && (isGroup(target) || isLayer(target))) target.appendChild(this._el);
      return this;
    };
  }

  // ----------------------------------------------------------------------------
  // Paint helpers (fill / stroke) shared by path & text
  // ----------------------------------------------------------------------------
  function definePaint(proto) {
    Object.defineProperty(proto, 'filled', {
      get: function () { var f = this._el.getAttribute('fill'); return !!f && f !== 'none'; },
      set: function (v) { if (!v) AI._setAttr(this._el, 'fill', 'none'); else if (this._el.getAttribute('fill') === 'none') AI._setAttr(this._el, 'fill', '#000000'); }
    });
    Object.defineProperty(proto, 'stroked', {
      get: function () { var s = this._el.getAttribute('stroke'); return !!s && s !== 'none'; },
      set: function (v) { if (!v) AI._setAttr(this._el, 'stroke', 'none'); else if (!this._el.getAttribute('stroke') || this._el.getAttribute('stroke') === 'none') AI._setAttr(this._el, 'stroke', '#000000'); }
    });
    Object.defineProperty(proto, 'fillColor', {
      get: function () { return AI.cssToRGB(this._el.getAttribute('fill') || '#000000'); },
      set: function (c) {
        if (c && c.typename === 'GradientColor') { AI.applyGradient(this._el, c, 'fill'); return; }
        if (c && c.typename === 'PatternColor') { AI.applyPattern(this._el, c, 'fill'); return; }
        AI._setAttr(this._el, 'fill', AI.colorToCss(c)); if (c && c.typename !== 'NoColor') { var o = c._opacity; if (o != null) AI._setAttr(this._el, 'fill-opacity', o); }
      }
    });
    Object.defineProperty(proto, 'strokeColor', {
      get: function () { return AI.cssToRGB(this._el.getAttribute('stroke') || 'none'); },
      set: function (c) {
        if (c && c.typename === 'GradientColor') { AI.applyGradient(this._el, c, 'stroke'); return; }
        if (c && c.typename === 'PatternColor') { AI.applyPattern(this._el, c, 'stroke'); return; }
        AI._setAttr(this._el, 'stroke', AI.colorToCss(c));
      }
    });
    Object.defineProperty(proto, 'strokeWidth', {
      get: function () { return AI.xToAi(parseFloat(this._el.getAttribute('stroke-width') || '1')); },
      set: function (v) { AI._setAttr(this._el, 'stroke-width', AI.xToSvg(v)); }
    });
    Object.defineProperty(proto, 'strokeCap', {
      get: function () { return this._el.getAttribute('stroke-linecap') || StrokeCap.BUTTENDCAP; },
      set: function (v) { AI._setAttr(this._el, 'stroke-linecap', v); }
    });
    Object.defineProperty(proto, 'strokeJoin', {
      get: function () { return this._el.getAttribute('stroke-linejoin') || StrokeJoin.MITERENDJOIN; },
      set: function (v) { AI._setAttr(this._el, 'stroke-linejoin', v); }
    });
    Object.defineProperty(proto, 'strokeDashes', {
      get: function () {
        var d = this._el.getAttribute('stroke-dasharray');
        return d && d !== 'none' ? d.split(/[ ,]+/).map(parseFloat) : [];
      },
      set: function (arr) { AI._setAttr(this._el, 'stroke-dasharray', (arr && arr.length) ? arr.join(',') : 'none'); }
    });
    Object.defineProperty(proto, 'fillOpacity', {
      get: function () { var o = this._el.getAttribute('fill-opacity'); return o == null ? 100 : parseFloat(o) * 100; },
      set: function (v) { AI._setAttr(this._el, 'fill-opacity', v / 100); }
    });
    Object.defineProperty(proto, 'strokeOpacity', {
      get: function () { var o = this._el.getAttribute('stroke-opacity'); return o == null ? 100 : parseFloat(o) * 100; },
      set: function (v) { AI._setAttr(this._el, 'stroke-opacity', v / 100); }
    });
  }

  // ----------------------------------------------------------------------------
  // PathItem
  // ----------------------------------------------------------------------------
  function PathItem(el) { this._el = el; this._typename = 'PathItem'; }
  definePageItem(PathItem.prototype);
  definePaint(PathItem.prototype);
  Object.defineProperty(PathItem.prototype, 'closed', {
    get: function () {
      if (this._el.tagName !== 'path') return true;
      return /z\s*$/i.test((this._el.getAttribute('d') || '').trim());
    },
    set: function (v) {
      if (this._el.tagName !== 'path') return;
      var d = (this._el.getAttribute('d') || '').trim();
      var isClosed = /z\s*$/i.test(d);
      if (v && !isClosed) this._el.setAttribute('d', d + ' Z');
      else if (!v && isClosed) this._el.setAttribute('d', d.replace(/\s*z\s*$/i, ''));
    }
  });
  Object.defineProperty(PathItem.prototype, 'pathPoints', {
    get: function () {
      var self = this;
      return makeCollection({
        typename: 'PathPoints',
        list: function () { return self._readPathPoints(); },
        extra: {
          add: function () {
            var pts = self._rawPoints();
            var pp = { anchor: [0, 0], leftDirection: [0, 0], rightDirection: [0, 0], pointType: PointType.CORNER };
            pts.push(pp); self._writePoints(pts);
            return self._readPathPoints()[pts.length - 1];
          }
        }
      });
    }
  });
  PathItem.prototype.setEntirePath = function (arrayOfPoints) {
    // arrayOfPoints: [[x,y], [x,y], ...] in AI coordinates
    var pts = arrayOfPoints.map(function (p) {
      return { anchor: [p[0], p[1]], leftDirection: [p[0], p[1]], rightDirection: [p[0], p[1]], pointType: PointType.CORNER };
    });
    this._writePoints(pts, this.closed);
  };
  PathItem.prototype._rawPoints = function () {
    // internal representation kept on the element as JSON for round-tripping handles
    var raw = this._el.getAttribute('data-ai-points');
    if (raw) { try { return JSON.parse(raw); } catch (e) {} }
    return this._pointsFromD();
  };
  PathItem.prototype._pointsFromD = function () {
    var pts = [];
    if (this._el.tagName === 'rect') {
      var x = +this._el.getAttribute('x'), y = +this._el.getAttribute('y'),
          w = +this._el.getAttribute('width'), h = +this._el.getAttribute('height');
      [[x, y], [x + w, y], [x + w, y + h], [x, y + h]].forEach(function (a) {
        pts.push({ anchor: a.slice(), leftDirection: a.slice(), rightDirection: a.slice(), pointType: PointType.CORNER });
      });
      return pts;
    }
    // path: parse M/L/C commands (coarse — enough for scripted geometry)
    var d = this._el.getAttribute('d') || '';
    var tokens = d.match(/[a-df-z]|-?\d*\.?\d+(?:e[-+]?\d+)?/gi) || [];
    var i = 0, cx = 0, cy = 0, cmd = '';
    function num() { return parseFloat(tokens[i++]); }
    while (i < tokens.length) {
      var t = tokens[i];
      if (/[a-z]/i.test(t)) { cmd = t; i++; }
      if (cmd === 'M' || cmd === 'L') { cx = num(); cy = num(); pts.push(mk([cx, cy])); }
      else if (cmd === 'C') { var c1x = num(), c1y = num(), c2x = num(), c2y = num(); cx = num(); cy = num();
        var p = mk([cx, cy]); p.leftDirection = [c2x, c2y]; if (pts.length) pts[pts.length - 1].rightDirection = [c1x, c1y]; pts.push(p); }
      else if (cmd === 'Z' || cmd === 'z') { i++; }
      else { i++; }
    }
    function mk(a) { return { anchor: a.slice(), leftDirection: a.slice(), rightDirection: a.slice(), pointType: PointType.CORNER }; }
    return pts;
  };
  PathItem.prototype._readPathPoints = function () {
    var self = this, raw = this._rawPoints();
    return raw.map(function (p, idx) { return new global.PathPoint(self, idx, p); });
  };
  PathItem.prototype._writePoints = function (pts, close) {
    this._el.setAttribute('data-ai-points', JSON.stringify(pts));
    // build SVG path d from anchors + bezier handles, in SVG coords
    if (this._el.tagName !== 'path') {
      // convert primitive to path so points can drive geometry
      var np = AI.create('path', { id: this._el.getAttribute('id'), fill: this._el.getAttribute('fill') || 'none', stroke: this._el.getAttribute('stroke') || 'none' }, this._el.parentNode);
      // (addSvgElementFromJson reuses id → replaces primitive); fall through using np
      this._el = np;
    }
    var d = '';
    for (var i = 0; i < pts.length; i++) {
      var a = [AI.xToSvg(pts[i].anchor[0]), AI.yToSvg(pts[i].anchor[1])];
      if (i === 0) { d += 'M ' + a[0] + ' ' + a[1]; }
      else {
        var prev = pts[i - 1];
        var c1 = [AI.xToSvg(prev.rightDirection[0]), AI.yToSvg(prev.rightDirection[1])];
        var c2 = [AI.xToSvg(pts[i].leftDirection[0]), AI.yToSvg(pts[i].leftDirection[1])];
        d += ' C ' + c1[0] + ' ' + c1[1] + ' ' + c2[0] + ' ' + c2[1] + ' ' + a[0] + ' ' + a[1];
      }
    }
    if (close !== false && pts.length > 2) d += ' Z';
    this._el.setAttribute('d', d);
  };

  // PathPoint — live view onto one entry of a PathItem's point array.
  function PathPoint(path, index, data) {
    this._path = path; this._index = index; this._data = data;
    this.typename = 'PathPoint';
  }
  ['anchor', 'leftDirection', 'rightDirection', 'pointType'].forEach(function (prop) {
    Object.defineProperty(PathPoint.prototype, prop, {
      get: function () { return this._data[prop]; },
      set: function (v) {
        var pts = this._path._rawPoints();
        pts[this._index][prop] = v; this._data[prop] = v;
        this._path._writePoints(pts, this._path.closed);
      }
    });
  });
  PathPoint.prototype.remove = function () {
    var pts = this._path._rawPoints(); pts.splice(this._index, 1); this._path._writePoints(pts, this._path.closed);
  };

  // ----------------------------------------------------------------------------
  // GroupItem
  // ----------------------------------------------------------------------------
  function GroupItem(el) { this._el = el; this._typename = 'GroupItem'; }
  definePageItem(GroupItem.prototype);
  ['pathItems', 'groupItems', 'textFrames', 'pageItems', 'compoundPathItems', 'rasterItems', 'placedItems'].forEach(function (name) {
    Object.defineProperty(GroupItem.prototype, name, {
      get: function () { return buildItemsCollection(this._el, name, this._el); }
    });
  });
  GroupItem.prototype.ungroup = function () {
    var parent = this._el.parentNode, kids = childElements(this._el, function (n) { return n.tagName !== 'title'; });
    kids.forEach(function (k) { parent.insertBefore(k, this._el); }, this);
    this.remove();
  };

  // ----------------------------------------------------------------------------
  // Fonts + per-character text ranges — svgfeedback SVGE-1315
  //
  // A TextFont is Illustrator's face descriptor: `name` is the PostScript name, `family` the
  // family, `style` the sub-family ("Bold Italic"). Scripts read app.textFonts (now backed by
  // the REAL installed faces — see AI.fonts), pick one, and assign it to
  // characterAttributes.textFont, most often ONE CHARACTER AT A TIME.
  //
  // Per-character styling is stored the way the app's own text editor stores it: one styled
  // character (or run of equally-styled characters) is an INLINE run <tspan> carrying a
  // `style` (and optionally `fill`) and NO x/y — exactly text_edit's makeRunTspan/isRunTspan
  // shape. So a font a script assigns survives serialization, reopening, and a later in-place
  // edit, and the Character palette reads it back. Line <tspan>s (the x/y ones of an area
  // text) are never rewritten: runs are edited INSIDE each line, so geometry is untouched.
  // ----------------------------------------------------------------------------
  function makeTextFont(name, family, style) {
    return {
      typename: 'TextFont',
      name: name, family: family, style: style,
      toString: function () { return this.name; }
    };
  }

  // What a face's STYLE name means in CSS. Only the two axes SVG selects a family member
  // with: assigning "Helvetica Bold Oblique" by family alone would silently paint the
  // regular cut. A face without a style name leaves weight/slant alone.
  function fontCss(f) {
    var family, style;
    if (f && typeof f === 'object') { family = f.family || f.name || ''; style = f.style || ''; }
    else { family = String(f == null ? '' : f); style = ''; }
    var css = {};
    if (family) css['font-family'] = family;
    if (style) {
      var s = String(style).toLowerCase();
      css['font-weight'] = /(^|[\s-])(bold|black|heavy|semibold|demibold|extrabold|ultrabold)([\s-]|$)/.test(s)
        ? 'bold' : 'normal';
      css['font-style'] = /(italic|oblique)/.test(s) ? 'italic' : 'normal';
    }
    return css;
  }

  // ---- minimal CSS declaration-list read/write on a run's `style` string ----
  function styleGet(style, prop) {
    var parts = String(style || '').split(';');
    for (var i = 0; i < parts.length; i++) {
      var c = parts[i].indexOf(':');
      if (c < 0) continue;
      if (parts[i].slice(0, c).trim() === prop) return parts[i].slice(c + 1).trim();
    }
    return '';
  }
  function styleSet(style, props) {
    var out = [], parts = String(style || '').split(';');
    for (var i = 0; i < parts.length; i++) {
      var c = parts[i].indexOf(':');
      if (c < 0) continue;
      var k = parts[i].slice(0, c).trim();
      if (!k || props.hasOwnProperty(k)) continue;      // replaced below
      out.push(k + ':' + parts[i].slice(c + 1).trim());
    }
    for (var p in props) if (props.hasOwnProperty(p) && props[p] != null && props[p] !== '')
      out.push(p + ':' + props[p]);
    return out.length ? out.join(';') + ';' : '';
  }

  // The containers holding a frame's glyphs: the LINE <tspan>s (x/y — area text / wrapped
  // lines) when there are any, else the <text> element itself (point text).
  function textHosts(el) {
    var lines = childElements(el, function (n) {
      return n.tagName === 'tspan' && (n.hasAttribute('x') || n.hasAttribute('y'));
    });
    return lines.length ? lines : [el];
  }

  // One host's inline content as an ordered [{text, style, fill}]. On the native engine the
  // shim's childNodes is ELEMENTS-only, so the interleaving of bare text and run <tspan>s is
  // only observable through svgCanvas.textRuns (scripting.mm walks the real node); inside the
  // WKWebView we walk the DOM ourselves.
  function readRuns(host) {
    var tr = null;
    try { tr = AI.canvas().textRuns; } catch (e) {}
    if (tr && tr.read) {
      return (tr.read(host) || []).map(function (r) {
        return { text: String(r.text || ''), style: String(r.style || ''), fill: String(r.fill || '') };
      });
    }
    var out = [];
    (function walk(node, style, fill) {
      var kids = node.childNodes;
      for (var i = 0; i < kids.length; i++) {
        var k = kids[i];
        if (k.nodeType === 3) { if (k.nodeValue) out.push({ text: k.nodeValue, style: style, fill: fill }); }
        else if (k.nodeType === 1 && k.tagName === 'tspan')
          walk(k, k.getAttribute('style') || style, k.getAttribute('fill') || fill);
      }
    })(host, '', '');
    return out;
  }

  function writeRuns(host, runs) {
    var tr = null;
    try { tr = AI.canvas().textRuns; } catch (e) {}
    if (tr && tr.write) { tr.write(host, runs); return; }
    while (host.firstChild) host.removeChild(host.firstChild);
    for (var i = 0; i < runs.length; i++) {
      var r = runs[i];
      if (!r.text) continue;
      if (!r.style && !r.fill) { host.appendChild(document.createTextNode(r.text)); continue; }
      var ts = document.createElementNS(SVGNS, 'tspan');
      if (r.fill) ts.setAttribute('fill', r.fill);
      if (r.style) ts.setAttribute('style', r.style);
      ts.setAttribute('xml:space', 'preserve');
      ts.textContent = r.text;
      host.appendChild(ts);
      AI.register(ts);
    }
  }

  // The frame flattened to ONE ENTRY PER CHARACTER — the addressable unit textRange.characters
  // hands out. Each entry remembers which host it belongs to so a rewrite stays inside its line.
  function frameChars(el) {
    var out = [], hosts = textHosts(el);
    for (var h = 0; h < hosts.length; h++) {
      var runs = readRuns(hosts[h]);
      for (var r = 0; r < runs.length; r++) {
        var t = runs[r].text;
        for (var c = 0; c < t.length; c++)
          out.push({ host: hosts[h], ch: t.charAt(c), style: runs[r].style, fill: runs[r].fill });
      }
    }
    return out;
  }

  // Rewrite the frame from per-character entries, COALESCING neighbours that share a style
  // back into one run — styling every character the same way leaves ONE <tspan>, not one per
  // glyph, so the document does not bloat and the native editor sees the runs it expects.
  function writeFrameChars(el, chars) {
    var hosts = textHosts(el);
    for (var h = 0; h < hosts.length; h++) {
      var runs = [];
      for (var i = 0; i < chars.length; i++) {
        if (chars[i].host !== hosts[h]) continue;
        var last = runs.length ? runs[runs.length - 1] : null;
        if (last && last.style === chars[i].style && last.fill === chars[i].fill) last.text += chars[i].ch;
        else runs.push({ text: chars[i].ch, style: chars[i].style, fill: chars[i].fill });
      }
      writeRuns(hosts[h], runs);
    }
  }

  // One character of a TextFrame, as Illustrator's length-1 TextRange. Every read/write goes
  // through the frame's run list, so the SVG stays in the app's charstyle shape.
  function CharacterRange(frame, index) { this._frame = frame; this._i = index; this.typename = 'TextRange'; }
  CharacterRange.prototype._read = function () { return frameChars(this._frame._el)[this._i] || null; };
  CharacterRange.prototype._apply = function (props, fill) {
    var chars = frameChars(this._frame._el), c = chars[this._i];
    if (!c) return;
    if (props) c.style = styleSet(c.style, props);
    if (fill != null) c.fill = fill;
    writeFrameChars(this._frame._el, chars);
  };
  CharacterRange.prototype.remove = function () {
    var chars = frameChars(this._frame._el);
    if (this._i < 0 || this._i >= chars.length) return;
    chars.splice(this._i, 1);
    writeFrameChars(this._frame._el, chars);
  };
  Object.defineProperty(CharacterRange.prototype, 'length', { get: function () { return this._read() ? 1 : 0; } });
  Object.defineProperty(CharacterRange.prototype, 'contents', {
    get: function () { var c = this._read(); return c ? c.ch : ''; }
  });
  Object.defineProperty(CharacterRange.prototype, 'characterAttributes', {
    get: function () {
      var self = this, frameEl = this._frame._el;
      function styleOf() { var c = self._read(); return c ? c.style : ''; }
      return {
        get textFont() {
          var st = styleOf();
          var fam = styleGet(st, 'font-family') || frameEl.getAttribute('font-family') || 'sans-serif';
          var bold = (styleGet(st, 'font-weight') || frameEl.getAttribute('font-weight') || '') === 'bold';
          var ital = /italic|oblique/.test(styleGet(st, 'font-style') || frameEl.getAttribute('font-style') || '');
          var face = (bold ? 'Bold' : '') + (ital ? (bold ? ' Italic' : 'Italic') : '');
          return makeTextFont(fam, fam, face || 'Regular');
        },
        set textFont(f) { self._apply(fontCss(f)); },
        get size() {
          var s = styleGet(styleOf(), 'font-size') || frameEl.getAttribute('font-size') || '16';
          return AI.yToAi(parseFloat(s));
        },
        set size(v) { self._apply({ 'font-size': AI.yToSvg(v) + 'px' }); },
        get fillColor() {
          var c = self._read();
          return AI.cssToRGB((c && c.fill) || frameEl.getAttribute('fill') || '#000000');
        },
        set fillColor(col) { self._apply(null, AI.colorToCss(col)); }
      };
    }
  });

  // Illustrator's `Characters` collection (frame.characters / frame.textRange.characters).
  function charactersOf(frame) {
    return makeCollection({
      typename: 'Characters',
      list: function () {
        var n = frameChars(frame._el).length, a = [];
        for (var i = 0; i < n; i++) a.push(new CharacterRange(frame, i));
        return a;
      }
    });
  }

  // ----------------------------------------------------------------------------
  // TextFrame
  // ----------------------------------------------------------------------------
  function TextFrame(el) { this._el = el; this._typename = 'TextFrame'; }
  definePageItem(TextFrame.prototype);
  definePaint(TextFrame.prototype);
  Object.defineProperty(TextFrame.prototype, 'contents', {
    get: function () { return this._el.textContent || ''; },
    set: function (v) { this._el.textContent = v; }
  });
  Object.defineProperty(TextFrame.prototype, 'kind', { get: function () { return TextType.POINTTEXT; } });
  Object.defineProperty(TextFrame.prototype, 'characterAttributes', {
    get: function () {
      var el = this._el;
      return {
        get size() { return AI.yToAi(parseFloat(el.getAttribute('font-size') || '16')); },
        set size(v) { el.setAttribute('font-size', AI.yToSvg(v)); },
        // SVGE-1315: the FAMILY is what goes on font-family (`name` is the PostScript name —
        // writing it made the renderer fall back to the default face); the face's style name
        // becomes the weight/slant that select that member of the family.
        get textFont() {
          var fam = el.getAttribute('font-family') || 'sans-serif';
          var bold = (el.getAttribute('font-weight') || '') === 'bold';
          var ital = /italic|oblique/.test(el.getAttribute('font-style') || '');
          var face = (bold ? 'Bold' : '') + (ital ? (bold ? ' Italic' : 'Italic') : '');
          return makeTextFont(fam, fam, face || 'Regular');
        },
        set textFont(f) {
          var css = fontCss(f);
          for (var p in css) if (css.hasOwnProperty(p)) AI._setAttr(el, p, css[p]);
        },
        get fillColor() { return AI.cssToRGB(el.getAttribute('fill') || '#000000'); },
        set fillColor(c) { AI._setAttr(el, 'fill', AI.colorToCss(c)); }
      };
    }
  });
  // The whole frame as a TextRange, plus its per-character sub-ranges (SVGE-1315). Setting an
  // attribute on the FRAME range writes the element level (one attribute, every glyph);
  // `characters[i]` addresses one glyph through the inline run <tspan>s.
  Object.defineProperty(TextFrame.prototype, 'textRange', {
    get: function () {
      var self = this;
      return {
        typename: 'TextRange',
        get contents() { return self.contents; },
        set contents(v) { self.contents = v; },
        get characterAttributes() { return self.characterAttributes; },
        get size() { return self.characterAttributes.size; },
        set size(v) { self.characterAttributes.size = v; },
        get length() { return frameChars(self._el).length; },
        get characters() { return charactersOf(self); }
      };
    }
  });
  Object.defineProperty(TextFrame.prototype, 'characters', {
    get: function () { return charactersOf(this); }
  });

  // ----------------------------------------------------------------------------
  // RasterItem — a placed bitmap (<image>), with PIXEL COLOUR reads.
  // svgfeedback SVGE-1159: the port of the paper.js "Using Pixel Colors" tutorial
  // surface, so a script can read an image that is IN the document and drive vector
  // output from it (mosaics, colour-from-reference art, redrawing over a reference
  // bitmap on a tracing-paper layer).
  //
  // TWO COORDINATE SPACES, and mixing them is the classic bug:
  //   • PIXEL space — the bitmap's own grid, (0,0) at its top-left texel, extent
  //     `size`. getPixel() speaks this one (paper.js does too).
  //   • DOCUMENT space — the SVG user space every other ai-dom coordinate uses.
  //     getAverageColor()'s area speaks this one.
  //   documentToPixel() / pixelToDocument() convert. They already account for the
  //   image's placement (x/y/width/height under preserveAspectRatio) AND for the
  //   transform of the node and of every ancestor — do NOT redo that by hand.
  //
  // TWO SIZES, and they are NOT interchangeable (svgfeedback SVGE-1439):
  //   • `width`/`height` are the PLACED size in document units — the PageItem meaning
  //     every other item has, what Illustrator's RasterItem.width reports, and the number
  //     the Links palette shows for the same image. `placedWidth`/`placedHeight` are
  //     explicit aliases; `geometricBounds`/`position`/`left`/`top` are the same geometry.
  //   • `size` (and its `pixelWidth`/`pixelHeight` scalars) is the BITMAP's own grid —
  //     paper.js Raster#size, the space getPixel() and pixelToDocument() speak.
  // They used to be the same property: `width`/`height` shadowed the placed geometry with
  // the pixel count, so a script (and the console line of demo/07-raster-mosaic) announced
  // "256x188" for an image the user sees as 96x70 in Links. Reporting a size in a unit the
  // caller did not ask for is worse than useless, so the two now have separate names.
  //
  // ENGINE: pixel reads need the native (Skia) scene — the pixel window is
  // `svgCanvas.rasterPixels`, provided by scripting_dom.js. Inside the WKWebView twin
  // there is no native scene, so these methods throw a clear error instead of
  // returning invented colours. Everything else (geometry, z-order, transforms,
  // remove/duplicate) works on both engines through the shared PageItem surface.
  // ----------------------------------------------------------------------------
  function RasterItem(el) { this._el = el; this._typename = 'RasterItem'; }
  // definePageItem's properties are non-configurable, so anything RasterItem wants to add
  // beside them needs its own prototype link. The PageItem surface goes on a BASE prototype
  // and RasterItem's own prototype inherits from it: `width`/`height` keep their PageItem
  // (placed) meaning through the chain, and the pixel-space properties below sit alongside
  // under their own names, together with bounds, z-order, transforms, remove/duplicate,
  // name/note/parent/layer/selected.
  function RasterItemBase() {}
  definePageItem(RasterItemBase.prototype);
  RasterItem.prototype = Object.create(RasterItemBase.prototype);
  RasterItem.prototype.constructor = RasterItem;

  // The native pixel window, or a thrown error naming what is missing.
  RasterItem.prototype._px = function () {
    var cv = AI.canvas();
    var px = cv && cv.rasterPixels;
    if (!px) throw new Error('pixel colors require the Skia engine (rasterPixels unavailable)');
    return px;
  };
  RasterItem.prototype._sizeArr = function () {
    var s = this._px().size(this._el);
    if (!s) throw new Error('image has no readable pixels (unsupported or broken href)');
    return s;
  };

  // ---- pixel dimensions (paper.js Raster#size), the space getPixel/pixelToDocument use ----
  Object.defineProperty(RasterItem.prototype, 'size', {
    get: function () { var s = this._sizeArr(); return { width: s[0], height: s[1] }; }
  });
  Object.defineProperty(RasterItem.prototype, 'pixelWidth', { get: function () { return this._sizeArr()[0]; } });
  Object.defineProperty(RasterItem.prototype, 'pixelHeight', { get: function () { return this._sizeArr()[1]; } });
  // The PLACED size in document units. `width`/`height` already mean exactly this (PageItem,
  // through RasterItemBase — Illustrator's own RasterItem semantics); these two are the
  // explicit spelling for a script that wants to be unambiguous next to `size`.
  Object.defineProperty(RasterItem.prototype, 'placedWidth', {
    get: function () { return AI.xToAi(this._bbox().width); }
  });
  Object.defineProperty(RasterItem.prototype, 'placedHeight', {
    get: function () { return AI.yToAi(this._bbox().height); }
  });
  // The href, so scripts can tell embedded (data:) from linked bitmaps.
  Object.defineProperty(RasterItem.prototype, 'source', {
    get: function () { return this._el.getAttribute('xlink:href') || this._el.getAttribute('href') || ''; }
  });
  Object.defineProperty(RasterItem.prototype, 'embedded', {
    get: function () { return /^data:/i.test(this.source); }
  });

  // ---- coordinate conversion ----
  RasterItem.prototype._matrix = function () {
    var m = this._px().matrix(this._el);
    if (!m || m.length < 12) throw new Error('image has no readable pixels (unsupported or broken href)');
    return m;
  };
  function applyM(m, o, x, y) {
    return [m[o] * x + m[o + 2] * y + m[o + 4], m[o + 1] * x + m[o + 3] * y + m[o + 5]];
  }
  /** Document point [x,y] (or x, y) → fractional pixel coordinates [px,py]. */
  RasterItem.prototype.documentToPixel = function (px, py) {
    var p = readPoint(px, py);
    return applyM(this._matrix(), 0, AI.xToSvg(p[0]), AI.yToSvg(p[1]));
  };
  /** Pixel coordinates [px,py] (or px, py) → the document point [x,y] they sit at. */
  RasterItem.prototype.pixelToDocument = function (px, py) {
    var p = readPoint(px, py);
    var d = applyM(this._matrix(), 6, p[0], p[1]);
    return [AI.xToAi(d[0]), AI.yToAi(d[1])];
  };

  /**
   * getPixel(x, y) — the colour of ONE texel, in PIXEL coordinates (paper.js
   * Raster#getPixel). Accepts (x, y) or a [x, y] point. Returns an RGBColor carrying
   * the texel's alpha both as `.alpha` (0..1) and as `._opacity`, so assigning it
   * straight to `item.fillColor` also transfers the transparency. Returns null when
   * (x, y) is outside the bitmap — never a made-up colour.
   * To sample at a DOCUMENT point: raster.getPixel(raster.documentToPixel([x, y])).
   */
  RasterItem.prototype.getPixel = function (x, y) {
    var p = readPoint(x, y);
    var c = this._px().pixel(this._el, Math.floor(p[0]), Math.floor(p[1]));
    return c ? rgbaToColor(c) : null;
  };

  /**
   * getAverageColor(area) — the average colour of the bitmap under `area`, with
   * paper.js semantics: the texels covered by the area are averaged ALPHA-WEIGHTED
   * (Σ c·α / Σ α) and the result is null when nothing is covered (or everything
   * covered is fully transparent). The area is in DOCUMENT coordinates and may be:
   *   • a page item (PathItem / GroupItem / TextFrame / another RasterItem) — its own
   *     outline is used, so a circle really samples a disc, not its bounding box;
   *   • [left, top, width, height] or {x, y, width, height} — an axis-aligned box;
   *   • omitted — the whole image.
   * `options.evenOdd` picks the even-odd fill rule; `options.samples` caps the
   * sampling grid per axis (default 32, i.e. at most ~1024 taps whatever the size).
   */
  RasterItem.prototype.getAverageColor = function (area, options) {
    var px = this._px();
    options = options || {};
    var spec = {};
    if (area == null) {
      var b = this._bbox();
      spec.rect = [b.x, b.y, b.width, b.height];
    } else if (area._el) {
      var h = px.handle(area._el);
      if (!h) return null;
      spec.node = h;
    } else if (Array.isArray(area) && area.length >= 4) {
      spec.rect = [AI.xToSvg(area[0]), AI.yToSvg(area[1]), AI.xToSvg(area[2]), AI.yToSvg(area[3])];
    } else if (typeof area === 'object' && area.width != null && area.height != null) {
      spec.rect = [AI.xToSvg(area.x || 0), AI.yToSvg(area.y || 0),
                   AI.xToSvg(area.width), AI.yToSvg(area.height)];
    } else {
      throw new Error('getAverageColor: area must be an item, [x,y,w,h], {x,y,width,height} or omitted');
    }
    if (options.evenOdd) spec.evenOdd = true;
    if (options.samples) spec.samples = +options.samples;
    var c = px.average(this._el, JSON.stringify(spec));
    return c ? rgbaToColor(c) : null;
  };

  // Accept (x, y) or ([x, y]) / ({x, y}) — the argument shape paper.js is lenient about.
  function readPoint(a, b) {
    if (Array.isArray(a)) return [+a[0], +a[1]];
    if (a && typeof a === 'object' && a.x != null) return [+a.x, +a.y];
    return [+a, +b];
  }
  // Native [r, g, b, a] (r/g/b 0..255, a 0..1) → an ai-dom RGBColor.
  function rgbaToColor(c) {
    var col = new global.RGBColor();
    col.red = c[0]; col.green = c[1]; col.blue = c[2];
    col.alpha = (c[3] == null) ? 1 : c[3];
    // fillColor/strokeColor forward _opacity to fill-opacity/stroke-opacity. Only carry it
    // when the sample is actually translucent, so an opaque colour writes a clean `fill`
    // with no redundant fill-opacity="1".
    if (col.alpha < 1) col._opacity = col.alpha;
    return col;
  }

  // ----------------------------------------------------------------------------
  // Item collections shared by Document / Layer / GroupItem
  // ----------------------------------------------------------------------------
  // scopeEl        — element whose subtree the collection reports on
  // createContainer — where new items are inserted (null → active layer, matching
  //                   Illustrator: doc.pathItems.add() lands on the active layer)
  //
  // Geometry lives INSIDE layer <g> groups, never directly under #svgcontent, so
  // typed collections query the scope recursively (querySelectorAll), the same
  // way document.pathItems is recursive in Illustrator.
  function buildItemsCollection(scopeEl, kind, createContainer) {
    function notLayerNotSelf(n) { return !isLayer(n) && n !== scopeEl && !inDefs(n); }
    function inDefs(n) { var p = n.parentNode; while (p && p !== scopeEl) { if (p.tagName === 'defs') return true; p = p.parentNode; } return false; }
    function notDefs(n) { return !inDefs(n); }
    function listOf() {
      var raw;
      if (kind === 'pathItems' || kind === 'compoundPathItems')
        raw = descendants(scopeEl, GEOM_TAGS).filter(notDefs);
      else if (kind === 'groupItems')
        raw = descendants(scopeEl, ['g']).filter(notLayerNotSelf);
      else if (kind === 'textFrames')
        raw = descendants(scopeEl, ['text']).filter(notDefs);
      // SVGE-1159 — placed bitmaps. In Illustrator rasterItems are the EMBEDDED images and
      // placedItems the LINKED ones; SVG expresses both as <image> (embedded = a data: href,
      // linked = a file href), so both collections list every <image> and RasterItem.embedded
      // tells them apart. Defs are excluded like everywhere else (a pattern tile is not art).
      else if (kind === 'rasterItems' || kind === 'placedItems')
        raw = descendants(scopeEl, ['image']).filter(notDefs);
      else { // pageItems — direct children (of the scope, or of every layer for the doc)
        var isPageItem = function (n) { return isGeom(n) || isGroup(n) || isText(n) || isRaster(n); };
        if (scopeEl === AI.root()) {
          raw = [];
          childElements(scopeEl, isLayer).forEach(function (lg) {
            raw = raw.concat(childElements(lg, isPageItem));
          });
        } else {
          raw = childElements(scopeEl, isPageItem);
        }
      }
      return wrapAll(raw, wrapItem);
    }

    var extra = {};
    if (kind === 'pathItems') {
      extra.add = function () {
        return wrapItem(AI.create('path', { d: 'M 0 0', fill: '#000000', stroke: 'none' }, createContainer));
      };
      extra.rectangle = function (top, left, width, height) {
        return wrapItem(AI.create('rect', {
          x: AI.xToSvg(left), y: AI.yToSvg(top), width: AI.xToSvg(width), height: AI.yToSvg(height),
          fill: '#000000', stroke: 'none'
        }, createContainer));
      };
      extra.roundedRectangle = function (top, left, width, height, hRad, vRad) {
        return wrapItem(AI.create('rect', {
          x: AI.xToSvg(left), y: AI.yToSvg(top), width: AI.xToSvg(width), height: AI.yToSvg(height),
          rx: AI.xToSvg(hRad || 0), ry: AI.yToSvg(vRad || hRad || 0), fill: '#000000', stroke: 'none'
        }, createContainer));
      };
      extra.ellipse = function (top, left, width, height) {
        var w = AI.xToSvg(width), h = AI.yToSvg(height);
        return wrapItem(AI.create('ellipse', {
          cx: AI.xToSvg(left) + w / 2, cy: AI.yToSvg(top) + h / 2, rx: w / 2, ry: h / 2,
          fill: '#000000', stroke: 'none'
        }, createContainer));
      };
      extra.polygon = function (cx, cy, radius, sides) {
        sides = sides || 6; var pts = [];
        for (var i = 0; i < sides; i++) {
          var ang = -Math.PI / 2 + i * 2 * Math.PI / sides;
          pts.push((AI.xToSvg(cx) + radius * Math.cos(ang)) + ',' + (AI.yToSvg(cy) + radius * Math.sin(ang)));
        }
        return wrapItem(AI.create('polygon', { points: pts.join(' '), fill: '#000000', stroke: 'none' }, createContainer));
      };
      extra.star = function (cx, cy, radius, innerRadius, points) {
        points = points || 5; var pts = [];
        for (var i = 0; i < points * 2; i++) {
          var r = (i % 2 === 0) ? radius : innerRadius;
          var ang = -Math.PI / 2 + i * Math.PI / points;
          pts.push((AI.xToSvg(cx) + r * Math.cos(ang)) + ',' + (AI.yToSvg(cy) + r * Math.sin(ang)));
        }
        return wrapItem(AI.create('polygon', { points: pts.join(' '), fill: '#000000', stroke: 'none' }, createContainer));
      };
    }
    if (kind === 'groupItems') {
      extra.add = function () { return wrapItem(AI.create('g', {}, createContainer)); };
    }
    if (kind === 'textFrames') {
      extra.add = function () {
        return wrapItem(AI.create('text', { x: 0, y: 0, 'font-size': 16, 'font-family': 'sans-serif', fill: '#000000' }, createContainer));
      };
      extra.pointText = function (posOrX, maybeY) {
        var x, y;
        if (Array.isArray(posOrX)) { x = posOrX[0]; y = posOrX[1]; } else { x = posOrX; y = maybeY; }
        return wrapItem(AI.create('text', { x: AI.xToSvg(x), y: AI.yToSvg(y), 'font-size': 16, 'font-family': 'sans-serif', fill: '#000000' }, createContainer));
      };
      extra.areaText = extra.pointText;
    }

    return makeCollection({ typename: kind, list: listOf, extra: extra });
  }

  // ----------------------------------------------------------------------------
  // Layer
  // ----------------------------------------------------------------------------
  function Layer(el) { this._el = el; this.typename = 'Layer'; }
  Object.defineProperty(Layer.prototype, 'name', {
    get: function () { var t = this._el.querySelector(':scope > title'); return t ? t.textContent : ''; },
    set: function (v) {
      try { AI.canvas().setCurrentLayer(this.name); AI.canvas().renameCurrentLayer(v); }
      catch (e) { var t = this._el.querySelector(':scope > title'); if (t) t.textContent = v; }
    }
  });
  Object.defineProperty(Layer.prototype, 'visible', {
    get: function () { return this._el.getAttribute('display') !== 'none'; },
    set: function (v) { try { AI.canvas().setLayerVisibility(this.name, v); } catch (e) { if (v) this._el.removeAttribute('display'); else this._el.setAttribute('display', 'none'); } }
  });
  Object.defineProperty(Layer.prototype, 'locked', {
    get: function () { return this._el.getAttribute('data-ai-locked') === 'true'; },
    set: function (v) { this._el.setAttribute('data-ai-locked', v ? 'true' : 'false'); }
  });
  Object.defineProperty(Layer.prototype, 'opacity', {
    get: function () { var o = this._el.getAttribute('opacity'); return o == null ? 100 : parseFloat(o) * 100; },
    set: function (v) { this._el.setAttribute('opacity', v / 100); }
  });
  Object.defineProperty(Layer.prototype, 'color', {
    get: function () { return AI.cssToRGB(this._el.getAttribute('data-svge-color') || '#4F80FF'); },
    set: function (c) { this._el.setAttribute('data-svge-color', AI.colorToCss(c)); }
  });
  ['pathItems', 'groupItems', 'textFrames', 'pageItems', 'compoundPathItems', 'rasterItems', 'placedItems'].forEach(function (name) {
    Object.defineProperty(Layer.prototype, name, {
      get: function () { return buildItemsCollection(this._el, name, this._el); }
    });
  });
  Object.defineProperty(Layer.prototype, 'layers', {
    get: function () { return makeCollection({ typename: 'Layers', list: function () { return []; }, extra: { add: function () { return app.activeDocument.layers.add(); } } }); }
  });
  Layer.prototype.remove = function () {
    try { AI.canvas().setCurrentLayer(this.name); AI.canvas().deleteCurrentLayer(); }
    catch (e) { if (this._el.parentNode) this._el.parentNode.removeChild(this._el); }
  };
  Layer.prototype.move = function () { return this; };

  // ----------------------------------------------------------------------------
  // Document
  // ----------------------------------------------------------------------------
  function Document() { this.typename = 'Document'; }
  Object.defineProperty(Document.prototype, '_root', { get: function () { return AI.root(); } });
  Object.defineProperty(Document.prototype, 'name', {
    get: function () { try { return AI.canvas().getDocumentTitle() || 'Untitled'; } catch (e) { return 'Untitled'; } }
  });
  Object.defineProperty(Document.prototype, 'width', { get: function () { return AI.xToAi(AI.docWidth()); } });
  Object.defineProperty(Document.prototype, 'height', { get: function () { return AI.yToAi(AI.docHeight()); } });
  Object.defineProperty(Document.prototype, 'documentColorSpace', {
    get: function () { return DocumentColorSpace.RGB; },
    set: function (v) { /* SVG is RGB only; no-op */ }
  });
  ['pathItems', 'groupItems', 'textFrames', 'pageItems', 'compoundPathItems', 'rasterItems', 'placedItems'].forEach(function (name) {
    Object.defineProperty(Document.prototype, name, {
      get: function () { return buildItemsCollection(AI.root(), name, null); }
    });
  });
  Object.defineProperty(Document.prototype, 'layers', {
    get: function () {
      return makeCollection({
        typename: 'Layers',
        list: function () { return wrapAll(childElements(AI.root(), isLayer), function (e) { return new Layer(e); }); },
        extra: {
          add: function () {
            var name = 'Layer ' + (childElements(AI.root(), isLayer).length + 1);
            try { AI.canvas().createLayer(name); } catch (e) {}
            var layers = childElements(AI.root(), isLayer);
            return new Layer(layers[layers.length - 1]);
          }
        }
      });
    }
  });
  Object.defineProperty(Document.prototype, 'activeLayer', {
    get: function () {
      try { var g = AI.canvas().getCurrentDrawing().getCurrentLayer(); if (g) return new Layer(g); } catch (e) {}
      var ls = childElements(AI.root(), isLayer); return ls.length ? new Layer(ls[ls.length - 1]) : null;
    },
    set: function (l) { try { AI.canvas().setCurrentLayer(l.name); } catch (e) {} }
  });
  Object.defineProperty(Document.prototype, 'selection', {
    get: function () {
      var sel = [];
      try { sel = AI.canvas().getSelectedElems().filter(Boolean); } catch (e) {}
      return wrapAll(sel, wrapItem);
    },
    set: function (arr) {
      try {
        AI.canvas().clearSelection();
        if (arr && arr.length) AI.canvas().addToSelection(arr.map(function (w) { return w._el; }));
      } catch (e) {}
    }
  });
  Object.defineProperty(Document.prototype, 'artboards', {
    get: function () {
      return makeCollection({
        typename: 'Artboards',
        list: function () { return [{ name: 'Artboard 1', artboardRect: [0, 0, AI.docWidth(), -AI.docHeight()] }]; }
      });
    }
  });
  // stub collections (present so scripts don't crash). placedItems/rasterItems USED to live
  // here; SVGE-1159 promoted them to real <image> collections (see buildItemsCollection).
  ['symbolItems', 'meshItems', 'pluginItems', 'graphItems', 'symbols', 'swatches', 'spots'].forEach(function (name) {
    Object.defineProperty(Document.prototype, name, {
      get: function () { return makeCollection({ typename: name, list: function () { return []; } }); }
    });
  });

  // Real session collections: doc.gradients / doc.patterns (AI-style — a
  // Gradient/Pattern is defined here, then APPLIED by assigning fillColor =
  // GradientColor/PatternColor; only the application writes SVG defs, via
  // AI.applyGradient/applyPattern). Session-scoped like Illustrator's own
  // unsaved swatches: scripts always add what they use.
  AI._gradients = [];
  AI._patterns = [];
  Object.defineProperty(Document.prototype, 'gradients', {
    get: function () {
      return makeCollection({
        typename: 'Gradients',
        list: function () { return AI._gradients; },
        extra: {
          add: function () {
            var g = new Gradient();
            g.name = 'Gradient ' + (AI._gradients.length + 1);
            AI._gradients.push(g);
            return g;
          }
        }
      });
    }
  });
  Object.defineProperty(Document.prototype, 'patterns', {
    get: function () {
      return makeCollection({
        typename: 'Patterns',
        list: function () { return AI._patterns; },
        extra: {
          add: function () {
            var p = new Pattern();
            p.name = 'Pattern ' + (AI._patterns.length + 1);
            AI._patterns.push(p);
            return p;
          }
        }
      });
    }
  });

  // doc.brushes — the native Brushes palette library (presets + user brushes),
  // injected by the Mac side as window.__svge_brush_library. Brush.applyTo(item)
  // is the real Illustrator API: stamps the brush along the item's path and
  // REPLACES it (its own "Brush Stroke" undo step, via main.js_brush_apply_el).
  // Webview engine only: on the native/JSC engine the library is never injected
  // (empty collection) and applyTo throws a clear error.
  function Brush(def) {
    this.typename = 'Brush';
    this.name = def.name || def.id;
    this._def = def;
  }
  Brush.prototype.applyTo = function (item) {
    var M = global.main;
    if (!M || typeof M.js_brush_apply_el !== 'function') {
      throw new Error('brushes require the webview engine');
    }
    var el = item && item._el;
    if (!el) throw new Error('applyTo needs a path item');
    var g = M.js_brush_apply_el(el, this._def, null, true);
    return g ? wrapItem(g) : null;
  };
  Object.defineProperty(Document.prototype, 'brushes', {
    get: function () {
      var lib = global.__svge_brush_library || [];
      var items = lib.map(function (d) { return new Brush(d); });
      return makeCollection({ typename: 'Brushes', list: function () { return items; } });
    }
  });
  Document.prototype.exportFile = function (file, type, options) {
    var name = (file && (file.name || file.fsName)) || ('export.' + (type || 'svg')).toLowerCase();
    var svg = '';
    try { svg = AI.canvas().getSvgString(); } catch (e) {}
    // Hand off to native for real file writing (Swift decides where/how).
    try {
      window.webkit.messageHandlers.msg.postMessage({ ai_export: { name: String(name), type: String(type || 'SVG'), svg: svg } });
    } catch (e) {}
    try { ace_wk_logger.log('[AI] exportFile → ' + name + ' (' + type + ')'); } catch (e) {}
    return true;
  };
  Document.prototype.saveAs = function () { return true; };
  Document.prototype.save = function () { try { window.webkit.messageHandlers.msg.postMessage({ ai_save: true }); } catch (e) {} return true; };
  Document.prototype.close = function () { return true; };
  Document.prototype.selectObjectsOnActiveArtboard = function () { try { AI.canvas().selectAllInCurrentLayer && AI.canvas().selectAllInCurrentLayer(); } catch (e) {} };
  Document.prototype.rasterize = function () { return null; };
  Document.prototype.groupItemsFromSelection = function () { try { AI.canvas().groupSelectedElements(); } catch (e) {} };

  // ----------------------------------------------------------------------------
  // Matrix + Application
  // ----------------------------------------------------------------------------
  function Matrix(a, b, c, d, tx, ty) {
    this.mValueA = a == null ? 1 : a; this.mValueB = b || 0; this.mValueC = c || 0;
    this.mValueD = d == null ? 1 : d; this.mValueTX = tx || 0; this.mValueTY = ty || 0;
    this.typename = 'Matrix';
  }

  function Application() {
    this.typename = 'Application';
    this.name = 'Adobe Illustrator';
    this.version = '28.0';
    this.buildNumber = 'SVGEditor';
    this.locale = 'en_US';
    this.userInteractionLevel = 0;
    this.scriptingVersion = '28.0';
    this.preferences = makePreferences();
    this.selection = [];
    var self = this;
    Object.defineProperty(this, 'documents', {
      get: function () {
        return makeCollection({
          typename: 'Documents',
          list: function () { return [self._doc]; },
          extra: {
            add: function () { return self._doc; },
            getByName: function () { return self._doc; }
          }
        });
      }
    });
    Object.defineProperty(this, 'activeDocument', {
      get: function () { return self._doc; },
      set: function () {}
    });
    Object.defineProperty(this, 'selection', {
      get: function () { return self._doc.selection; },
      set: function (v) { self._doc.selection = v; }
    });
    // svgfeedback SVGE-1315 — the REAL installed faces (AI.fonts: CoreText natively, the
    // editor's own font popup inside the WKWebView). This list used to be hard-coded empty,
    // which is why a font script could only report "no font available" and stop.
    this.textFonts = makeCollection({
      typename: 'TextFonts',
      list: function () { return AI.fonts(); },
      extra: {
        getByName: function (n) {
          var a = AI.fonts(), want = String(n == null ? '' : n);
          for (var i = 0; i < a.length; i++) if (a[i].name === want) return a[i];
          for (var j = 0; j < a.length; j++) if (a[j].family === want) return a[j];
          // Unknown face: hand back a usable descriptor instead of throwing, so a script that
          // hard-codes a font name still runs (the family lands on the element as written).
          return makeTextFont(want, want, 'Regular');
        }
      }
    });
  }
  Application.prototype.redraw = function () { try { AI.canvas().call && AI.canvas().call('changed', AI._created); } catch (e) {} };
  Application.prototype.concatenateMatrix = function (m1, m2) {
    return new Matrix(
      m1.mValueA * m2.mValueA + m1.mValueB * m2.mValueC,
      m1.mValueA * m2.mValueB + m1.mValueB * m2.mValueD,
      m1.mValueC * m2.mValueA + m1.mValueD * m2.mValueC,
      m1.mValueC * m2.mValueB + m1.mValueD * m2.mValueD,
      m1.mValueTX * m2.mValueA + m1.mValueTY * m2.mValueC + m2.mValueTX,
      m1.mValueTX * m2.mValueB + m1.mValueTY * m2.mValueD + m2.mValueTY
    );
  };
  Application.prototype.getScaleMatrix = function (sx, sy) { return new Matrix((sx == null ? 100 : sx) / 100, 0, 0, (sy == null ? 100 : sy) / 100, 0, 0); };
  Application.prototype.getRotationMatrix = function (angle) {
    var r = (angle || 0) * Math.PI / 180; return new Matrix(Math.cos(r), Math.sin(r), -Math.sin(r), Math.cos(r), 0, 0);
  };
  Application.prototype.getTranslationMatrix = function (tx, ty) { return new Matrix(1, 0, 0, 1, tx || 0, ty || 0); };
  Application.prototype.getIdentityMatrix = function () { return new Matrix(1, 0, 0, 1, 0, 0); };
  Application.prototype.concatenateTranslationMatrix = function (m, tx, ty) { return this.concatenateMatrix(m, this.getTranslationMatrix(tx, ty)); };
  Application.prototype.concatenateScaleMatrix = function (m, sx, sy) { return this.concatenateMatrix(m, this.getScaleMatrix(sx, sy)); };
  Application.prototype.concatenateRotationMatrix = function (m, a) { return this.concatenateMatrix(m, this.getRotationMatrix(a)); };
  Application.prototype.invertMatrix = function (m) {
    var det = m.mValueA * m.mValueD - m.mValueB * m.mValueC; if (!det) return this.getIdentityMatrix();
    return new Matrix(m.mValueD / det, -m.mValueB / det, -m.mValueC / det, m.mValueA / det,
      (m.mValueC * m.mValueTY - m.mValueD * m.mValueTX) / det, (m.mValueB * m.mValueTX - m.mValueA * m.mValueTY) / det);
  };
  Application.prototype.undo = function () { try { AI.canvas().undoMgr.undo(); } catch (e) {} };
  Application.prototype.redo = function () { try { AI.canvas().undoMgr.redo(); } catch (e) {} };
  Application.prototype.executeMenuCommand = function (cmd) {
    try {
      var cv = AI.canvas();
      switch (cmd) {
        case 'selectall': cv.selectAllInCurrentLayer(); break;
        case 'deselectall': cv.clearSelection(); break;
        case 'group': cv.groupSelectedElements(); break;
        case 'ungroup': cv.ungroupSelectedElement(); break;
        case 'cut': case 'copy': case 'paste': break;
        case 'clear': cv.deleteSelectedElements(); break;
        // Pathfinder — the REAL Illustrator menu-command names scripts use
        // (there is no DOM pathfinder API in AI either). They act on the
        // selection via the add.js Pathfinder (pathbool.js core). Webview
        // engine only: without `main` they log and no-op like the default.
        case 'Live Pathfinder Add':
        case 'Live Pathfinder Subtract':
        case 'Live Pathfinder Intersect':
        case 'Live Pathfinder Exclude':
        case 'Live Pathfinder Minus Back':
        case 'Live Pathfinder Divide':
        case 'Live Pathfinder Trim':
        case 'Live Pathfinder Merge':
        case 'Live Pathfinder Crop': {
          var M = global.main;
          var op = ({
            'Live Pathfinder Add': 'unite',
            'Live Pathfinder Subtract': 'subtract',
            'Live Pathfinder Intersect': 'intersect',
            'Live Pathfinder Exclude': 'exclude',
            'Live Pathfinder Minus Back': 'subtract_back',
            'Live Pathfinder Divide': 'divide',
            'Live Pathfinder Trim': 'trim',
            'Live Pathfinder Merge': 'merge',
            'Live Pathfinder Crop': 'crop'
          })[cmd];
          var ex = (op === 'divide' || op === 'trim' || op === 'merge' || op === 'crop');
          if (M && typeof (ex ? M.js_pathfinder_ex : M.js_pathfinder) === 'function') {
            if (ex) M.js_pathfinder_ex(op); else M.js_pathfinder(op);
          } else {
            ace_wk_logger.log('[AI] ' + cmd + ' unavailable on this engine');
          }
          break;
        }
        default: ace_wk_logger.log('[AI] executeMenuCommand ignored: ' + cmd);
      }
    } catch (e) {}
  };
  Application.prototype.open = function () { return this._doc; };
  Application.prototype.loadColorScheme = function () {};
  Application.prototype.showColorPicker = function () { return false; };

  function makePreferences() {
    var store = {};
    return {
      typename: 'Preferences',
      getBooleanPreference: function (k) { return !!store[k]; },
      setBooleanPreference: function (k, v) { store[k] = !!v; },
      getIntegerPreference: function (k) { return store[k] || 0; },
      setIntegerPreference: function (k, v) { store[k] = v; },
      getRealPreference: function (k) { return store[k] || 0; },
      setRealPreference: function (k, v) { store[k] = v; },
      getStringPreference: function (k) { return store[k] || ''; },
      setStringPreference: function (k, v) { store[k] = v; }
    };
  }

  // ----------------------------------------------------------------------------
  // Color constructors
  // ----------------------------------------------------------------------------
  function RGBColor() { this.typename = 'RGBColor'; this.red = 0; this.green = 0; this.blue = 0; }
  function CMYKColor() { this.typename = 'CMYKColor'; this.cyan = 0; this.magenta = 0; this.yellow = 0; this.black = 0; }
  function GrayColor() { this.typename = 'GrayColor'; this.gray = 0; }
  function NoColor() { this.typename = 'NoColor'; }
  function SpotColor() { this.typename = 'SpotColor'; this.spot = null; this.tint = 100; }
  // GradientColor / PatternColor — REAL now (svgfeedback MCP/scripting update):
  // assigning them to fillColor/strokeColor writes actual SVG defs via
  // AI.applyGradient/applyPattern (webview engine; flat-colour fallback on the
  // native engine). Field names follow the Illustrator DOM.
  function GradientColor() {
    this.typename = 'GradientColor';
    this.gradient = null;      // a Gradient (doc.gradients.add())
    this.angle = 0;            // degrees, AI convention (0 = left→right)
    this.origin = null;        // accepted, unused in v1
    this.length = null;        // accepted, unused in v1
    this.matrix = null;        // accepted, unused in v1
    this.hiliteAngle = 0;
    this.hiliteLength = 0;
  }
  function PatternColor() {
    this.typename = 'PatternColor';
    this.pattern = null;       // a Pattern (doc.patterns.add())
    this.rotation = 0;         // degrees → patternTransform rotate()
    this.scaleFactor = 100;    // percent → patternTransform scale()
    this.shiftDistance = 0;
    this.shiftAngle = 0;
    this.matrix = null;
  }
  function LabColor() { this.typename = 'LabColor'; this.l = 0; this.a = 0; this.b = 0; }

  // Gradient model (doc.gradients.add() → set stops → GradientColor.gradient).
  // gradientStops is a plain array augmented with the AI collection verbs, so
  // both `stops.add()` and `stops[0]` / `stops.length` behave like Illustrator.
  function GradientStop() {
    this.typename = 'GradientStop';
    this.color = new RGBColor();
    this.rampPoint = 0;        // 0..100 (% along the ramp)
    this.midPoint = 50;
    this.opacity = 100;
  }
  function Gradient() {
    this.typename = 'Gradient';
    this.name = '';
    this.type = GradientType.LINEAR;
    var stops = [];
    stops.add = function () { var s = new GradientStop(); stops.push(s); return s; };
    stops.removeAll = function () { stops.length = 0; };
    this.gradientStops = stops;
  }
  // Pattern model (doc.patterns.add() → PatternColor.pattern). Fields map 1:1
  // onto the js_set_pattern_fill spec (procedural vector tiles or raster href).
  function Pattern() {
    this.typename = 'Pattern';
    this.name = '';
    this.tile = 'dots';        // 'dots'|'stripes'|'stripes-h'|'stripes-d'|'checker'|'grid'|'crosshatch'
    this.color = '#000000';
    this.background = '';      // '' = transparent
    this.size = 12;            // tile edge, px
    this.repeat = 'repeat';    // 'repeat'|'repeat-x'|'repeat-y'|'no-repeat'
    this.x = 0;
    this.y = 0;
    this.href = null;          // raster tile URL/data: (+ width/height required)
    this.width = null;
    this.height = null;
    this.patternTransform = null;
  }

  // ----------------------------------------------------------------------------
  // Enums / constants
  // ----------------------------------------------------------------------------
  var GradientType = { LINEAR: 'linear', RADIAL: 'radial' };
  var StrokeCap = { BUTTENDCAP: 'butt', ROUNDENDCAP: 'round', PROJECTINGENDCAP: 'square' };
  var StrokeJoin = { MITERENDJOIN: 'miter', ROUNDENDJOIN: 'round', BEVELENDJOIN: 'bevel' };
  var PointType = { SMOOTH: 'smooth', CORNER: 'corner' };
  var ZOrderMethod = { BRINGTOFRONT: 1, BRINGFORWARD: 2, SENDBACKWARD: 3, SENDTOBACK: 4 };
  var ElementPlacement = { PLACEATEND: 0, PLACEATBEGINNING: 1, INSIDE: 2, PLACEBEFORE: 3, PLACEAFTER: 4 };
  var DocumentColorSpace = { RGB: 'rgb', CMYK: 'cmyk' };
  var RulerUnits = { Points: 0, Picas: 1, Inches: 2, Millimeters: 3, Centimeters: 4, Pixels: 6, Unknown: 7 };
  var Justification = { LEFT: 'left', CENTER: 'center', RIGHT: 'right', FULLJUSTIFY: 'justify', FULLJUSTIFYLASTLINELEFT: 'justify' };
  var TextType = { POINTTEXT: 'point', AREATEXT: 'area', PATHTEXT: 'path' };
  var FillRule = { NONZEROWINDINGFILLRULE: 'nonzero', EVENODDFILLRULE: 'evenodd' };
  var BlendModes = { NORMAL: 'normal', MULTIPLY: 'multiply', SCREEN: 'screen', OVERLAY: 'overlay', DARKEN: 'darken', LIGHTEN: 'lighten', COLORDODGE: 'color-dodge', COLORBURN: 'color-burn', DIFFERENCE: 'difference', EXCLUSION: 'exclusion', HUE: 'hue', SATURATIONBLEND: 'saturation', COLORBLEND: 'color', LUMINOSITY: 'luminosity', DISSOLVE: 'normal', HARDLIGHT: 'hard-light', SOFTLIGHT: 'soft-light' };
  var ExportType = { SVG: 'SVG', PNG24: 'PNG24', PNG8: 'PNG8', JPEG: 'JPEG', GIF: 'GIF', PDF: 'PDF', TIFF: 'TIFF', AIT: 'AIT' };
  var Transformation = { DOCUMENTORIGIN: 0, TOPLEFT: 1, TOP: 2, TOPRIGHT: 3, LEFT: 4, CENTER: 5, RIGHT: 6, BOTTOMLEFT: 7, BOTTOM: 8, BOTTOMRIGHT: 9 };
  var LayerOrderType = { none: 0 };
  var ColorModel = { PROCESS: 0, SPOT: 1, REGISTRATION: 2 };
  var ImageColorSpace = { GrayScale: 'gray', Grayscale: 'gray', RGB: 'rgb', CMYK: 'cmyk', DeviceN: 'devicen', Separation: 'separation', Indexed: 'indexed' };
  var ColorConvertPurpose = { defaultpurpose: 0, previewpurpose: 1, exportpurpose: 2, dummypurpose: 3 };

  // app.convertSampleColor(sourceSpace, sourceColor[], destSpace, purpose) → number[]
  // Used by real colour scripts (AverageColors, etc.). Approximated in RGB.
  Application.prototype.convertSampleColor = function (srcSpace, srcColor, dstSpace, purpose) {
    var r, g, b, ss = String(srcSpace), ds = String(dstSpace);
    srcColor = srcColor || [];
    if (/cmyk/i.test(ss)) {
      var c = (srcColor[0] || 0) / 100, m = (srcColor[1] || 0) / 100, y = (srcColor[2] || 0) / 100, k = (srcColor[3] || 0) / 100;
      r = 255 * (1 - c) * (1 - k); g = 255 * (1 - m) * (1 - k); b = 255 * (1 - y) * (1 - k);
    } else if (/gray/i.test(ss)) {
      var gg = 255 * (1 - (srcColor[0] || 0) / 100); r = g = b = gg;
    } else { r = srcColor[0] || 0; g = srcColor[1] || 0; b = srcColor[2] || 0; }
    if (/cmyk/i.test(ds)) {
      var K = 1 - Math.max(r, g, b) / 255, denom = (1 - K) || 1;
      return [(1 - r / 255 - K) / denom * 100, (1 - g / 255 - K) / denom * 100, (1 - b / 255 - K) / denom * 100, K * 100];
    }
    if (/gray/i.test(ds)) { return [(1 - (0.299 * r + 0.587 * g + 0.114 * b) / 255) * 100]; }
    return [r, g, b];
  };

  // Simple option-object constructors used by exportFile()
  function ExportOptionsSVG() { this.typename = 'ExportOptionsSVG'; this.embedRasterImages = false; this.coordinatePrecision = 3; }
  function ExportOptionsPNG24() { this.typename = 'ExportOptionsPNG24'; this.transparency = true; this.artBoardClipping = true; this.horizontalScale = 100; this.verticalScale = 100; }
  function ExportOptionsPNG8() { this.typename = 'ExportOptionsPNG8'; this.transparency = true; }
  function ExportOptionsJPEG() { this.typename = 'ExportOptionsJPEG'; this.qualitySetting = 80; }

  // ----------------------------------------------------------------------------
  // Publish globals
  // ----------------------------------------------------------------------------
  var app = new Application();
  app._doc = new Document();

  var exports = {
    AI: AI, AIConfig: AIConfig,
    app: app, Application: Application, Document: Document, Layer: Layer,
    PathItem: PathItem, PathPoint: PathPoint, GroupItem: GroupItem, TextFrame: TextFrame,
    RasterItem: RasterItem,
    RGBColor: RGBColor, CMYKColor: CMYKColor, GrayColor: GrayColor, NoColor: NoColor,
    SpotColor: SpotColor, GradientColor: GradientColor, PatternColor: PatternColor, LabColor: LabColor,
    Gradient: Gradient, GradientStop: GradientStop, GradientType: GradientType, Pattern: Pattern,
    Matrix: Matrix,
    StrokeCap: StrokeCap, StrokeJoin: StrokeJoin, PointType: PointType, ZOrderMethod: ZOrderMethod,
    ElementPlacement: ElementPlacement, DocumentColorSpace: DocumentColorSpace, RulerUnits: RulerUnits,
    Justification: Justification, TextType: TextType, FillRule: FillRule, BlendModes: BlendModes,
    ExportType: ExportType, Transformation: Transformation, LayerOrderType: LayerOrderType, ColorModel: ColorModel,
    ExportOptionsSVG: ExportOptionsSVG, ExportOptionsPNG24: ExportOptionsPNG24,
    ExportOptionsPNG8: ExportOptionsPNG8, ExportOptionsJPEG: ExportOptionsJPEG,
    ImageColorSpace: ImageColorSpace, ColorConvertPurpose: ColorConvertPurpose,
    _wrapItem: wrapItem
  };
  for (var k in exports) if (exports.hasOwnProperty(k)) global[k] = exports[k];
  global.AIDOM = exports;

  // Illustrator exposes these as *globals* (bare `selection`, `activeDocument`,
  // `documents`, `activeLayer`) — real scripts rely on them heavily. Proxy to app.
  function galias(name, getter, setter) {
    try { Object.defineProperty(global, name, { get: getter, set: setter || function () {}, configurable: true }); }
    catch (e) { global[name] = getter(); }
  }
  galias('selection', function () { return app.selection; }, function (v) { app.selection = v; });
  galias('activeDocument', function () { return app.activeDocument; });
  galias('documents', function () { return app.documents; });
  galias('activeLayer', function () { return app.activeDocument.activeLayer; }, function (v) { app.activeDocument.activeLayer = v; });

})(this);
