Edit in GitHubLog an issue

Creating a Document Stats add-on with the Adobe Express Communication API

In this tutorial, we'll build an Adobe Express add-on that gathers statistics on the active document using the Communication API.

Introduction

Hello, and welcome to this Adobe Express Communication API tutorial, where we'll build together a fully functional Stats add-on from scratch. This add-on will retrieve metadata from the currently open Adobe Express document, such as pages and their size, plus information about the kind and number of any element used.

stats addon

Timestamp

This tutorial has been written by Davide Barranca, software developer and author from Italy. It's been first published on December 14th, 2023.

Prerequisites

  • Familiarity with HTML, CSS, JavaScript.
  • Familiarity with the Adobe Express add-ons environment; if you need a refresher, follow the quickstart guide.
  • Familiarity with the Adobe Express Document API, covered in this tutorial.
  • An Adobe Express account; use your existing Adobe ID or create one for free.
  • Node.js version 16 or newer.

Topics Covered

Getting Started with the Communication API

As we've seen in the previous Adobe Express Document API tutorial, add-ons belong to the UI iframe: a sandboxed environment subject to CORS policies, where the User Interface (UI) and the add-on logic are built. The iframe itself has limited editing capabilities, though: via the addOnUISdk module, it can invoke a few methods to import media (image, video, and audio) and export the document into a number of formats, like .pdf, .mp4 or .jpg for example.

The Document API makes new, more powerful capabilities available, allowing the add-on to manipulate elements directly—like scripting in Desktop applications such as Photoshop or InDesign. This API is one component of the Document Sandbox, a JavaScript execution environment that also includes a restricted set of Web API (mostly debugging aids) as well as the means for the UI iframe and the Document API to exchange messages—the Communication API. This infrastructure is paramount as it bridges the gap between the two environments, allowing them to create a seamless experience.

Proxies

How does this all work, then? The process involves exposing proxies for the other context to use, serving as a user-friendly abstraction; under the hood, the implementation relies on a messaging system, which is hidden to us developers.

Copied to your clipboard
// runtime in the UI iframe
import addOnUISdk from "https://new.express.adobe.com/static/add-on-sdk/sdk.js";
const { runtime } = addOnUISdk.instance;
// runtime in the Document Sandbox
import addOnSandboxSdk from "add-on-sdk-document-sandbox";
const { runtime } = addOnSandboxSdk.instance;

The runtime object uses the exposeApi()to make content available to the other context—it works the same, regardless of whether the subject is the iframe or the Document Sandbox.

Copied to your clipboard
// 👇 both in the UI frame and the Document Sandbox
runtime.exposeApi({ /* ... */ }); // exposing a payload {}

We'll get to the details of such a payload in a short while; for the moment, think about it as a collection of methods acting on their environment (UI iframe or Document Sandbox). There needs to be more than exposing, though: some action is required on the other side to surface such a payload—it involves using the apiProxy() method documented here.

Copied to your clipboard
// UI iframe, importing a payload from the Document Sandbox
const sandboxProxy = await runtime.apiProxy("documentSandbox");
// Document Sandbox, importing a payload from the UI iframe
const panelUIProxy = await runtime.apiProxy("panel");

At this point, sandboxProxy and panelUIProxy represent their counterparts from the original contexts. It all may be easier to understand when the entire process is written down; for example, in the following code, we expose a custom method called ready() defined in the UI iframe to the Document API.

Copied to your clipboard
import addOnUISdk from "https://new.express.adobe.com/static/add-on-sdk/sdk.js";
const { runtime } = addOnUISdk.instance;
// exporting a payload to the Document Sandbox
runtime.exposeApi({
ready: (env) => {
console.log(`The ${env} environment is ready`);
}
});

As the name implies, the panelUIProxy constant in the Document Sandbox is a proxy for the object exposed by the iframe's runtime. The other way around works the same: exposing a Document API method to the iframe.

Copied to your clipboard
import addOnUISdk from "https://new.express.adobe.com/static/add-on-sdk/sdk.js";
const { runtime } = addOnUISdk.instance;
// importing from the Document Sandbox
const sandboxProxy = await runtime.apiProxy("documentSandbox");
// We can call this method now
await sandboxProxy.drawRect();

The following diagram helps visualize the process.

Proxy objects

Proxy API

Now that we've seen how contexts expose and import each other's API, let's discuss what's inside the payload objects that cross this environment boundary.

Functions

Most of the time, you're going to expose functions.

