UXP Plugin NAPI API Failure: "String expected" on `pathItems.add()`

Hello! My name is Joseph, and I can’t seem to get past this error, after so many failed attempts! This plugin is so close to being complete and even after this error gets finished the next issue that could arise is getting the actual symbols along a user-defined path to actually show up on the blank photoshop document. I would love any kind of insights on any of this please! See below for full details on the issue, goal of plugin, and everything I’ve tried! Thanks so much, I’ve spent several days trying to figure this out, and y’all are my last resort! :sob:

Plugin Goal
The goal of this UXP plugin is to draw weather front symbols (triangles for cold fronts, semicircles for warm fronts, etc.) along a user-defined path in Photoshop and be able to customize a few aspects of the weather front.

The Problem

The plugin consistently fails when trying to create a new path for the symbols using the UXP DOM API. The call to document.pathItems.add() results in a Error: NAPI API failure: String expected. This error occurs even with a simplified, hardcoded path, suggesting an issue within the UXP environment rather than the script’s logic, but not totally 100% sure on this.

Environment

  • Manifest Version: 4
  • Host App: Photoshop
  • Minimum Host Version: 27.0
// manifest.json
{
  "id": "com.weatherfronts.plugin.fresh",
  "name": "Weather Fronts Fresh",
  "version": "1.0.0",
  "main": "index.html",
  "manifestVersion": 4,
  "host": [
    {
      "app": "PS",
      "minVersion": "27.0"
    }
  ],
  "entrypoints": [
    {
      "type": "panel",
      "id": "mainPanel",
      "label": {
        "default": "Weather Fronts"
      }
    }
  ]
}

Debugging Steps Taken

We have exhaustively debugged this issue and ruled out many potential causes.

  1. Initial batchPlay Approach:

    • The first implementation used action.batchPlay to create paths.
    • This failed with an Error: NAPI API failure: Number expected.
    • We tried various data structures for path points (with and without _unit wrappers, with and without forward/backward handles) without success.
  2. Switch to UXP DOM API:

    • To avoid batchPlay complexities, we switched to the modern UXP DOM API, using document.pathItems.add().
    • This led to a new series of errors.
  3. PathPointInfo Constructor Errors:

    • Initially, we encountered TypeError: ... is not a constructor when trying to instantiate PathPointInfo.
    • We discovered through logging and inspecting official Adobe samples that the correct import is const { PathPointInfo, SubPathInfo } = require('photoshop').app;.
  4. PathKind Enum Issue:

    • After fixing the import, we used PathKind.CORNERPOINT for the pathPoint.kind property, as shown in Adobe’s samples.
    • This failed with TypeError: Cannot read properties of undefined (reading 'CORNERPOINT').
    • Through logging, we confirmed that require('photoshop').app.PathKind was undefined in the user’s environment.
  5. constants.PointKind Enum Issue:

    • We then tried using the require('photoshop').constants module.
    • Logging confirmed that constants.PointKind.CORNERPOINT resolves to the string "cornerPoint".
    • When using this enum, the error reverted to Error: NAPI API failure: String expected.
  6. String Literal Attempts:

    • Based on the logs and the error message, we tried passing string literals directly to the pathPoint.kind property.
    • Both "cornerPoint" and "CORNER_POINT" resulted in the same Error: NAPI API failure: String expected.
  7. Simplified Hardcoded Test:

    • To eliminate any possibility of calculation errors, we replaced the dynamic symbol drawing function with one that creates a simple, hardcoded triangle: [[100, 100], [200, 100], [150, 200]].
    • This simplified test case still failed with the exact same Error: NAPI API failure: String expected error. This is the strongest evidence that the issue is not with the script’s logic, but with the API call itself in the user’s environment (potentially).

Conclusion

The pathItems.add() method appears to be broken or behaving in an undocumented way in this specific UXP environment. The parameters being passed to it are correct according to the official documentation and logs, yet the call fails in the native layer with a misleading “String expected” error.

Final Code (index.js)

This is the final state of the code, which is as correct as possible according to the documentation. It fails on the doc.pathItems.add() line.

const { core, app, action } = require('photoshop');
const { PathPointInfo, SubPathInfo } = require('photoshop').app;