Copied to your clipboard
// Document Sandbox
runtime.exposeApi({
drawRect: () => { /* ... */ }, // 👈
drawEllipse: () => { /* ... */ }, // 👈
drawShape: () => { /* ... */ }, // 👈
// etc.
});

In the above example, if drawShape() needs to call either drawEllipse() or drawRect(), you may use the method shorthand syntax.

Copied to your clipboard
// Document Sandbox
runtime.exposeApi({
drawRect() { /* ... */ }, // 👈
drawEllipse() { /* ... */ }, // 👈
drawShape() {
this.drawRect(); // 👆
// or
this.drawEllipse(); // 👆
},
// etc.
});

When not strictly necessary, keep functions private and expose only the ones needed.

Copied to your clipboard
// Document Sandbox
const drawRect = () => { /* ... */ }; // 👈 private
const drawEllipse = () => { /* ... */ }; // 👈 private
runtime.exposeApi({
drawShape() {
drawRect(); // 👆
// or
drawEllipse(); // 👆
}
});

Here, drawRect() and drawEllipse() exist within the closure of the drawShape() function exposed to the iframe and will work just fine when the iframe invokes it. This notion of "private" variables defined in one context can be exploited in various ways, for instance, with a counter as follows.

Copied to your clipboard
const sandboxProxy = await runtime.apiProxy("documentSandbox");
// Calling the exposed method
const shapesNo = await sandboxProxy.drawShape();
console.log("Shapes drawn", shapesNo);

The counter variable in the Document Sandbox is maintained between iframe calls within the same user session. It remains isolated to the specific document and cannot be shared with other open documents or users. However, it can be accessed by the drawShape() function. In this context, it's returned to provide the iframe with the count of shapes created up to that point. This introduces us to the notion of returned values.

Generally speaking, you should restrict your returns to the following types.

  • ✅ Primitive values.
  • ✅ Object literals.
  • ✅ JSON strings.
  • ✅ ArrayBuffers and Blobs.
  • ✅ Promises.

The following returns won't work as expected and must be avoided.

  • ❌ Classes (constructor functions).
  • ❌ Class instances (will get serialized and stripped away from any method).
  • ❌ Functions.
  • ❌ Proxies.
  • ❌ Constructs such as Maps and Sets won't get through either, nor will Date and RegExp.
  • new Error(), either thrown or returned, won't provide any error message (at the moment) when caught using a try/catch block.

Primitives

Nothing prevents you from using something else besides functions in your proxy. For instance, you can refactor the drawShape() example by exposing a counter property alongside its setter and getter.

Copied to your clipboard
// iframe
const sandboxProxy = await runtime.apiProxy("documentSandbox");
for (let i = 0; i < 10; i++) {
await sandboxProxy.drawShape();
}
sandboxProxy.counter = 0; // resetting the counter
await sandboxProxy.drawShape();

The counter setter has the ability to perform additional actions, such as manipulating the document, in addition to assigning a new value to the private variable _counter.

Please note that, given the async nature of the Communication API, retrieving the counter value in the iframe requires the use of await or a then() callback.

Copied to your clipboard
let counter = await sandboxProxy.counter;
// or
sandboxProxy.counter.then((counter) => { /* ... */ });

Asynchronous communication

The Communication API wraps all inter-context function invocations with a Promise. In other words, regardless of the nature of the function called, when it gets from one context to the other (from the iframe to the Document Sandbox or vice-versa), it must be dealt with as if it were asynchronous.

Copied to your clipboard
// Document Sandbox
runtime.exposeApi({
drawShape() { /* ... */ } // 👈 Document API methods are typically *synchronous*
});
// iframe
const res = sandboxProxy.drawShape(); // 👀 no await, returns a Promise
❌ console.log(res instanceof Promise); // true
const res = await sandboxProxy.drawShape(); // using await
✅ console.log(typeof res); // number (it returns the counter, as you remember)

Coding the Stats add-on

You are now equipped with all the theory and reference snippets needed to start building the Stats add-on: it's a simple project implementing the Communication API in a couple of different ways. Feature-wise, it's split into two parts.

  1. The add-on shows two status lights that indicate whether the SDKs are ready for use.
  2. At the press of a button, the add-on (via Document API) collects the document's metadata0, which is used to compile and show a table of Nodes (elements).

stats addon vscode