document.addEventListener("DOMContentLoaded", () => {
    console.log("Plugin DOM Loaded. Ready.");

    // --- Get All UI Elements ---
    const statusEl = document.getElementById("status");
    const drawButton = document.getElementById("btnDrawFront");
    const frontTypeButtons = document.querySelectorAll(".front-type-btn");
    
    // Customization Elements
    const sizeSlider = document.getElementById("slider-size");
    const spacingSlider = document.getElementById("slider-spacing");
    const colorPicker = document.getElementById("color-picker");

    // --- State Variables (Our plugin's 'memory') ---
    let currentFrontValue = "cold";
    let currentFrontName = "Cold Front";
    let currentSymbolSize = 10;
    let currentSymbolSpacing = 25;
    let currentFrontColor = "#FF0000";

    // --- Functions ---

    const getFrontName = (value) => {
        switch(value) {
            case "cold": return "Cold Front";
            case "warm": return "Warm Front";
            case "stationary": return "Stationary Front";
            case "occluded": return "Occluded Front";
            default: return "Unknown Front";
        }
    };

    const updateSelection = (event) => {
        frontTypeButtons.forEach(btn => btn.classList.remove('active'));
        const clickedButton = event.currentTarget;
        clickedButton.classList.add('active');
        currentFrontValue = clickedButton.getAttribute('value');
        currentFrontName = getFrontName(currentFrontValue);
        if (statusEl) {
            statusEl.textContent = currentFrontName;
            console.log(`Selection updated to: ${currentFrontName}`);
        }
    };

    const drawTriangle = async (p1, angle, symbolSize, layer) => {
        const trianglePoints = [
            { x: 0, y: -symbolSize },
            { x: -symbolSize / 2, y: 0 },
            { x: symbolSize / 2, y: 0 }
        ];
        const transformedTriangle = trianglePoints.map(p => {
            const rotatedX = p.x * Math.cos(angle) - p.y * Math.sin(angle);
            const rotatedY = p.x * Math.sin(angle) + p.y * Math.cos(angle);
            return { x: rotatedX + p1.x, y: rotatedY + p1.y };
        });

        const doc = app.activeDocument;
        
        const pathPoints = transformedTriangle.map(p => {
            const point = [Math.round(p.x), Math.round(p.y)];
            const pathPoint = new PathPointInfo();
            pathPoint.anchor = point;
            pathPoint.leftDirection = point;
            pathPoint.rightDirection = point;
            pathPoint.kind = "cornerPoint";
            return pathPoint;
        });

        const subPathInfo = new SubPathInfo();
        subPathInfo.closed = true;
        subPathInfo.entireSubPath = pathPoints;

        const tempPath = await doc.pathItems.add("tempTriangle", [subPathInfo]);
        
        doc.activeLayers = [layer];

        await tempPath.select();
        await doc.selection.fill(app.foregroundColor);
        await doc.selection.deselect();
        await tempPath.remove();
    };

    const drawSemicircle = async (p1, angle, symbolSize, flipped, layer) => {
        const numPoints = 16;
        const semicirclePoints = [];
        for (let i = 0; i <= numPoints; i++) {
            const pointAngle = (i / numPoints) * Math.PI;
            const x = Math.cos(pointAngle) * symbolSize;
            const y = Math.sin(pointAngle) * symbolSize * (flipped ? -1 : 1);
            semicirclePoints.push({ x, y });
        }

        const transformedSemicircle = semicirclePoints.map(p => {
            const rotatedX = p.x * Math.cos(angle) - p.y * Math.sin(angle);
            const rotatedY = p.x * Math.sin(angle) + p.y * Math.cos(angle);
            return { x: rotatedX + p1.x, y: rotatedY + p1.y };
        });

        const doc = app.activeDocument;

        const pathPoints = transformedSemicircle.map(p => {
            const point = [Math.round(p.x), Math.round(p.y)];
            const pathPoint = new PathPointInfo();
            pathPoint.anchor = point;
            pathPoint.leftDirection = point;
            pathPoint.rightDirection = point;
            pathPoint.kind = "cornerPoint";
            return pathPoint;
        });

        const subPathInfo = new SubPathInfo();
        subPathInfo.closed = true;
        subPathInfo.entireSubPath = pathPoints;

        const tempPath = await doc.pathItems.add("tempSemicircle", [subPathInfo]);

        doc.activeLayers = [layer];

        await tempPath.select();
        await doc.selection.fill(app.foregroundColor);
        await doc.selection.deselect();
        await tempPath.remove();
    };

    const drawFront = async (coordinates, frontType, symbolSize, symbolSpacing, color) => {
        const hexToRgb = (hex) => {
            const result = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
            return result ? { r: parseInt(result[1], 16), g: parseInt(result[2], 16), b: parseInt(result[3], 16) } : null;
        };
        const rgbColor = hexToRgb(color);
        if (!rgbColor) {
            await core.showAlert({ title: "Error", message: "Invalid color format." });
            return;
        }

        try {
            await core.executeAsModal(async () => {
                const document = app.activeDocument;
                const newLayer = await document.createLayer({ name: "Weather Front", opacity: 100, blendMode: "normal" });
                if (!newLayer) {
                    throw new Error("Failed to create the 'Weather Front' layer.");
                }

                await action.batchPlay([{ _obj: "set", _target: [{ _ref: "foregroundColor" }], to: { _obj: "RGBColor", red: rgbColor.r, green: rgbColor.g, blue: rgbColor.b } }], {});

                let totalDistanceTraveled = 0;
                let distanceToNextSymbol = symbolSpacing;
                let symbolCounter = 0;

                for (let i = 0; i < coordinates.length - 1; i++) {
                    const p1 = coordinates[i];
                    const p2 = coordinates[i + 1];
                    const segmentDx = p2.x - p1.x;
                    const segmentDy = p2.y - p1.y;
                    const segmentLength = Math.sqrt(segmentDx * segmentDx + segmentDy * segmentDy);

                    if (segmentLength === 0) continue;

                    const angle = Math.atan2(segmentDy, segmentDx);

                    while (distanceToNextSymbol <= totalDistanceTraveled + segmentLength) {
                        const distanceFromP1 = distanceToNextSymbol - totalDistanceTraveled;
                        const pointOnSegment = { x: p1.x + distanceFromP1 * Math.cos(angle), y: p1.y + distanceFromP1 * Math.sin(angle) };
                        
                        if (frontType === 'cold') await drawTriangle(pointOnSegment, angle, symbolSize, newLayer);
                        else if (frontType === 'warm') await drawSemicircle(pointOnSegment, angle, symbolSize, false, newLayer);
                        else if (frontType === 'stationary') {
                            if (symbolCounter % 2 === 0) await drawTriangle(pointOnSegment, angle, symbolSize, newLayer);
                            else await drawSemicircle(pointOnSegment, angle, symbolSize, true, newLayer);
                        } else if (frontType === 'occluded') {
                            await drawTriangle(pointOnSegment, angle, symbolSize, newLayer);
                            await drawSemicircle(pointOnSegment, angle, symbolSize, false, newLayer);
                        }

                        symbolCounter++;
                        distanceToNextSymbol += symbolSpacing;
                    }
                    totalDistanceTraveled += segmentLength;
                }
                
                await action.batchPlay([{ _obj: "selectNoPath" }], {});

            }, { commandName: "Draw Weather Front" });

            await core.showAlert({ title: "Success", message: "Drawing complete." });
        } catch (e) {
            console.error(e);
            await core.showAlert({ title: "Error", message: "An unexpected error occurred. See console for details." });
        }
    };

    const onGetPathAndDrawClick = async () => {
        try {
            const activePathResult = await action.batchPlay([{ _obj: "get", _target: [{ _ref: "path", _enum: "ordinal", _value: "targetEnum" }] }], {});
            if (!activePathResult || !activePathResult[0] || !activePathResult[0].pathContents) {
                 await core.showAlert({ title: "Error", message: "Could not get active path. Please make sure you have a path selected."});
                 return;
            }
            const pathData = activePathResult[0].pathContents;
            if (!pathData.pathComponents || !pathData.pathComponents[0].subpathListKey || !pathData.pathComponents[0].subpathListKey[0].points || pathData.pathComponents[0].subpathListKey[0].points.length < 2) {
                await core.showAlert({ title: "Error", message: "The selected path is invalid or has too few points."});
                return;
            }
            const points = pathData.pathComponents[0].subpathListKey[0].points;
            const coordinates = points.map(p => ({ x: p.anchor.horizontal._value, y: p.anchor.vertical._value }));
            await drawFront(coordinates, currentFrontValue, currentSymbolSize, currentSymbolSpacing, currentFrontColor);
        } catch (e) {
            console.error(e);
            await core.showAlert({ title: "Error", message: e.message });
        }
    };

    // --- Attach All Event Listeners ---
    frontTypeButtons.forEach(button => button.addEventListener("click", updateSelection));
    sizeSlider.addEventListener("input", (event) => { currentSymbolSize = parseFloat(event.target.value); console.log("Size changed:", currentSymbolSize); });
    spacingSlider.addEventListener("input", (event) => { currentSymbolSpacing = parseFloat(event.target.value); console.log("Spacing changed:", currentSymbolSpacing); });
    colorPicker.addEventListener("input", (event) => { currentFrontColor = event.target.color.toHex(true); console.log("Color changed:", currentFrontColor); });
    drawButton.addEventListener("click", onGetPathAndDrawClick);
    
    // --- Set Initial State on Load ---
    if (statusEl) {
        statusEl.textContent = currentFrontName;
    }
});