Like in the Grids add-on tutorial, the starting point will be this template, which provides a Webpack-managed JavaScript—hence, able to easily import Spectrum Web Components to build the User Interface. Everything's in the src folder:

  • index.html is where the iframe's UI is built.
  • ui/index.js deals with the add-on's internal logic.
  • ui/table-utils.js contains table-related functions consumed by the iframe and imported by index.js to keep it slim.
  • documentSandbox/code.js contains the Document API methods exposed to the iframe.
  • documentSandbox/utils.js stores Document API private functions imported in code.js.

User Interface

To keep a consistent look & feel with the rest of the application, we'll use Spectrum Web Component as much as possible, styling them with the express theme provided by the <sp-theme> wrapper.

stats addon doc

For the "Framework Status", the Status Light component is spot-on: it comes in many variants, which set different light colors—positive and negative (green 🟢 and red 🔴) will be enough for us. The "Document Statistics" section is going to show a list of key/value pairs (the element type and the number of items found on each page). The most obvious choice is a Table component, which in SWC is constructed with several tags.

  • <sp-table> is the outer wrapper.
  • <sp-table-head> and <sp-table-head-cell> set the header.
  • <sp-table-body> is for the body, which contains <sp-table-row> and <sp-table-cell> elements.

We'll start with a placeholder row with a friendly message, which is going to be removed at the first launch. A regular <sp-button> is placed at the bottom to initiate the metadata-collecting process.

Copied to your clipboard
<body>
<sp-theme scale="medium" color="light" theme="express">
<h3>Framework status</h3>
<hr>
<div class="row">
<sp-status-light size="m" variant="negative" id="iframe-status">
iFrame API
</sp-status-light>
<sp-status-light size="m" variant="negative" id="document-status">
Authoring API
</sp-status-light>
</div>
<h3>Document statistics</h3>
<hr>
<div class="row">
<sp-table size="m">
<sp-table-head>
<sp-table-head-cell>Element</sp-table-head-cell>
<sp-table-head-cell>Count</sp-table-head-cell>
</sp-table-head>
<sp-table-body id="stats-table-body">
<sp-table-row id="row-placeholder">
<sp-table-cell>Get the Document stats first...</sp-table-cell>
</sp-table-row>
</sp-table-body>
</sp-table>
</div>
<div class="row align-right">
<sp-button id="stats">Analyze document</sp-button>
</div>
</sp-theme>
</body>

Logic

The diagram below illustrates the communication flow between the two contexts.

stats addon flow

The iframe exposes two methods:

  • toggleStatus() will be used independently by the iframe and the Document API to switch the Framework Status lights to green when ready.
  • createTable() expects to be passed the document metadata and deals with the <sp-table> setup.

The Document Sandbox exposes only a getDocumentData() method, which collects the metadata from the open document. Both contexts import each other's API via proxy.

When the iframe has loaded its SDK, it will call toggleStatus(), passing the "iframe" string as a parameter—telling the function which light to turn green. Similarly, when the Document Sandbox is ready, it will reach out for toggleStatus() on its own. Neither process requires the user's intervention.

When the "Analyze Document" button is clicked, the iframe—via the Document Sandbox proxy—invokes getDocumentData(). Instead of returning an object with the metadata to the iframe for further processing (which would be OK), the Document API uses the iframe proxy to run directly createTable() and initiate the table subroutine in an iframe-to-Document-Sandbox-to-iframe roundtrip. Let's have a look at the overall structure in index.js implementing the logic I've just described.

Copied to your clipboard
// SWC imports
import "@spectrum-web-components/styles/typography.css";
import "@spectrum-web-components/theme/src/themes.js";
import "@spectrum-web-components/theme/theme-light.js";
import "@spectrum-web-components/theme/express/theme-light.js";
import "@spectrum-web-components/theme/express/scale-medium.js";
import "@spectrum-web-components/theme/sp-theme.js";
import "@spectrum-web-components/status-light/sp-status-light.js";
import "@spectrum-web-components/table/elements.js";
import "@spectrum-web-components/button/sp-button.js";
import addOnUISdk from "https://new.express.adobe.com/static/add-on-sdk/sdk.js";
// Wait until the addOnUISdk has been loaded
addOnUISdk.ready.then(async () => {
// API to expose to the Document Sandbox
const iframeApi = {
createTable(documentData) { /* ... */ },
toggleStatus(sdk) { /* ... */ }
};
runtime.exposeApi(iframeApi);
// Toggle the iframe SDK status light
iframeApi.toggleStatus("iframe");
// Import the Document Sandbox API proxy
const { runtime } = addOnUISdk.instance;
const sandboxProxy = await runtime.apiProxy("documentSandbox");
// Exposing the iFrame API to the Document Sandbox.
// Set the button's click handler
const statsButton = document.getElementById("stats");
statsButton.addEventListener("click", async () => {
// Invoke the Document API via Document Sandbox proxy
// to get the document metadata and initiate the table creation process
await sandboxProxy.getDocumentData(); // 👈 mind the await
});
// Enable the button only when the addOnUISdk is ready
statsButton.disabled = false;
});

Besides the usual SWC imports, everything must be wrapped by a callback invoked when the addOnUISdk module is ready—which also means we can switch the first SDK status light to green. Please note that toggleStatus() must be available to both the iframe and the Document Sandbox: declaring the iframeApi constant first (lines 18-21) and passing it to exposeApi() later allows me to call it (line 21).

Copied to your clipboard
// ❌ _in this case_ it's the wrong syntax choice!
runtime.exposeApi({
createTable(documentData) { /* ... */ },
toggleStatus(sdk) { /* ... */ }
});
// ✅ better for our needs _in this context_
const iframeApi = {
createTable(documentData) { /* ... */ },
toggleStatus(sdk) { /* ... */ }
});
runtime.exposeApi(iframeApi);
// toggleStatus is accessible in the iframe too
iframeApi.toggleStatus("iframe"); // 👈

In documentSandbox/code.js, we bring the iframe proxy in, toggle the status light, and expose the getDocumentData() function. Please note that it must be declared asynchronous (line 7) because of the need to await when invoking the panelUIProxy method createTable() (line 12).

Copied to your clipboard
import addOnSandboxSdk from "add-on-sdk-document-sandbox";
const { runtime } = addOnSandboxSdk.instance;
async function start() {
const panelUIProxy = await runtime.apiProxy("panel");
runtime.exposeApi({
async getDocumentData() { // 👈 async keyword, because 👇
// Get the document's metadata
let documentData;
// ... TODO
// ... then, directly invoke the iframe method
await panelUIProxy.createTable(documentData); // 👈 the Communication API is async 👆
}
});
// switch the Framework Status light to green
panelUIProxy.toggleStatus("document");
}
start();

Implementation

Let's start filling in the missing parts in our code; we'll begin with the Framework Status, the easiest bit. The toggleStatus() method is immediately invoked in the addOnUISdk.ready callback, as well as the Document Sandbox code.js, where it is also exposed. It updates the variant attribute of the <sp-status-light> element based on the sdk parameter passed in.

Copied to your clipboard
const iframeApi = {
toggleStatus(sdk) {
// sdk parameter validation
if (["document", "iframe"].indexOf(sdk) === -1) {
throw new Error("Invalid SDK type");
}
const el =
sdk === "document"
? document.getElementById("document-status")
: document.getElementById("iframe-status");
el.setAttribute("variant", "positive"); // 🟢
},
// ...
}
iframeApi.toggleStatus("iframe"); // 👈

When refreshing the add-on, the UI updates almost instantly, but a frame-by-frame analysis would reveal a progression in the rendering process: first, the HTML is loaded, then the CSS with the <sp-status-light> components in their original, "red" variant; finally, when the two SDK are fully loaded, we get the green lights.1

Status lights

Let's tackle the metadata collection in the Document Sandbox, especially the data structure we want to create2. There are many ways to go about this business: I've decided to keep track of elements on a Page basis and store page dimensions, too. Eventually, the iframe will receive documentData, an array of objects, one for each page, with dimensions and nodes properties. If you've got a taste for TypeScript, the type definition would be as follows.

Copied to your clipboard
type DocumentData = Array<{
dimensions: { width: number; height: number; };
nodes: { [key: string]: number; };
}>;

The above would transpose into something along these lines.

Copied to your clipboard
[
{ // page 1
"dimensions": { "width": 1200, "height": 1200 },
"nodes": {
"ab:Artboard": 1, "MediaContainer": 1, "Text": 2, "ComplexShape": 3
}
},
{ // page 2
"dimensions": { "width": 800, "height": 400 },
"nodes": {
"ab:Artboard": 1, "MediaContainer": 4, "Rectangle": 2
}
},
// ...
]

The various "ab:Artboard", "MediaContainer" and others, are the Node type strings as Adobe Express exposes them. Let's create the getDocumentData() function that outputs such a structure.

stats addon type

Copied to your clipboard
import { getNodeData } from "./utils";
runtime.exposeApi({
async getDocumentData() {
const doc = editor.documentRoot; // get the document
let documentData = []; // initialize the array to return
for (const page of doc.pages) { // loop through each page
let pageData = {}; // create an empty object
pageData.dimensions = { // get and store the page `dimensions`
width: page.width,
height: page.height,
};
pageData.nodes = getNodeData(page); // 👈 build the `nodes` object (more on this later)
documentData.push(pageData); // push the object to the documentData array
}
// invoke the iframe method to create the table on the UI
await panelUIProxy.createTable(documentData);
},
});

The code comments will guide you through the process of getting the document, loop through pages extracting dimensions, and retrieving nodes metadata, but up to a point. What's getNodeData? As I mentioned before, I've split the Document API code into two parts: the main Document Sandbox entrypoint (code.js, where methods are exposed to and imported from the iframe) and table-utils.js, which is kept private to the context and exports just what code.js needs—the getNodeData() method, which makes use of increaseCount().

Copied to your clipboard
const increaseCount = (obj, type) => {
// If the type is already in the object, increase its count, otherwise set it to 1
if (obj.hasOwnProperty(type)) {
obj[type] += 1;
} else {
obj[type] = 1;
}
};
const getNodeData = (node, nodeData = {}) => {
if (node.type === "MediaContainer") {
return nodeData;
}
// Check if the current node has children and if they are not an empty array
if (node.allChildren && node.allChildren.length > 0) {
// Iterate over all children using for..of
for (const child of node.allChildren) {
// Increase the count for the current type
increaseCount(nodeData, child.type);
// Recursively call getNodeData for each child that has its own children
// ... unless it's a MediaContainer
if (
child.type !== "MediaContainer" &&
child.allChildren &&
child.allChildren.length > 0
) {
getNodeData(child, nodeData);
}
}
}
return nodeData;
};
export { getNodeData };

Given the nature of Adobe Express documents (which will be covered in detail in a future tutorial), it makes sense to build getNodeData() as a recursive function: a PageNode can contain multiple ArtboardNode elements, which in turn can contain multiple GroupNode elements, and so on. As follows, the metacode.

  1. The getNodeData() method begins its execution when called by getDocumentData(), taking a single parameter named page. At the start, nodeData is initialized as an empty object. The method then checks if the current node has the allChildren property, which should be a non-empty iterable (please note: it's not an Array, but can be transformed into one if needed via Array.from()). If so, it goes through it. During each iteration, it increments the count for the type property of each child node (such as "Text", "Group", etc.).

  2. If a child node within this array also features a non-empty allChildren property, getNodeData() is called recursively on that child node. Mind you: during these recursive calls, nodeData is passed as the second argument. This approach ensures that the same nodeData object is continuously used throughout the recursion, allowing it to accumulate and keep tabs on all node types encountered across all hierarchy levels. The "MediaContainer" node is a particular one, 3 and when encountered, we stop there.

  3. The increaseCount() method, which we keep private to the utils.js module, receives the nodeData object and bumps the count for each child.type.

When the whole process is repeated for each page, we can finally invoke the iframe createTable() method, passing the documentData.

Copied to your clipboard
runtime.exposeApi({
async getDocumentData() {
// ... create and fill the `documentData` array
await panelUIProxy.createTable(documentData); // 👈 calling this iframe proxy method
},
});

Now, it's up to the iframe to manage such data (the array of objects collecting page dimensions and node counts) and transform it into a Spectrum Table.

Copied to your clipboard
import { rebuildTable } from "./table-utils";
const iframeApi = {
toggleStatus(sdk) { /* ... */ },
createTable(documentData) { // 👈
const table = document.getElementById("stats-table-body");
rebuildTable(table, documentData);
},
};

The process is not difficult per se, but it may be slightly tedious. The rebuildTable() method is declared in the table-utils.js module alongside a private addRowToTable().

Copied to your clipboard
// documentSandbox/table-utils.js
const addRowToTable = (table, rowData) => {
// Create a new row
const newRow = document.createElement("sp-table-row");
// For each cell data, create a cell and append it to the row
let cell;
rowData.forEach((cellData) => {
cell = document.createElement("sp-table-cell");
cell.textContent = cellData;
newRow.appendChild(cell);
});
if (rowData.length === 1) {
// Making the page row bold
cell.className = "page-row";
}
// Append the row to the table body
table.appendChild(newRow);
};
const rebuildTable = (table, documentData) => {
// Removing all existing rows from the table,
// either the placeholder or the previous results
while (table.firstChild) {
table.removeChild(table.firstChild);
}
documentData.forEach((pageData, index) => {
addRowToTable(table, [
`Page ${index + 1} (${pageData.dimensions.width} x ${
pageData.dimensions.height
})`,
]);
// the for...in loop iterates over the keys of an object
for (const node in pageData.nodes) {
// for example, ["Text", 1]
addRowToTable(table, [node, pageData.nodes[node]]);
}
});
};
export { rebuildTable };

As follows, the rebuildTable() metacode.

  • Initialize the existing table, which is the first argument it receives.
  • Loop through each page in the documentData array, adding a row with the dimensions via addRowToTable(); please note that, although the Table is supposed to have two columns, we can set a row with only one cell, which will span both of them.
  • Loop through each node in the pageData.nodes object, adding a row with the node type and the number of instances found on that page.
  • addRowToTable() is a utility function that takes a table and an array of strings as arguments. It creates a new <sp-table-row>, then loops through the array, creating a <sp-table-cell> for each string and appending it to the row. If the array contains only one element, the cell is given a page-row class, which makes it bold.

stats addon stats

Next Steps

Congratulations! You've coded from scratch the Stats add-on for Adobe Express. As an exercise, you may want to extend it with the following features.

  • Better visualization: you can add <sp-icon> elements for each Node type, or bypass the Table altogether using an Accordion component for a hierarchical, collapsible menu.
  • Hide and Show: via the Document API you may hide and show elements based on their type—the <sp-table> has a selects and a selected attributes that you can put to use.
  • Save Snapshots: using the Client Storage API, you can keep track of the document's metadata and compare it with previous versions.

Lessons Learned

Let's review the concepts covered in this tutorial and how they've been implemented in the Stats add-on.

  • Communication API: the iframe and the Document Sandbox can expose and import each other's API via proxy objects. The Communication API wraps all inter-context function invocations with a Promise; hence, the need to await when invoking a proxy method.
  • Proxy API: the payload objects exchanged between the two contexts can contain functions, which are the most common use case: they can return primitives, object literals, JSON strings, ArrayBuffers, and Blobs.
  • Context Closures: proxy methods have access to variables defined in their context, even when invoked from the other context. This is a powerful feature that can be exploited to keep private variables and functions out of the Communication API.
  • Roundtrip calls: proxy methods can be chained—the iframe can invoke the Document API via the Document Sandbox proxy, which in turn can invoke the iframe proxy, and so on.

Final Project

The code for this project can be downloaded here. Use this template as a starting point.

Copied to your clipboard
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="description"
content="Adobe Express Add-on template using JavaScript, the Document Sandbox, and Webpack" />
<meta name="keywords" content="Adobe, Express, Add-On, JavaScript, Document Sandbox, Adobe Express Document API" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Stats add-ons</title>
<link rel="stylesheet" href="styles.css">
</head>
<body>
<sp-theme scale="medium" color="light" theme="express">
<h3>Framework status</h3>
<hr>
<div class="row">
<sp-status-light size="m" variant="negative" id="iframe-status">iFrame API</sp-status-light>
<sp-status-light size="m" variant="negative" id="document-status">Authoring API</sp-status-light>
</div>
<h3>Document statistics</h3>
<hr>
<div class="row">
<sp-table size="m">
<sp-table-head>
<sp-table-head-cell>Element</sp-table-head-cell>
<sp-table-head-cell>Count</sp-table-head-cell>
</sp-table-head>
<sp-table-body id="stats-table-body">
<sp-table-row id="row-placeholder">
<sp-table-cell>Get the Document stats first...</sp-table-cell>
</sp-table-row>
</sp-table-body>
</sp-table>
</div>
<div class="row align-right">
<sp-button id="stats">Analyze document</sp-button>
</div>
</sp-theme>
</body>
</html>

  1. When referring to "metadata", I mean the dimensions and types of elements on each page. Custom metadata haven't been implemented in Adobe Express yet.
  2. In my tests, the two SDKs are ready almost instantly and together—I couldn't tell which one is loaded firsts.
  3. Designing data structures in the early stages of the development process is, in my humble opinion, a way to dodge a good deal of future headaches.
  4. A "MediaContainer" (say, an image) has as children both the "ImageRectangle" and the mask that crops it. At this stage of the Document API development, getting complex masks may throw an Error—hence, I've decided to use the "MediaContainer" type as a recursion's termination clause.
  • Privacy
  • Terms of Use
  • Do not sell or share my personal information
  • AdChoices
Copyright © 2024 Adobe. All rights reserved.