PodoLay

PodoLay プラグイン SDK

SDK 1.9.0 · API 1.9 · JavaScript でエディターを拡張

このドキュメントは英語と韓国語のみです。

PodoLay Plugin API Reference (Plugin API 1.9)

A single reference for everything available to a plugin's main.js. If you're new, start with README.md (getting started, development workflow, review criteria). It matches the type definitions in podolay-plugin.d.ts one to one — add /// <reference path="./podolay-plugin.d.ts" /> in your code editor to get autocompletion.

Notation

MarkerMeaning
1.0Since API 1.0 — available to every plugin
1.1Since API 1.1 — only for plugins whose manifest declares "apiVersion": "1.1" (an error under 1.0)
1.2Since API 1.2 — only for plugins whose manifest declares "apiVersion": "1.2"
1.3Since API 1.3 — only for plugins whose manifest declares "apiVersion": "1.3" (editor 2.13 or later)
1.4Since API 1.4 — only for plugins whose manifest declares "apiVersion": "1.4" (editor 2.14.7 or later)
1.5Since API 1.5 — only for plugins whose manifest declares "apiVersion": "1.5" (editor 2.14.12 or later)
1.6Since API 1.6 — only for plugins whose manifest declares "apiVersion": "1.6" (editor 2.14.12 or later)
1.7Since API 1.7 — only for plugins whose manifest declares "apiVersion": "1.7" (editor 2.16.2 or later)
1.8Since API 1.8 — only for plugins whose manifest declares "apiVersion": "1.8" (editor 2.17.2 or later)
1.9Since API 1.9 — only for plugins whose manifest declares "apiVersion": "1.9" (editor 2.18.0 or later)
🔑 document:readRequires that permission in the manifest's permissions (otherwise the call fails with 권한이 없습니다, "permission missing")
🔓No permission required
→ TReturns a Promise<T> — every call that talks to the editor is asynchronous

Contents

  1. Runtime environment
  2. Permissions
  3. Manifest podolay-plugin.json
  4. podolay at a glance
  5. Definitions — graphics · tools · commands
  6. Events — podolay.on
  7. Reading the document — podolay.doc · podolay.text
  8. Selection — podolay.selection
  9. Writing to the document — podolay.edit and tx
  10. Batches — podolay.batch
  11. UI — podolay.ui · podolayUI in panels
  12. Storage and license — podolay.storage · podolay.license
  13. Uploading images — podolay.assets
  14. Package files — podolay.package
  15. Network — fetch · WebSocket · EventSource
  16. Data types
  17. Display list
  18. Limits at a glance
  19. Errors and timeouts
  20. Changes between versions

1. Runtime environment

ItemDetails
Where it runsIn a Web Worker inside a sandboxed iframe, isolated from the editor. It cannot reach the editor's page (DOM), sign-in details or other documents
What's availableThe global podolay, worker APIs (self, setTimeout, OffscreenCanvas, TextEncoder, crypto.getRandomValues, crypto.randomUUID, …), crypto.subtle with digest only (SHA-1/256/384/512, from editor 2.14.14 — the worker isn't a secure context, so the editor computes it), and console.* (goes to the log window)
What's notdocument and window (use panels for UI), XMLHttpRequest, importScripts, Worker (never), fetch (without a network permission)
Calling conventionEvery call that talks to the editor returns a Promise. On failure it rejects with an Error (e.message explains why)
Units · coordinatesAll lengths are in pt (1/72 inch). Object frames use spread coordinates (origin at the spread's top-left). Display lists use object-local coordinates
Page numbersZero-based — the same numbers as spreads[].pages[].index in doc.info()
When it startsWhen the editor opens, it loads the installed, enabled plugins and runs each main.js once. Plugins don't run in observer, guest or reader mode, or in public windows
If it hangsIf a call isn't answered within 8 seconds and the plugin also fails to answer a liveness check, the editor turns the plugin off (§19)

An error thrown at the top level of main.js is a "start failure" and turns the plugin off. Syntax errors are also written to the log window together with main.js:line.

main.js is loaded under the name <plugin id>/<main path> (e.g. com.example.qr/main.js), so the browser developer tools' Sources panel, error stacks and the log window show that name and the real line numbers. You can set breakpoints or pause with debugger; (§19).


2. Permissions

Declare them in the manifest's permissions. They are shown verbatim to the person installing the plugin, so declare only what you use (a review criterion).

PermissionWhat it unlocks
document:readdoc.info · doc.graphics · doc.graphic · doc.raw · doc.styles · doc.text · doc.find · doc.textStatus · doc.inlines · doc.texts · doc.table · assets.list · text.measure
document:writeedit (all tx.*) · batch · assets.upload
selectionselection.get · selection.set · selection.setText · text.selection · the selectionChange and textSelectionChange events
uiui.showPanel (manifest contributes.panels)
storagestorage.get · storage.set
network:<host>fetch, WebSocket and EventSource to that host (§15). privacyUrl is required
Without any permissionDefining graphics, tools and commands, on (except selectionChange and textSelectionChange), doc.getData, ui.toast · ui.closePanel · ui.postMessage · ui.onMessage · ui.progress, package.read · text · json, ui.pickFiles

If a new version asks for more permissions, the editor keeps using the previous version until the user consents again in the portal.


3. Manifest podolay-plugin.json

3.1 Top-level fields

FieldRequiredFormatDescription
id✔Reverse domain com.example.nameLowercase letters, digits and -, at least two dot-separated parts, up to 100 characters. It belongs to the developer who uploads it first
name✔Localized stringUp to 100 characters (server)
descriptionLocalized stringUp to 2000 characters
version✔semver 1.2.0Every version is reviewed anew
apiVersion✔"1.0" – "1.9"The API version you use. Declare "1.1" – "1.9" to use that version's features. The editor won't load a version it doesn't know
minEditorsemverLoad only in this editor version or later
main✔Path inside the packageThe script to run in the worker
iconImage inside the package (svg, png, jpg, webp)Store icon
permissionsArray of strings (up to 30)§2
privacyUrl△https URL (up to 500 characters)Required if you request any network permission
contributesObject§3.2–§3.6 below
helpObjectGuide and samples — §3.8
dataFromArray of plugin ids (up to 10)1.8 Your other plugins whose data this one may read — §3.9

3.2 contributes.graphics — graphic types (up to 50)

FieldRequiredFormatDescription
type✔Local nameThe same name as in graphics.define(type, …)
name✔Localized stringInspector title, etc.
usesFillStroketrueUses the frame's fill and stroke — the Inspector shows fill/stroke rows (the frame fill goes behind the drawing, the stroke in front)
defaultSize[w, h] (0 < pt < 10000)The size when the user just clicks with the tool (100×100 if omitted)
inspectorArray of fields (up to 50)Controls shown in the Inspector when the object is selected
autoSizetrue 1.3The data decides the size — when the editor changes the data it fits the frame with size

3.3 Inspector fields

Changing a control updates that object's data[key], and the editor calls render again (one Undo step).

FieldRequiredFormatDescription
key✔Local nameKey in data
label✔Localized string
type✔number · text · checkbox · select · color
min · max · stepNumberRange and arrow-key step of a number
unit'pt' · '%' · ''Unit shown after a number
options△[{ value: string or number, label: localized string }] (up to 100)Required for select
livefalse 1.3number only — commit once after the control settles (0.3 s) instead of on every arrow/stepper click (one Undo step)
hideInlinetrue 1.3Hidden when the object is selected as an inline object (controls like alignment or size mode that mean nothing inside text)

The value of a color field uses the color format.

3.4 contributes.tools — toolbar buttons (up to 10)

FieldRequiredFormatDescription
id✔Local nameThe same name as in tools.define(id, …) (for custom)
label✔Localized stringTooltip
iconsvg or png inside the packageA puzzle icon if omitted
kind✔createGraphic · customcreateGraphic = drag to create a graphicType object, like the rectangle tool (handled by the editor) · custom = mouse events are sent to the plugin
graphicType△A type from contributes.graphicsRequired for createGraphic

3.5 contributes.menus · contributes.keybindings (up to 50 each)

"menus":       [{ "command": "number", "label": { "en": "Number objects", "ko": "번호 매기기" } }],
"keybindings": [{ "command": "number", "key": "mod+alt+n" }, { "command": "ipa", "key": "ctrl+alt+c" }]
  • Menu items go under the editor header's Plugins (puzzle) menu › plugin name › item. Choosing an item calls commands.define(command, …).
  • 1.2 Context menus — context chooses where the item appears (a single value or an array; "header" if omitted). In a context menu, only the item label appears, below a separator at the bottom.
    "menus": [
      { "command": "makeQr",  "label": "Convert to QR code",    "context": "object", "when": { "kind": ["rectangle", "image"], "single": true } },
      { "command": "regen",   "label": "Redraw",                "context": ["object", "header"], "when": { "type": ["qr"] } },
      { "command": "lookup",  "label": "Look up selected text", "context": "text" },
      { "command": "stamp",   "label": "Stamp here",            "context": "canvas" }
    ]
contextWhere it appearsCommand argument (§5.3)
headerThe Plugins menu in the header (default)None — 1.4 the text argument while text is being edited
objectObject context menu — only when when matches{ context: 'object', ids, spread, point, inline? }
textContext menu while editing text (text frames, 1.4 table cells){ context: 'text', id, selection: { start, end, text } }
canvasContext menu on an empty area{ context: 'canvas', spread, page, point }

when (object menus only): kind = every selected object is one of these kinds (Graphic.kind), type = every one is this plugin's graphic of this type, single: true = only when exactly one object is selected. If omitted, the item appears whenever at least one object is selected. Items never appear in the menus shown while editing a table, a table cell or a footnote. They do appear in the menu of a selected inline object: ids is then a single stand-in object that exists only while it is selected (read and change it with doc.graphic, tx.set, setData and setPluginData, and the inline object changes; it cannot be removed or moved), and inline: { id, index } gives the story (text frame id) and the index used by doc.inlines and tx.removeInline.

  • Shortcut key = [mod+][ctrl+][alt+][shift+]<key>, where <key> is a–z, 0–9 or f1–f12. mod = ⌘ on macOS, Ctrl elsewhere. 1.4 ctrl = ⌃ (Control) on macOS, the same Ctrl as mod elsewhere — for Mac combinations without ⌘ such as ⌃⌥C. ctrl needs apiVersion 1.4 or later (otherwise the manifest is invalid). Letter and digit keys need mod, ctrl or alt (so they don't swallow typing). A shortcut that clashes with one the editor already uses is not bound, and a warning is logged (outside macOS ctrl counts as the same key as mod).
  • 1.4 Plugin shortcuts are checked before the editor's built-in key handling and also while text is being edited (before the text engine) — commands of 1.4 plugins then get the text argument (§5.3). Shortcuts of plugins at 1.3 or lower are not called while text is being edited.

3.6 contributes.panels — floating panel windows (up to 10)

FieldRequiredFormatDescription
id✔Local nameui.showPanel(id)
title✔Localized stringWindow title (plugin name — title)
html✔html inside the package§11.2
width · height120–1200 pxDefault 280×360

3.7 Example

{
  "id": "com.example.qr",
  "name": { "en": "QR Code", "ko": "QR 코드" },
  "version": "1.0.0",
  "apiVersion": "1.1",
  "main": "main.js",
  "permissions": ["document:read", "document:write", "selection"],
  "contributes": {
    "graphics": [{ "type": "qr", "name": "QR", "defaultSize": [80, 80],
                   "inspector": [{ "key": "text", "label": { "en": "Text", "ko": "내용" }, "type": "text" }] }],
    "tools":    [{ "id": "qrTool", "label": "QR", "kind": "createGraphic", "graphicType": "qr", "icon": "icon.svg" }],
    "menus":    [{ "command": "regen", "label": { "en": "Regenerate", "ko": "다시 만들기" } }]
  }
}

3.8 help — guide and samples (manual, sample files, sample layouts)

Ship a guide and samples with your plugin. They are reviewed with the version, and users see them in portal › Gallery › plugin details, Guide and Samples tabs, in Guide & samples on the dashboard plugin card, and in editor plugin menu › Help (for the running version). Only signed-in users can see them — the gallery before sign-in shows the About tab only and nothing can be downloaded. The editor does not read help, so it doesn't depend on apiVersion.

"help": {
  "manual": [
    { "id": "start",   "title": { "en": "Getting started", "ko": "시작하기" },
      "file": { "en": "docs/en/start.md", "ko": "docs/ko/start.md" } },
    { "id": "options", "title": "Options", "file": "docs/options.md" }
  ],
  "samples": [
    { "id": "words", "title": { "en": "Sample word list" }, "description": { "en": "A CSV to try with the import command" },
      "lang": "en", "file": "samples/en/words.csv" },
    { "id": "photos", "title": { "en": "Photos" }, "lang": "en", "file": "samples/en/Photo/" }
  ],
  "layouts": [
    { "id": "0f8fad5b-d9cb-469f-a165-70867728950e", "lang": "en", "title": { "en": "Phonetic word book" } }
  ],
  "defaultLang": "en",
  "homepage": "https://example.com/plugin",
  "supportUrl": "https://example.com/support"
}
FieldFormatDescription
manualArray (up to 20 pages)Guide pages, in table-of-contents order — id (up to 40 lowercase letters, digits and -), title (localized text, 100 chars), file (an .md in docs/, one path or one per language)
samplesArray (100 per language)Downloadable sample files — id, title, description (500 chars), file (a file in samples/, or a folder ending in / — users get it as one zip), lang
layoutsArray (20 per language)Sample layouts — id = the id of a layout in your Shared folder, title · description (default: the layout title), lang
defaultLangLanguage codeThe language shown when there are no samples for the screen language (default en)
homepagehttps address (up to 500 chars)Plugin website — shown as a link only
supportUrlhttps address (up to 500 chars)Support or contact address

Guide — Markdown: headings (# · ## · ###), paragraphs, lists, tables, bold, code, links, and images ![alt](../img/step1.png).

  • Images only from the package (relative to that .md; png, jpg, webp, svg). External image addresses are not drawn — only the alt text shows.
  • Links: https:// opens a new tab, other guide pages such as options.md#… switch to that page, anything else shows as text.
  • HTML is not rendered.

Samples per language — give sample files and layouts a lang (one code or an array: "ko" · ["zh-Hans", "zh-Hant"]) and they come first only on screens in that language. Without lang, every language.

  • If no sample matches the screen language, the samples of defaultLang (default en) are shown. "zh" matches zh-Hans and zh-Hant.
  • Samples in other languages are folded under 'Samples in other languages' — a Korean user can still get the English samples.
  • Limits are counted per language (items without lang count in every language): 100 sample files and 20 layouts (400 and 60 in total).
  • The guide page and Preview guide in the developer console let you switch the viewing language.

Folder samples — a file ending in /, such as "samples/en/Photo/", makes the whole folder (subfolders included) one item. Users get one zip the server makes (Photo.zip) instead of dozens of images one by one. For a per-language bundle, use one folder such as samples/ko/.

Sample files — the samples/ folder takes any file that doesn't run (docx, xlsx, pdf, idml, psd, images, text, csv …). Executables, scripts, HTML, SVG, archives and macro Office files (docm, xlsm …) are refused, and so is any file with an executable header (MZ, ELF, Mach-O, #!) whatever its name. Users always get them as downloads. The plugin itself can read them too, e.g. podolay.package.text('samples/words.csv').

Sample layouts — give users a real layout made with the plugin.

  1. In Portal › Developer › Prepare sample layouts, pick a layout and click Make a shared copy — a read-only copy appears in your Shared folder and the line for help.layouts ({ "id": … }) is shown (copy button). Sharing it from the dashboard card menu › Share (no recipients) does the same. Share copies can't be edited, so it stays fixed as this version's sample.
  2. Put that line (the copy's id) in help.layouts.
  3. When you upload the version, the server checks that the id is a layout in your Shared folder (PLUGIN_SAMPLE_LAYOUT_INVALID).

When a user clicks Import, the layout is copied into the user's Received Files folder as a normal (editable) layout — within the user's layout and storage limits. User fonts don't travel (the user sees other fonts). If you delete the share copy, the sample shows as removed by the developer.

Who can get them — the guide: anyone signed in (also before buying or installing). Sample files and layouts: only people who can use the plugin — free plugins need a paid plan (Starter or higher), paid plugins need a purchase (or an active trial — regardless of plan). You and admins always can.

Size — code, guide and samples together: a 20 MB zip (40 MB unpacked, 500 files). Use Preview guide on the version row in the developer console to check it before review.

3.9 dataFrom — reading your other plugins' data

1.8 — read only what another plugin of yours (say, a free edition → Pro) left in documents and the account. List that plugin's id in the manifest.

"apiVersion": "1.9",
"dataFrom": ["com.example.dbp"]
WhereWhat you get
pluginDataFrom in object descriptions (doc.graphic · doc.graphics · doc.find …){ "com.example.dbp": that plugin's pluginData } — only on objects that plugin marked
plugin.data of plugin graphicsThe data too when the graphic belongs to a dataFrom plugin
doc.find({ tag, from: 'com.example.dbp' })Finds by that plugin's marks
doc.getData('com.example.dbp')That plugin's document data
storage.getFrom('com.example.dbp')🔑 storage That plugin's account storage (null in development mode)
  • When you upload a version the server checks that every dataFrom plugin is one you uploaded (else PLUGIN_DATA_FROM_INVALID). There is no writing — to change things, add your own marks.
  • A from that isn't in dataFrom is an error.

4. podolay at a glance

APIVersionPermissionReturnsWhat it does
podolay.apiVersion1.0🔓'1.7'This editor's API version
podolay.env1.0🔓ObjectPlugin, editor and language info
graphics.define(type, def)1.0🔓—Graphic type — data → drawing
tools.define(id, def)1.0🔓—Mouse handling for custom tools
commands.define(id, fn)1.0🔓—Menu and shortcut commands
on(name, fn)1.0🔓 (selection for selection)Unsubscribe functionEditor events
doc.info()1.0document:read→ DocInfoDocument, spreads, pages, masters
doc.graphics({spread})1.0document:read→ Graphic[]Objects on a spread
doc.graphic(id)1.0document:read→ Graphic | nullA single object
doc.raw()1.0document:read→ anyThe whole document storage format
doc.getData()1.0🔓→ anyThis plugin's document data
doc.styles()1.1document:read→ { para, char }Paragraph and character styles
doc.text(id)1.1document:read→ StoryPara[]The text of a text object or text target
doc.find(query)1.1document:read→ Graphic[]Find template slots
doc.textStatus(id)1.1document:read→ TextStatusText flow and overset
doc.inlines(id)1.2document:read→ InlinePlaced[]Inline objects
doc.texts(q)1.4document:read→ TextPlace[]Every text place in the document
doc.table(target)1.6document:read→ TableInfoRows, columns, merges and header rows of a table
doc.tableStyles()1.7document:read→ { table, cell }Table and cell style names
text.measure(story, width, opts)1.1document:read→ { height, lines }Measure text before placing it
text.selection()1.4selection→ TextSelection | nullThe selection of the text being edited
selection.get()1.0selection→ number[]Selected objects
selection.set(ids)1.0selection→ number[]Select objects
selection.setText(id, start, end?)1.4selection→ trueSelect a text range and open it for editing
edit(name, fn)1.0document:write→ { created, results }Change the document (one Undo step)
batch(name, fn)1.1document:write→ value of fnMany edits as one Undo step
ui.toast(msg, kind)1.0🔓→ voidNotification
ui.progress(o)1.1🔓 (inside batch)→ voidProgress window
ui.showPanel(id)1.0ui→ voidOpen a panel
ui.closePanel(id)1.0🔓→ voidClose a panel
ui.postMessage(panelId, msg)1.0🔓→ voidSend to a panel
ui.onMessage(fn)1.0🔓—Receive from a panel
ui.pickFiles(o)1.6🔓→ PickedFile[]Read files or folders from the user's computer
storage.get() · set(v)1.0storage→ any · → voidPer-account settings
license.status()1.5🔓→ LicenseStatusLicense and trial of a paid plugin
assets.upload(bytes, opts)1.1document:write→ { asset, width, height, sha256?, reused? }Turn an image into a document asset
assets.list(q)1.6document:read→ AssetInfo[]The document's image assets
package.read(path) · text · json1.4🔓→ ArrayBuffer · → string · → anyRead a file in the package

The tx operations are listed in the table in §9.2.

4.1 podolay.apiVersion · podolay.env

podolay.apiVersion   // '1.7' — this editor's API version
podolay.env          // { pluginId, pluginVersion, apiVersion, editorVersion, locale, permissions, dev }
env keyTypeDescription
pluginIdstringManifest id
pluginVersionstringManifest version
apiVersionstringThe editor's API version
editorVersionstringEditor version (2.11.0, etc.)
localestringEditor UI language — ko en es de fr ja zh-Hans zh-Hant. Not the same as the document language
permissionsstring[]Granted permissions (including network:…)
devbooleanWhether the plugin was loaded in development mode (pluginDev)

env is filled in before main.js starts, so you can read it from top-level code too.


5. Definitions — graphics · tools · commands

Make definitions once, at the top level of main.js. Calling again with the same name overwrites the earlier definition.

5.1 graphics.define(type, def)

1.0 🔓 — A plugin graphic type. It can only be used if the manifest's contributes.graphics has the same type.

podolay.graphics.define('grid', {
  defaults: { rows: 10, cols: 20, color: '#c00' },          // a value or an (async) function
  render(data, { width, height }) {                          // pure function — same input → same drawing
    const items = [];
    for (let r = 0; r <= data.rows; r++) {
      const y = height * r / data.rows;
      items.push({ t: 'line', x1: 0, y1: y, x2: width, y2: y, stroke: { color: data.color, width: 0.5 } });
    }
    return items;                                            // or { items }
  },
});
def keyTypeDescription
defaultsvalue · () => value · async () => valueThe data for an object newly created with the tool
render(data, { width, height }) => Drawing | Promise<Drawing>Returns a display list
size(data, frame) => { width, height, x?, y? } | null 1.3Natural size — called only with autoSize: true in the manifest

When render is called: when the object is created with the tool, on tx.create({ kind: 'plugin' }) and tx.setData, when an Inspector field changes, after the object is resized (0.25 s after resizing stops), when Undo/Redo changes its size, and when tx.set(id, { frame }) changes its size (with the current data if the same edit has no setData, in the same Undo step). width/height are always the size after that edit — changing frame and setData in one edit renders once at the new size, whatever the op order.

  • The drawing is saved in the document — people without the plugin, reader mode, PDF and IDML all show and print that drawing. So render must be fast (tens of ms) and pure.
  • If the object is resized and the plugin isn't available, the saved drawing is stretched.
  • 1.3 Natural size — for graphics whose data decides their size (like a formula), declare autoSize: true in the manifest and define size(data, frame). Whenever the editor changes the data (an Inspector field, tx.setData, creating with the tool, tx.setInline), it calls size first, fits the frame to the result, renders at that size, and changes frame and data in one Undo step. Without x/y the top-left corner stays put (compute them from frame to keep the centre). null = keep the size. If the same edit sets the size itself with tx.set(id, { frame }), size isn't called. For an inline object the text reflows to the new size. It isn't called when the user resizes with the handles (the object renders at the user's size). Sides are capped at 20000 pt and the call must answer within 8 s (otherwise, or on a malformed answer, the size stays and only a log entry is written).
  • If render doesn't answer within 8 seconds or the drawing exceeds the limits, that call fails and the previous drawing stays.

5.2 tools.define(id, def)

1.0 🔓 — Mouse handling for kind: 'custom' tools. (createGraphic tools are handled by the editor and need no definition.)

podolay.tools.define('lasso', {
  onDown(e) { pts = [[e.x, e.y]]; },
  onMove(e) { pts.push([e.x, e.y]); return [{ t: 'path', d: toPath(pts), stroke: { color: '#09f', width: 1 } }]; },  // preview
  async onUp(e) { await podolay.edit('Lasso', tx => tx.create({ kind: 'rectangle', frame: bbox(pts) })); },
});
Event keyTypeDescription
x · ynumberSpread coordinates, pt
shiftboolean
altbooleanOption/Alt
modboolean⌘ or Ctrl
buttonnumber0 = left
  • If onDown or onMove returns a display list (in spread coordinates), the editor draws it as a preview. Returning null clears the preview.
  • While the editor waits for an answer, only the latest move is sent — a slow plugin never makes the editor lag.
  • When the gesture ends (after onUp), the preview is cleared. Call edit to leave something in the document.

5.3 commands.define(id, fn)

1.0 🔓 — A command invoked by a menu item or shortcut. fn may be async.

podolay.commands.define('regen', async () => { … });
podolay.commands.define('makeQr', async (arg) => {          // 1.2 context menu — arg holds where it was invoked
  if (arg?.context === 'object') for (const id of arg.ids) …;
});

1.2 When invoked from a context menu (menus[].context), fn(arg) receives where it was invoked. From the header menu or a shortcut there is no argument (undefined). 1.4 Commands called from the header menu or a shortcut also get the same text argument as the text context menu while a text frame or a document table cell is being edited (1.4 plugins only — 1.3 and lower still get no argument).

arg.contextFields
objectids = the selected object ids (if the right-clicked object wasn't selected, it alone is selected first) · spread = spread number · point = the right-clicked point (spread coordinates, pt)
canvasspread · page = the page number at that point (null outside any page) · point
textid = the text being edited — a text frame (thread head) id, 1.4 for a table cell { id: table, cell: [row, col] } (text target) · selection = { start, end, text } — the character range [start, end) in Graphic.text and the selected text (start = end for a bare caret)
  • When calling with a text argument, the editor ends text editing (the text being typed is committed to the document) and then calls the command — so doc.text and tx.insertInline(id, selection.start, …) see the same character positions.
  • When the command finishes, the editor reopens editing — unless the plugin called selection.set or selection.setText: the original selection if the text is unchanged, otherwise a caret at original end + characters added (right after the new text when the range was replaced). Nothing is reopened while a batch is running or if other text started being edited meanwhile.
    // 1.4 — manifest "keybindings": [{ "command": "ipa", "key": "ctrl+alt+c" }] (⌃⌥C on Mac · Ctrl+Alt+C elsewhere)
    podolay.commands.define('ipa', async (arg) => {
      if (arg?.context !== 'text' || arg.selection.start === arg.selection.end) return;
      const { start, end, text } = arg.selection;
      await podolay.edit('IPA', tx => tx.replaceRange(arg.id, start, end, { runs: [{ text: toIpa(text), font: 'Charis SIL' }] }));
    });   // afterwards the editor reopens editing with the caret right after the new text
  • ids is passed even without the selection permission (the user chose those objects explicitly). Reading or changing the document still requires document:read / document:write.
  • A command must finish within 60 seconds (after that the editor stops waiting and shows a "Plugin command failed" notification). Do longer work inside a batch, or run it separately without awaiting it.
  • Thrown errors are shown as a notification and in the log.

6. Events — podolay.on

1.0 — podolay.on(name, fn) returns a function that unsubscribes.

const off = podolay.on('selectionChange', ({ ids }) => { … });
off();   // stop receiving
NamePayloadWhenPermission
selectionChange{ ids: number[], inline? }The selection changesselection
documentChange{ origins }After the document changes (consecutive changes are coalesced over 0.2 s)🔓
dataChange{ id, before, after, origin }After the data of one of this plugin's objects changes🔓
historyChange{}After the user performs Undo or Redo🔓
panelClose{ panelId: string }The user closed one of this plugin's panel windows🔓
textSelectionChange 1.4{ id, start, end, text } | nullAfter the caret or selection changes while editing text (once, 0.12 s after it stops), null when editing endsselection
  • selectionChange.inline = { id, index } when an inline object is selected — the story (text frame id) and the index used by doc.inlines and tx.removeInline. The object in ids is then a stand-in that exists only while it is selected (same as the context menu's arg.inline).
  • documentChange.origins = why the document changed during those 0.2 s: user (user edits, the Inspector), plugin (a plugin edit), history (Undo/Redo).
  • dataChange fires every time one object's data actually changes (not coalesced), only to the plugin that made the object. origin = inspector (an Inspector field), plugin (tx.setData), undo or redo. A re-render after a resize alone does not fire it, nor does changing an inline object with tx.setInline (including its Undo — that is a text change and only fires documentChange). When an inline object is edited in the Inspector, id is the stand-in that exists only while it is selected. To avoid fighting Undo, don't apply follow-up fixes on undo/redo.
  • textSelectionChange is not sent again for the same value. The value has the shape of text.selection() — id is a text target (a text frame id or a table cell { id, cell }). It is null while editing a footnote or the text inside an inline object. Use it to preview the selected text in a panel.

Errors thrown inside an event handler are only logged.


7. Reading the document — podolay.doc · podolay.text

7.1 doc.info()

1.0 🔑 document:read → DocInfo

const info = await podolay.doc.info();
// { title, language, colorMode, pageSize: {width, height}, currentSpread,
//   spreads: [{ index, pages: [{ index, x, y, width, height, master, label }] }],
//   masters: [{ id, name }] }
KeyDescription
languageDocument language (ko en ja …)
colorModeCMYK · RGB
currentSpreadThe spread number currently in view
pages[].x·yThe page's position within the spread — to place an object on a page, use frame.x = page.x + …
pages[].masterMaster id (null if none) 1.1
pages[].labelDisplayed page number — follows section numbering (iii, A-3 …) 1.1
mastersList of masters { id, name } 1.1

7.2 doc.graphics(q)

1.0 🔑 document:read → Graphic[] — Objects placed directly on the spread, in stacking order (back → front). A group lists the objects inside it in children.

q keyDefaultDescription
spreadCurrent spreadSpread number

Objects on the pasteboard (outside the pages), hidden objects and objects on hidden levels are included — tell them apart with visible and the frame. Master objects are not included (doc.find({ masters: true }), doc.graphic).

It returns one spread only — in a single-sided document every page is its own spread. To cover the whole document, loop over doc.info().spreads passing spread, or use doc.find. The mock editor keeps all pages in spread 0, so tests there don't show the difference — run code that looks for objects on other pages in the sandbox editor too.

7.3 doc.graphic(id)

1.0 🔑 document:read → Graphic | null — Looks up an object by id anywhere in the document (including inside groups and on masters). Returns null if not found.

See the Graphic type.

1.6 id may also be an inline object address { id: story, inline: n, path? } — reads an inline object or a child inside an inline group. The returned value has no id (inline objects have none), and group children carry path. frame is not in spread coordinates — where the object sits is the placed of doc.inlines. An address that doesn't exist is an error, not null.

7.4 doc.raw()

1.0 🔑 document:read → any — The whole document in its storage format. This is an internal format and may change between editor versions — read only what no other API provides, and write code that tolerates changes. It is read-only.

7.5 doc.getData()

1.0 🔓 → any — The value this plugin saved in the document with tx.setDocData (null if none). Other plugins' values are not visible.

7.6 doc.styles()

1.1 🔑 document:read → { para: StyleInfo[], char: StyleInfo[] } — The document's paragraph and character styles (excluding "None").

const { para, char } = await podolay.doc.styles();
// para: [{ id: -1, name: 'Basic', key: '$base' }, { id: 12, name: 'Title', basedOn: -1 }, …]
KeyDescription
idStyle id
nameDisplay name. Built-in style names differ by UI language
keyBuilt-in styles only — '$base' (Basic). Use this alias in a Story
basedOnId of the style it is based on

7.7 doc.text(id)

1.1 🔑 document:read → StoryPara[] — The text of a text object (a text frame, title text, or a shape containing text). For threaded frames, any frame id returns the whole story.

const story = await podolay.doc.text(id);
// [{ para: 'Title', runs: [{ text: 'Spring collection', char: '$base', font: 'Noto Sans KR', size: 28, color: {cmyk:[0,100,100,0]}, bold: true }] }, …]
  • Paragraph and character styles are given by name ('$base' for the built-in one); direct formatting covers only font, size, color, bold and italic.
  • Variable text such as page numbers and footnote references comes with variable: true and its currently displayed value.
  • 1.2 Inline objects come as separate runs { inline: { index, kind, width, height, … } } (§16.8). Passing such a run back to tx.setText unchanged reuses that object — so doc.text → modify → setText doesn't lose inline images. For plugins with apiVersion 1.1 or lower, the run's text contains the object replacement character U+FFFC, as before.
  • 1.4 id may also be a text target — a table cell { id: table, cell: [row, col] }, an inline text frame { id: story, inline: n }, a cell of an inline table { id, inline, cell }. Before 1.4 a table gave the error '글이 있는 개체가 아닙니다' (not a text object), and inline text frames could only be read through the text of doc.inlines. 1.6 Text frames and table cells inside an inline group are { id, inline, path } and { id, inline, path, cell }.
  • 1.6 More direct formatting — the run's tracking, hScale, vScale, baselineShift, underline, strike, position and smallCaps are included only when they differ from the default, and the paragraph's align, leading, spaceBefore, spaceAfter, indentLeft, indentRight and indentFirst only when they differ from its paragraph style (§16.6). Plugins on 1.5 or lower don't get them.

7.8 doc.find(query)

1.1 🔑 document:read → Graphic[] — Finds template slots: the objects that match all conditions (including inside groups, in spread order).

await podolay.doc.find({ text: '{{title}}' });                  // text objects containing the placeholder
await podolay.doc.find({ tag: { slot: 'photo' }, page: 2 });     // objects on page 3 tagged by this plugin
await podolay.doc.find({ kind: 'image', spread: 0 });
query keyTypeDescription
textstringThe text contains this string. For threaded text, only one frame (the head of the thread) matches
tagtrue · string or number · objectThis plugin's pluginData — true = present, a value = equal (or pluginData.tag is equal), an object = those keys are equal
kindGraphic.kindKind
spreadnumberOnly on that spread
pagenumber (zero-based)The center of the object's frame lies on that page
masterstrueInclude master spreads too (when spread and page aren't given)
limitnumberDefault 1000, max 5000

Objects don't have names. Find slots by placeholders in the text ({{field}}), which designers can type in the editor, or by pluginData tags attached through a plugin panel.

Search scope: objects on the pasteboard (outside the pages), hidden objects and objects on hidden levels are included (a template kept on the pasteboard or hidden is still found). Masters only with masters: true.

7.9 doc.textStatus(id)

1.1 🔑 document:read → TextStatus — Text flow, read from the same layout as the screen.

const st = await podolay.doc.textStatus(bodyId);
// { frame: 0, frames: [101, 102], lines: 7, chars: [0, 134], length: 458, height: 108.2, overflow: false }
KeyDescription
frameThis frame's position in the thread
framesThe ids of the frames in the thread (one if not threaded)
linesNumber of lines in this frame
charsCharacter range in this frame [start, end)
lengthLength of the whole story
heightHeight of the text in this frame (pt)
overflowText overflows at the end of the thread (overset)

If font slices haven't arrived yet, the values may change slightly a moment later.

7.10 text.measure(story, width, opts)

1.1 🔑 document:read → { height, lines } — Composes text without changing the document.

const { height, lines } = await podolay.text.measure(story, 180, { like: bodyId });
ArgumentDescription
storyStory
widthWidth the text is set in, pt — the frame width minus its insets
opts.likeA text object id — use that text's first formatting as the base (= the same formatting as tx.setText). Defaults to the document's "Basic" style

Text is measured with the fonts loaded right now. Fonts and characters not yet used in the document may be measured with a substitute font at first, so check with doc.textStatus after placing the text. 1.2 If the Story contains inline object runs, their sizes are measured too ({ graphic: id } uses that object's size without moving it).

7.11 doc.inlines(id)

1.2 🔑 document:read → InlinePlaced[] — The inline objects (inline anchors) of a text object, in text order. For threaded frames, the whole story.

const list = await podolay.doc.inlines(bodyId);
// [{ index: 0, kind: 'image', width: 40, height: 30, image: …, pluginData: 'logo', offset: 12,
//    placed: { frame: 102, x: 318.5, y: 240, width: 40, height: 30 } }, …]
KeyDescription
indexWhich inline object this is in the story (zero-based) — the { index } of tx.removeInline and setText
offsetCharacter position in Graphic.text (the object replacement character U+FFFC)
baseline1.3 From the object's bottom edge up to the text baseline (pt) — omitted when 0
placedWhere it sits — frame = that text frame's id; x, y, width, height = spread coordinates (before the frame's rotation). null if it's in overset text
OthersSame as Graphic — width and height instead of id and frame (inline objects have no id)
children[].path1.6 The position inside the group [child index, …], on the children of an inline group (and of groups inside it) — the path of an inline object address

Positions are read from the same layout as the screen (they may shift slightly if font slices arrive late). Objects inside footnote text are not included.

1.8 id may also be a text target outside text frames — a table cell { id, cell }, an inline table cell { id, inline, cell } and so on. You get that text's inline objects in the same shape, with placed = null. Address them with { in: that text target, inline: index, path? } (for a catalog with a product picture in each table row, say).

const cell = { id: tableId, cell: [row, 0] };
const [pic] = await podolay.doc.inlines(cell);                       // the inline group in the cell
await podolay.edit('photo', tx => tx.setImage({ in: cell, inline: pic.index, path: [0] }, asset, 'fill'));

7.12 doc.texts(q)

1.4 🔑 document:read → TextPlace[] — every text place in the document, in spread order. For plugins that sweep text (spelling, notation conversion) and must not miss table cells or inline text boxes.

for (const t of await podolay.doc.texts()) {
  // { target: 12, kind: 'frame', text: '…' } · { target: { id: 30, cell: [0, 1] }, kind: 'cell', text: '…' } · …
  if (t.text.includes('ph')) await podolay.edit('Replace', tx => tx.replaceText(t.target, 'ph', 'f'));
}
KeyMeaning
targetA text target — pass it as is to doc.text and tx.*
kindframe = a text frame (one head per threaded story) · cell = a table cell · inline = an inline text frame · inlineCell = a cell of an inline table
textThe text (same shape as Graphic.text — \n between paragraphs)
q keyMeaning
spreadOnly that spread
masterstrue = master spreads too (when spread is not given)

Objects inside groups are included; table cells covered by a merge are skipped. A text frame is followed by the inline text frames and table cells of its story. Up to 20,000 entries. Text on the pasteboard, hidden objects and objects on hidden levels is included. Masters only with masters: true.

7.13 text.selection()

1.4 🔑 selection → { id, start, end, text } | null — the selection of the text the user is editing. While a command called with a text argument (§5.3) runs, it returns that place (even though editing is closed for the moment).

  • id = a text target — a text frame (thread head) id or a table cell { id, cell }. start · end = character positions in Graphic.text, text = the selected text ('' for a bare caret).
  • null when no text is being edited. Footnotes and the text inside inline objects are null for now.
  • To show it in a panel, listening to the textSelectionChange event is lighter.

7.14 doc.table(target)

1.6 🔑 document:read → TableInfo — the structure of a table. target = a table id on a spread (including inside groups) or an inline table { id: story, inline: n, path? }.

const t = await podolay.doc.table(tableId);
// { rows: 6, cols: 4, headerRows: 1, footerRows: 0, merges: [[2, 0, 2, 1]], rowHeights: [20, 18, …], colWidths: [80, 60, 60, 60],
//   tableStyle: 'Striped', cellStyles: [[null, …], …] }   ← 1.7
KeyMeaning
rows · colsNumber of rows and columns
headerRowsNumber of header rows (top rows repeated when the table breaks across pages)
footerRowsNumber of footer rows (bottom rows repeated below every part but the last when the table breaks across pages; editor 2.15.0 or later — always 0 before)
mergesMerged cells as [row, col, row count, col count] (zero-based, by the top-left cell)
rowHeights · colWidthsRow heights and column widths (pt)
tableStyle1.7 Name of the applied table style, or null
cellStyles1.7 Per cell (row → column), the name of the cell style applied directly, or null (also for cells covered by a merge; region cell styles are in tableStyles() regions)

Read a cell's text with doc.text({ id, cell }); add and remove rows with tx.insertRows · tx.removeRows; apply styles with tx.setTableStyle · tx.setCellStyle.

7.15 doc.tableStyles()

1.7 🔑 document:read — the document's table styles and cell styles (made in the editor's Inspector › Table Style).

const { table, cell } = await podolay.doc.tableStyles();
// table: [{ name: 'Striped', regions: { header: 'Header cell', body: null, footer: null, left: null, right: null }, alternating: 'rows' }]
// cell:  [{ name: 'Header cell' }]

regions = the cell style name used for each region (null if none — header, footer, left and right follow the body), alternating = alternating fills 'rows', 'cols' or null. Creating and editing styles is the user's job, so the API has no calls for it — plugins apply styles by name.


8. Selection — podolay.selection

8.1 selection.get()

1.0 🔑 selection → number[] — The ids of the selected objects.

8.2 selection.set(ids)

1.0 🔑 selection → number[] — Selects objects. Returns the ids that were actually selected.

  • Up to 1000. Objects inside a group can't be selected (select the whole group).
  • One spread only — only objects on the first object's spread are selected.

8.3 selection.setText(id, start, end?)

1.4 🔑 selection → true — selects [start, end) of that text and opens it for editing (without end, a caret at start). If it is on another spread or a master, the editor goes there (as Find does).

  • id = a text frame id or a document table cell { id, cell } (text target). Not inside inline objects yet.
  • 0 ≤ start ≤ end ≤ text length (character positions in Graphic.text). Not during a batch.
  • Called inside a command run with a text argument, the editor doesn't restore the original selection after the command (the same goes for selection.set).

9. Writing to the document — podolay.edit and tx

9.1 edit(name, fn)

1.0 🔑 document:write → { created: number[], results: any[] }

const { created, results } = await podolay.edit('Align', tx => {
  tx.set(id, { frame: { x: 0, y: 0, width: 100, height: 50 } });
  const k = tx.create({ kind: 'text', frame, story: 'Hello' });   // k = the op index within this edit
});
results[k];   // the new object's id
  • fn is a synchronous function — don't await inside it. tx.* only records what to do; the editor applies everything at once after fn returns.
  • One edit = one Undo step for the user ("plugin name: name" in the Undo menu). If any op fails, nothing changes and edit throws.
  • Up to 2000 ops per edit.
  • Ops are applied in the order they were recorded — a later op sees the document as changed by earlier ones (in one edit, replaceRange can edit text duplicated by copyRange).
  • created = ids of the objects created (in creation order); results[op index] = each op's result ("Result" in the table below). Each tx.* returns its op index.
  • If the user was editing text, the editor ends text editing before applying.

9.2 tx operations at a glance

OperationVersionResult results[op]What it does
create(o)1.0New object idCreate an object
set(id, props)1.0nullChange common properties
remove(id)1.0nullDelete
setData(id, data)1.0nullData of your own graphic (also redraws it)
setPluginData(id, value)1.0nullThis plugin's tag on any object
setDocData(value)1.0nullDocument-level data
setText(id, story)1.1nullReplace the whole text (keeps template formatting)
replaceText(id, search, text)1.1nullReplace placeholders
setImage(id, asset, fit)1.1nullPut an image in a shape
link(from, to)1.1nullThread text frames
unlink(from)1.1nullUnthread text frames
addPages(o)1.1{ pages }Add pages
duplicatePages(o)1.1{ pages, copies }Duplicate pages (including objects)
removePages(pages)1.1nullDelete pages
applyMaster(pages, master)1.1nullApply a master
insertInline(id, at, inline)1.2Number insertedInsert an object into text
removeInline(id, index)1.2nullRemove an inline object
setInline(id, index, o)1.3nullChange an inline object in place
replaceRange(id, start, end, what)1.3null · { start, end } ({ story })Replace a character range with text, formatted runs, an object or a Story
formatRange(id, start, end, format)1.4characters formattedApply formatting only to a character range
copyRange(from, to)1.6{ start, end, inlines, dropped }Duplicate a text range with its formatting
appendText(id, story, o)1.6{ start, end, inlines }Append a Story with its paragraphs to the end of a text
insertRows(target, o)1.6{ rows }Insert table rows (duplicating rows)
removeRows(target, at, count?)1.6rows removedRemove table rows
autoflow(id, o)1.6{ pages, frames, overflow }Flow overset text onto new pages and frames
duplicate(id, o)1.6{ id, copies }Duplicate a spread object
setTableStyle(target, name, o?)1.7nullApply or remove a table style
setCellStyle(target, cells, name, o?)1.7nullApply or remove a cell style

1.4 The id of the operations that take text (setText · replaceText · replaceRange · formatRange) may also be a text target — changing the text of a table cell or an inline text frame becomes part of the Undo step that changes that table or host story. Inline objects can't be inserted into the text of inline objects (inline text frames; { inline } runs in setText, { inline } in replaceRange). Table cells take them from 1.9 — see below. 1.6 Text inside an inline group is { id, inline, path }, and the id of tx.set, tx.setPluginData and tx.setImage may also be an inline object address.

9.3 tx.create(o)

kindFieldsDescription
plugintype, frame, data?Your own graphic type (render is called to produce the drawing)
rectangle · ellipseframe, fill?, stroke?fill: a color · null (none). stroke: { color?, width? } · null
lineframe, stroke?From the frame's top-left to its bottom-right. Default black 1pt
textframe, textUnformatted text
text 1.1frame, storyA styled Story. Base = the document's "Basic" style
image 1.1frame, asset, fit?An image frame. For asset see §13; for fit see §9.11

Common: frame = spread coordinates in pt, spread? = spread number (default: the current spread). New objects go on the current level (layer).

9.4 tx.set(id, props)

PropertyTypeDescription
frameFramePosition and size (spread coordinates)
rotationnumber (degrees)
opacity0–1Out-of-range values are clamped
visibleboolean
lockedboolean
fillcolor · nullSolid fill · no fill
stroke{ color?, width? } · nullStroke · no stroke

Objects inside groups can be changed too. Other properties (text frame options, etc.) will come in a later API version.

1.6 With an inline object address { id: story, inline: n, path? } as id, it changes an inline object or a child inside an inline group (one Undo step that changes the host story; locking follows the host story). Changing frame or rotation re-measures the group's frame and the size of the inline slot — the text reflows.

9.5 tx.remove(id)

Only objects placed directly on a spread (objects inside a group go with the whole group). Locked objects are not deleted (the whole edit fails).

9.6 tx.setData(id, data)

Only for this plugin's graphics (kind: 'plugin', same plugin). The editor calls render again and updates the drawing and data together. data must be at most 256 KB of JSON.

9.7 tx.setPluginData(id, value)

Attaches this plugin's tag to any object — it never mixes with other plugins' tags. value = JSON (up to 256 KB), null = remove. It is saved and copied along with the object, and read through doc.find({ tag }) and Graphic.pluginData. 1.6 id may also be an inline object address — tag a child inside an inline group and find it again through the children of doc.inlines.

9.8 tx.setDocData(value)

Saves this plugin's value at the document level (read it with doc.getData()). At most 256 KB of JSON; null = remove.

9.9 tx.setText(id, story)

1.1 — Replaces all the text of a text object (the whole story for threaded frames). The base is the current text's first paragraph and first character formatting — formatting the designer applied in the template is kept. Not allowed on locked objects.

tx.setText(titleId, 'Spring collection');                                      // template formatting as is
tx.setText(bodyId, [{ para: 'Body', runs: [{ text: 'List price ' }, { text: '$12.00', char: 'Emphasis' }] }]);

9.10 tx.replaceText(id, search, text)

1.1 — Replaces every occurrence of search in the text with text. The surrounding formatting is kept.

  • search is 1–1000 characters, text up to 100,000 characters (line breaks become spaces).
  • It only finds matches that lie entirely within one formatting run — if the formatting changes in the middle of a placeholder, it won't be found.
  • Variable text such as page numbers and footnotes, and inline objects, are left alone. If nothing is found, nothing happens (not an error).

9.11 tx.setImage(id, asset, fit)

1.1 — Places or replaces an image in a shape (rectangle, ellipse, polygon, path, image frame, etc.; not tables). The image is clipped to the shape. Not allowed on locked objects.

fitDescription
'fill' (default)Covers the frame proportionally — the excess is cropped
'fit'Shows the whole image proportionally — leftover space stays empty
'stretch'Ignores the proportions and fills the frame exactly

asset must be something this plugin uploaded in that window (assets.upload), 1.6 something assets.list returned, or an image path already in the document. After placement, the user can change it with the Fit options in the Inspector.

1.6 id may also be an inline object address — puts the image into an image box (shape) inside an inline group.

// into the second child of inline group #0, showing the whole image
await podolay.edit('Photo', tx => tx.setImage({ id: target, inline: 0, path: [1] }, asset, 'fit'));

9.12 tx.link(from, to) · tx.unlink(from)

1.1 — Threads and unthreads text frames (same rules as threading directly in the editor).

  • link: from must be the end of a thread (no next frame), and to an empty text frame that doesn't belong to another story. Title text, text on a path and locked objects are not allowed.
  • unlink: breaks the thread between from and its next frame (the text of the following frames flows back into the earlier story).

9.13 Page operations

1.1 — Page numbers are zero-based and as they stand when that op runs (they shift if an earlier op adds or removes pages). The pages in the result are numbers after the whole edit has finished.

const r = await podolay.edit('Catalog pages', tx => tx.duplicatePages({ from: 2, times: 3 }));
r.results[0];   // { pages: [3, 4, 5], copies: [{ '120': 301, '121': 302 }, { … }, { … }] }
OperationArgumentsResult
addPages({ after?, count?, master? })after: after that page (default: at the end; -1 = at the start) · count: 1–200 · master: id, name or null (none); if omitted, the neighbouring page's master{ pages }
duplicatePages({ from, count?, times?, after? })Duplicates count pages (default 1) starting at from, times times (1–500), after after (default: right after the original). Includes objects, masters and deleted master items (see Restore Deleted Master Items){ pages, copies } — copies[k] = the k-th copy's { original object id: copy id } (1.6 children inside groups too)
removePages(pages)Array of page numbers. You can't delete every pagenull
applyMaster(pages, master)master = id, name or nullnull
  • An edit that contains page operations is undone through one snapshot of the whole document (together with the other ops in the same edit). On failure, everything returns to the state before it started.
  • If the plan's page limit per document would be exceeded, the whole edit fails. One edit can add up to 1000 pages. Not allowed while editing masters.
  • tx.autoflow is a page operation too (same snapshot Undo, same page limit).
  • copies also contains the ids of children inside groups (since the 1.6 editor — before, only top-level objects). Table cells have no id — a cell of the copied table is { id: copied table, cell }.
  • Within the same edit, the ids of new pages and copies aren't known yet — fill duplicated pages in the next edit, and use batch to make both one Undo step.

9.14 tx.insertInline · tx.removeInline

1.2 — An inline object (inline anchor) occupies one character position and flows with the text (it moves when lines rewrap or earlier text grows). You can put images, shapes, your own graphics and text boxes into text. Not allowed on locked text objects.

await podolay.edit('Insert logo', tx => {
  tx.insertInline(bodyId, '{{logo}}', { kind: 'image', asset, width: 40, height: 30 });   // at every placeholder
  tx.insertInline(bodyId, null, { graphic: badgeId });                                    // moves a spread object to the end of the text
});
await podolay.edit('Remove', tx => tx.removeInline(bodyId, 0));
ArgumentDescription
idThe text object (the whole story for threaded frames)
atA string = placeholder — replaces every occurrence of it in the text with the object (only matches within one formatting run, as with replaceText) · a number = character position in Graphic.text (0 = start) · null or omitted = end of text
inlineAn Inline — a new object or { graphic: id }
  • The result results[op] = the number of objects inserted. If the placeholder isn't found it's 0, not an error. Nothing is inserted inside variable text such as page numbers.
  • The inserted object takes on the formatting of the text at that position (e.g. the font size used to compute line height).
  • removeInline(id, index) — removes the index-th inline object (along with its character) (the index from doc.inlines).
  • You can also insert through Story runs — the { inline } run in tx.create({ kind: 'text', story }) and tx.setText.

9.15 tx.setInline · tx.replaceRange

1.3

await podolay.edit('Edit formula', tx => tx.setInline(bodyId, 2, { data: { tex: 'x^2' } }));       // with autoSize, the size too
await podolay.edit('To formula', tx => tx.replaceRange(bodyId, sel.start, sel.end,
  { inline: { kind: 'plugin', type: 'math', data: { tex }, width: 30, height: 12, baseline: 3 } }));
  • setInline(id, index, { data?, width?, height?, baseline? }) — changes the index-th inline object of text id (the index of doc.inlines) in place. Formatting, position and rotation stay. Only this plugin's graphics (kind: 'plugin'). With width/height that size is used (greater than 0, up to 5000); with only data and autoSize, size fits it. render is called at the new size. index counts in the text as changed by earlier ops of the same edit (inserting or removing objects before it shifts it).
  • replaceRange(id, start, end, what) — replaces the character range [start, end). Positions are those of Graphic.text (including the \n between paragraphs, same as doc.find). what = text (a string or { text }; line breaks become spaces) · 1.4 { runs: [{ text, char?, font?, size?, color?, bold?, italic? }] } · { inline: Inline }. The formatting is that of the character at start; a range across a paragraph end merges the paragraphs (keeping the first paragraph's formatting). { runs } starts from the formatting of the character at start and applies each run's character style → direct formatting (the same rules as Story runs; no inline runs, up to 1000 runs and 200,000 characters). { inline } only into a text frame's text (a numeric id) and, 1.9, a table cell's text (a text target with cell). start = end inserts. A range that cuts a variable run (such as a page number) in half fails (the whole run is fine). Unlike insertInline with a placeholder, only that range changes even if the same text appears elsewhere. 1.6 { story } — replaces the range with a Story, with paragraph styles and direct paragraph formatting (§9.18). The result is then { start, end }.

9.16 tx.formatRange(id, start, end, format)

1.4 — applies formatting only to the character range [start, end); the text stays. On top of those characters' current formatting: the format's character style → direct formatting.

const r = await podolay.edit('IPA font', tx => tx.formatRange(sel.id, sel.start, sel.end, { font: 'Charis SIL', italic: false }));
r.results[0];   // the number of characters formatted
format keyMeaning
charCharacter style name or id ('$none' = only detach the style — direct formatting stays)
font · size · color · bold · italicSame as the direct formatting of Story runs (size 0.5–2000 pt)
tracking · hScale · vScale · baselineShift · underline · strike · position · smallCaps1.6 Same as the additional direct formatting of Story runs
  • A format with no keys is an error. results[op] = the number of characters formatted — 0 for an empty range (start = end), which changes nothing.
  • A range across paragraphs leaves the paragraphs as they are. A range that cuts a variable run (such as a page number) in half fails. An unknown style name fails the whole edit.

9.17 tx.copyRange(from, to)

1.6 — duplicates a text range with its formatting and inserts it somewhere else (the same text is fine). Paragraph styles, character styles, all direct formatting and inline objects (images, shapes, text boxes, groups, nested groups, inline tables) go along. Use it to repeat template text per record, as in database publishing.

ArgumentMeaning
from{ id: text target, start, end } — the character range [start, end) to copy (a text target; character positions of Graphic.text)
to.idThe text target to insert into
to.atA character position · 'end' (default) — where to insert
to.newParagraphtrue = if the insertion point isn't at the start of a paragraph, split the paragraph first (paste as a new paragraph). Default false
  • Paragraph formatting follows the paragraph end (\n) (as in InDesign): paragraphs closed inside the copied range (those whose ending \n is included) keep the source's paragraph formatting; the open last piece takes the formatting of the paragraph it is inserted into. Exception: when it lands in the last paragraph of the text with nothing left of that paragraph (pasting into empty text, for instance), it keeps the source's last paragraph formatting. To move whole paragraphs, make the range include the paragraph end \n.
  • Result results[op] = { start, end, inlines, dropped } — [start, end) = the inserted range in the target text (without the \n added by newParagraph), inlines = the ordinals of the inline objects in the inserted range (the index of doc.inlines), dropped = the number of footnote and endnote references left out.
  • Later ops in the same edit see the duplicated text — if you can work out the range in advance, change it in the same edit. Usually you take the result and change it in the next edit (example below).
  • A range containing inline objects can only be inserted into a text frame's text (to.id is a number) and, 1.9, a table cell's text (to.id is { id, cell } or { id|in, inline, path?, cell }) — text frames inside inline objects can't hold inline objects yet.
  • Footnote and endnote references are not copied (the note text belongs to its original text) — they are left out and counted in dropped. Other variable text, such as page numbers, is copied.
  • Nothing is inserted into locked text. An empty range changes nothing (start = end is returned).
// Database publishing — duplicate the template text (whole paragraphs) at the end of the target text for each record, then fill the <field> tags
const tpl = (await podolay.doc.texts()).find(t => t.text.includes('<name>'));   // also finds a template text box kept on the pasteboard
const len = tpl.text.length;
await podolay.batch('Build directory', async (signal) => {
  for (const rec of records) {
    signal.throwIfAborted();
    const r = await podolay.edit('Duplicate', tx => tx.copyRange({ id: tpl.target, start: 0, end: len }, { id: bodyId, newParagraph: true }));
    const { start, end } = r.results[0];
    const { text } = await podolay.doc.graphic(bodyId);                     // the whole text (Graphic.text)
    await podolay.edit('Fill', tx => {
      // replace from the back so earlier positions don't shift
      const hits = [...text.slice(start, end).matchAll(/<(\w+)>/g)].reverse();
      for (const m of hits) tx.replaceRange(bodyId, start + m.index, start + m.index + m[0].length, String(rec[m[1]] ?? ''));
    });
  }
});

9.18 tx.appendText · replaceRange with { story }

1.6 — inserts a Story with its paragraphs (paragraph styles and direct paragraph formatting) into a text. Unlike setText, it appends or replaces only a range instead of the whole text.

await podolay.edit('Append', tx => {
  tx.appendText(bodyId, [{ para: 'Subhead', text: 'Chapter 3' }, { para: 'Body', text: '…' }]);   // at the end, in a new paragraph
  tx.replaceRange(bodyId, s, e, { story: [{ para: 'Quote', text: '…' }] });                     // a range, with paragraphs
});
  • appendText(id, story, { newParagraph? }) — appends at the end of the text. newParagraph defaults to true (if the last paragraph has text, it is split first); false = continue the last paragraph. Result { start, end, inlines }.
  • replaceRange(id, start, end, { story }) — deletes the range and inserts the Story there. Result { start, end }.
  • Base formatting = the paragraph formatting of the paragraph at the insertion point and the character formatting of that character (the last character at the end of the text). On top of it: the Story's paragraph style → direct paragraph formatting, the run's character style → direct formatting. Paragraph formatting follows the paragraph end as in copyRange — the Story's last paragraph is an open piece and takes the formatting of the paragraph it lands in (when appended as a new paragraph at the end, it keeps the Story's).
  • Inline runs { inline } only into a text frame's text and, 1.9, a table cell's text. The Story input limits (200,000 characters, 10,000 paragraphs) apply to one input — append several times to make the text longer.

9.19 tx.insertRows · tx.removeRows

1.6 — inserts and removes table rows. target = a table id on a spread (including inside groups) or an inline table { id: story, inline: n, path? }. The table's structure comes from doc.table.

// duplicate row 1 (the template row) once per record below the header row, then fill the cells
const r = await podolay.edit('Rows', tx => tx.insertRows(tableId, { after: 1, count: items.length, copyFrom: { row: 1 } }));
await podolay.edit('Fill', tx => r.results[0].rows.forEach((row, k) => tx.setText({ id: tableId, cell: [row, 0] }, items[k].name)));
await podolay.edit('Remove template row', tx => tx.removeRows(tableId, 1));

// append a row at the end of the body (above the footer rows) — with after: t.rows - 1 a table with footer rows gets another footer row
const t = await podolay.doc.table(tableId);
await podolay.edit('Append row', tx => tx.insertRows(tableId, { after: t.rows - t.footerRows - 1, copyFrom: { row: t.rows - t.footerRows - 1 } }));
o of insertRowsMeaning
afterInsert after this row (-1 = at the top)
countRows to insert (1–1000). Defaults to copyFrom.count, else 1
copyFrom{ row, count?, table? } — repeats these rows (default 1 row) in turn, duplicating cell formatting, text, cell backgrounds, row heights, row border lines and cell borders. Without it, empty rows. 1.8 table = from another table (same number of columns)
  • Result { rows: [new row numbers…] } — numbers after the edit.
  • Merges: refused if a merged cell spans across the insertion point vertically, or if a merge reaches outside the rows to copy. If the rows to copy contain vertical merges, count must be a multiple of copyFrom.count.
  • Header and footer rows (as in the editor): a new row inside the header rows (after < headerRows - 1) adds a header row; inside or below the footer rows (after ≥ rows - footerRows) adds a footer row. Rows inserted just below the header (after: headerRows - 1) or just above the footer (after: rows - footerRows - 1) are body rows. Removing footer rows reduces footerRows.
  • 1.7 Rows duplicated with copyFrom keep their cell style names (cellStyles in doc.table). Inserting and removing rows shifts the rows of cellStyles with them.
  • 1.8 copyFrom.table — copies rows of another table (a spread table id or an inline table address) with their merges, cell backgrounds, border lines, cell borders and row heights. A different number of columns is an error. For mixing rows of several template tables in one table. Cell borders (sides changed per cell) now travel too, also when copying within the same table.
  • removeRows(target, at, count = 1) — removes count rows starting at at. Result = the number of rows removed. You can't remove every row.
  • A spread table goes into a table-structure Undo step; an inline table into the Undo step that changes the host story. Locked tables (for inline ones, a locked host story) are refused.

9.20 tx.autoflow(id, o)

1.6 — flows overset text (InDesign's Shift-click autoflow). While the end of the thread is overset, it inserts a page after the last frame's page and places a frame there at the same position, size and options (columns, insets, fill), threading it on. If the last frame is a primary text frame, the new page's master primary text frame is used.

await podolay.edit('Append', tx => tx.appendText(bodyId, longStory));
const r = await podolay.edit('Flow', tx => tx.autoflow(bodyId, { maxPages: 200 }));
r.results[0];   // { pages: [5, 6, 7], frames: [311, 312, 313], overflow: false }
o keyMeaning
masterMaster of the new pages: id, name or null (none). If omitted, follows the neighbouring page
maxPagesMaximum pages to insert (1–1000, default 100)
  • Result { pages, frames, overflow } — new page numbers (after the edit), new frame ids, and whether text is still overset (true when maxPages was reached or an object bigger than the frame stops the flow).
  • It is a page operation — snapshot Undo, the plan's page limit, not while editing masters (§9.13). Master text, a last frame on the pasteboard, title text and text on a path are refused.
  • Layout is measured with the fonts loaded now — if font slices are still on their way, the flow may change slightly later.

9.21 tx.duplicate(id, o)

1.6 — duplicates an object placed directly on a spread (a group as a whole).

o keyMeaning
spreadSpread to place it on (default: the original's spread)
dx · dyOffset in pt (default 0 — same position)

Result { id, copies } — id = the copy's id, copies = { original id: copy id } including children inside groups. The new object is also in created.

9.22 tx.setTableStyle · tx.setCellStyle

1.7 — applies table and cell styles by name. target is the same as for doc.table.

await podolay.edit('Style tables', (tx) => {
    tx.setTableStyle(tableId, 'Striped');                          // the whole table — region cell styles and alternating fills too
    tx.setCellStyle(tableId, [[3, 0], [3, 1]], 'Total cell');      // cells [row, col] (a cell covered by a merge means that merged cell)
    tx.setTableStyle(otherId, 'Striped', { clearOverrides: true }); // values changed on cells take the style values too
    tx.setCellStyle(tableId, [[0, 0]], null);                      // null = remove (values stay)
});
  • The values go into the table and cells (the same rules as table styles in the editor — values the user changed on a cell stay unless clearOverrides).
  • An unknown name is an error. List the styles with doc.tableStyles().
  • Locked tables can't be changed. One undo reverts it (together with the other operations in the same edit).

9.23 tx.mergeCells · tx.unmergeCells

1.8 — merges and unmerges cells. target is as in doc.table, and the range is [row, col, rows = 1, cols = 1].

await podolay.edit('title row', tx => tx.mergeCells(tableId, [row, 0, 1, 3]));   // three cells of a row into one
await podolay.edit('unmerge', tx => tx.unmergeCells(tableId, [row, 0]));          // unmerges merges meeting that cell
  • mergeCells — merges inside the range are absorbed. A merge crossing the range is an error (widen the range or unmerge first). Result = whether it changed.
  • unmergeCells — unmerges every merge meeting the range. Result = whether any was unmerged.
  • The merged cell shows the top-left cell's text (as the editor's cell merge does). Locked tables can't be changed.

10. Batches — podolay.batch

1.1 🔑 document:write → the value returned by fn

await podolay.batch('Build catalog', async (signal) => {
  const r = await podolay.edit('Duplicate', tx => tx.duplicatePages({ from: 2, times: items.length }));
  for (let k = 0; k < items.length; k++) {
    signal.throwIfAborted();                                         // stop if the user cancelled
    await podolay.ui.progress({ label: items[k].name, value: k + 1, max: items.length });
    await podolay.edit('Fill', tx => tx.setText(r.results[0].copies[k][titleId], items[k].name));
  }
});
BehaviorDetails
UndoAll edits inside become one step (plugin name: name)
SavingThe edits inside show up on screen immediately; the whole document is saved once at the end
FailureIf fn throws, everything is rolled back to the state before the batch and batch rethrows the error
CancelThe user clicks Cancel in the progress window → signal.aborted = true. The batch is rolled back and batch throws 일괄 작업이 취소되었습니다 ("the batch was cancelled")
If cancel is ignoredIf the batch doesn't finish within 5 seconds, the editor rolls back by itself. After that, this plugin's edit calls are rejected until the batch ends
MeanwhileThe editor is blocked and the user can't edit (including Undo and shortcuts). The progress window appears only after 0.5 s. Other plugins' edit calls are rejected as well
AfterwardsThe objects selected when the batch started are selected again if they still exist (also after a rollback)
NestingYou can't call batch inside a batch
What remainsImage assets uploaded with assets.upload remain even after a rollback (if the document doesn't use them, they can be deleted in the gallery)
Time 1.6There is no total time limit. Editor-side API calls have no time limit either. The response time of a command (60 s) or a panel message (8 s) isn't treated as a failure while that plugin's batch is running — a command or panel-message handler may await a long batch. The editor keeps checking that the plugin is alive (if synchronous code blocks it from answering, it is turned off)
Screen 1.6The edits inside redraw the screen at most once every 250 ms (before: after every edit) — calling edit thousands of times doesn't let drawing outpace the work
SizeUp to 2000 ops per edit. A few dozen records per edit is recommended — too small means many round trips, too large makes a single apply long
MemoryUndo keeps two document snapshots, at the start and at the end (document JSON size × 2) — regardless of how many edits ran inside

ui.progress({ label?, value?, max? }) 1.1 🔓 — Only inside a batch. The progress window's text (200 characters) and bar. Without max the bar is indeterminate; with value/max it shows something like 3 / 10.

signal is { aborted: boolean, throwIfAborted(): void }.

Inside a batch you can't call ui.pickFiles or selection.setText — pick files before the batch.


11. UI — podolay.ui · podolayUI in panels

11.1 ui.toast(message, kind)

1.0 🔓 — An editor notification. kind = 'info' (default) · 'success' · 'error'. It is prefixed with the plugin name and truncated at 300 characters.

11.2 Panel HTML and podolayUI

A panel is a floating window showing the html from contributes.panels. It runs under the same sandbox rules as the plugin — no network (even if the plugin has network permissions, the panel doesn't), inline scripts and styles only, images as data: URLs. The editor's dark default styling (text, buttons, inputs) is applied first, and your panel CSS can override it.

A panel can't call the editor API directly; it talks only to its own plugin's main.js:

// inside panel.html
podolayUI.postMessage({ type: 'apply', value: 3 });     // to ui.onMessage in main.js
podolayUI.onMessage((msg) => { … });                    // from ui.postMessage in main.js
  • 1.6 Reading files in a panel — <input type="file"> (multiple, webkitdirectory), drag and drop and FileReader all work inside the panel (a sandbox="allow-scripts" iframe). Send the bytes to main.js with podolayUI.postMessage(msg, transfer?) — transfer = an array of ArrayBuffers to hand over (moved, not copied; they become empty in the panel). ArrayBuffers in a message don't count toward the 4-million-character limit (no separate cap — only memory). To pick files without a panel, use ui.pickFiles.
    <input type="file" id="f" webkitdirectory>
    <script>
      document.getElementById('f').onchange = async (e) => {
        const files = await Promise.all([...e.target.files].map(async (f) => ({ path: f.webkitRelativePath || f.name, bytes: await f.arrayBuffer() })));
        podolayUI.postMessage({ type: 'files', files }, files.map((x) => x.bytes));
      };
    </script>
  • Keys stay inside the panel — keys pressed while the panel has focus (⌘Z, ⌘C, Delete, …) never reach the editor (the panel is a separate sandboxed iframe, so the editor can't receive them). ⌘Z in an input is undone by the browser in that input only and never mixes with the document's Undo. Document Undo works again once the user clicks the canvas.

11.3 ui.showPanel(id) · ui.closePanel(id)

1.0 — showPanel 🔑 ui: opens the panel (does nothing if it's already open). closePanel 🔓: closes it. When the user closes a panel, the panelClose event fires.

11.4 ui.postMessage · ui.onMessage

1.0 🔓

await podolay.ui.postMessage('main', { count: 3 });              // the panel must be open (otherwise an error)
podolay.ui.onMessage(async (msg, panelId) => { … });             // the panel's podolayUI.postMessage
  • Messages are sent by structured clone (JSON-like values). At most 4 million characters per message — 1.6 ArrayBuffers inside don't count. The editor hands the ArrayBuffers of panel messages and pickFiles results to the plugin without copying them.
  • Don't await long-running work in an onMessage handler — the editor applies the 8-second limit to handler calls too. Run long work separately, e.g. void doWork(). 1.6 While that plugin's batch is running, going past 8 seconds isn't treated as a failure — a handler may start a batch and await it.

11.5 ui.pickFiles(o)

1.6 🔓 → PickedFile[] — reads files or a folder from the user's computer. Browsers open a file picker only from a click by the user, so the worker can't open one itself — the editor shows a picker window labelled with the plugin's name, and the user picks with its button. Only the files picked reach the plugin (which is why no permission is needed).

const files = await podolay.ui.pickFiles({ directory: true });            // a whole folder — photo folders, data files
// [{ name: 'a.jpg', path: 'photos/a.jpg', type: 'image/jpeg', size: 182733, bytes: ArrayBuffer }, …]
const csv = await podolay.ui.pickFiles({ accept: '.csv,.tsv' });
if (csv.length) rows = parse(new TextDecoder().decode(csv[0].bytes));
o keyMeaning
acceptSame form as <input accept> ('.csv', 'image/*', up to 500 characters)
multipletrue = several files
directorytrue = pick a folder (every file inside it)
Returned keyMeaning
nameFile name
pathFor a folder, the relative path starting at the folder's name (photos/2024/a.jpg); otherwise the same as name
typeThe MIME type the browser knows ('' if unknown)
sizeSize in bytes
bytesArrayBuffer — can be passed straight to assets.upload
  • Cancelling or picking nothing returns [].
  • Up to 5000 files and 1 GB in total per call (more is an error). An error if another picker is open or a batch is running — pick files before the batch.
  • A plugin with a panel can also pick with the panel's <input type="file"> (§11.2).

12. Storage and license — podolay.storage · podolay.license

1.0 🔑 storage

const s = (await podolay.storage.get()) ?? {};
await podolay.storage.set({ ...s, lastRun: Date.now() });
  • One JSON value (up to 64 KB) per account × plugin. It is independent of documents — use it for values that belong to the person, such as user settings and API keys.
  • In development mode (pluginDev) nothing is written to the server; the value stays in that window only.
  • Never put API keys in main.js (it's distributed code anyone can read) — store the user's own key, entered in a panel, here instead.
  • 1.8 storage.getFrom(pluginId) — reads the storage of another plugin of yours listed in the manifest's dataFrom, read only (moving a free edition's presets to Pro).

12.1 license.status()

1.5 🔓 → LicenseStatus

const lic = await podolay.license.status();
// { licensed: true, source: 'purchase', trialEndsAt: null }
if (lic.source === 'trial') podolay.ui.toast(`Trial until ${new Date(lic.trialEndsAt).toLocaleDateString()}`);
KeyTypeDescription
licensedbooleantrue if bought (including a license given by PodoLay) or the plugin is free, false during a trial
sourcestringfree (free plugin) · purchase (bought) · grant (given by PodoLay) · trial (on a trial) · dev (development mode)
trialEndsAtstring · nullWhen the trial ends (ISO) — if a trial was ever started
  • Paid plugins are sold by PodoLay at a price tier ($10 up to, but not including, $1,000). One purchase gives the account a permanent license — upgrading to new versions costs nothing more, and it works regardless of plan (even on the Free plan). If the plugin has trial days (0–30, 0 = no trial), it can be used for that long from the first install (once per account).
  • The editor loads a paid plugin only for people who bought it or are on a trial — if the plugin is running, it may be used. Use this value for what to show, such as a trial notice. When the trial ends the plugin is no longer loaded from the next time the editor opens, and objects already made stay as their saved fallback images.
  • As of 2026-10, only plugins made by PodoLay can be sold (the price row in the developer console). Sales by other developers open after confirmation with the payment provider.
  • In development mode and the sandbox it is always { licensed: true, source: 'dev' }. In the mock editor, change it with createHarness(m, { license }) or h.license.

13. Uploading images — podolay.assets

assets.upload(bytes, opts)

1.1 🔑 document:write → { asset: string, width: number, height: number, sha256?: string, reused?: boolean }

const res = await fetch('https://cdn.example.com/p/1.jpg');                 // network permission
const { asset } = await podolay.assets.upload(await res.arrayBuffer(), { name: 'p1', type: 'image/jpeg' });
await podolay.edit('Photo', tx => tx.setImage(frameId, asset));
ArgumentDescription
bytesArrayBuffer · Uint8Array · Blob — up to 30 MB
opts.typeimage/png · image/jpeg · image/gif · image/tiff · image/webp (converted to PNG)
opts.nameFile name (without extension, 80 characters) — shown in the gallery
opts.reuse1.6 'name' = if the document has an asset with the same file name and the same byte size, use it instead of uploading · false (default)
  • Creates an image asset of this document under the user's account — the plan's storage limit and the server's format checks are the same as for a normal upload, and the original goes to print unchanged.
  • Use the returned asset with tx.setImage or tx.create({ kind: 'image' }). width and height are the original's pixel dimensions.
  • Images drawn with OffscreenCanvas can be uploaded too, via canvas.convertToBlob().
  • 1.6 The same content isn't uploaded twice — the result carries the content hash sha256 (hex) and reused. If the same content was already uploaded in this window (while the editor is open), that asset is returned (reused: true). reuse: 'name' looks for an asset with the same name (name + extension) and the same size among the document's assets on the server — for running the same photo folder again on a reopened document. To deduplicate by content across windows, keep a sha256 → asset map in the document data (tx.setDocData) and look it up first — server assets have no hash.
    const known = (await podolay.doc.getData())?.hashes ?? {};           // { sha256: asset }
    const used = new Set((await podolay.assets.list()).map((a) => a.asset));
    for (const f of files) {
      const hash = await sha256(f.bytes);                                  // compute it yourself before uploading (sha256 below)
      let asset = known[hash];
      if (!asset || !used.has(asset)) ({ asset } = await podolay.assets.upload(f.bytes, { name: f.name.replace(/\.[^.]+$/, ''), type: f.type, reuse: 'name' }));
      known[hash] = asset;
    }
    await podolay.edit('Photo map', (tx) => tx.setDocData({ hashes: known }));
    
    async function sha256(bytes) {                                         // the same hex as the upload result's sha256
      const d = await crypto.subtle.digest('SHA-256', bytes);
      return [...new Uint8Array(d)].map((b) => b.toString(16).padStart(2, '0')).join('');
    }
    crypto.subtle exists from editor 2.14.14 (digest only) — to support older editors, set minEditor: "2.14.14" or compute the hash in plain JS.

assets.list(q)

1.6 🔑 document:read → AssetInfo[] — this document's image assets (the document's image assets on the server, up to 5000).

const all = await podolay.assets.list();                  // [{ asset, name, width, height, size, used }, …]
const one = await podolay.assets.list({ name: 'a.jpg' });  // only that file name
KeyMeaning
assetAsset path — use it with tx.setImage and tx.create({ kind: 'image' })
nameFile name (with extension) — q.name matches it exactly
width · heightOriginal pixels
sizeSize in bytes (null if unknown)
usedWhether the document currently uses it
  • Assets in the list can be used by tx in this window (even if you didn't upload them).
  • The sandbox editor has no server, so it lists only what was uploaded in this window and what the document uses.

14. Package files — podolay.package

1.4 🔓 — reads files inside the plugin package. Keep large data such as dictionaries and conversion tables out of main.js and read it when needed.

const table = await podolay.package.json('data/ipa.json');                    // → any
const words = await podolay.package.text('data/words.tsv');                   // → string (UTF-8)
const bytes = new Uint8Array(await podolay.package.read('data/dict.bin'));    // → ArrayBuffer
RuleDetails
PathA relative path inside the package (data/x.json; a leading ./ is fine). No leading /, .. or \; up to 200 characters
SizeUp to 20 MB per file (the whole package: 20 MB zip, 40 MB unpacked)
File typesOnly what a package may hold — js json html css svg png jpg jpeg webp woff2 txt md bin csv tsv (bin · csv · tsv were added with 1.4)
Development modeThe dev server serves them straight from the plugin folder — edited files apply from the next call
Mock editorReads from the plugin folder (the folder given to loadPlugin) by the same rules
  • Code can't be imported — the worker loads only main.js and the CSP blocks other scripts. Bundle code into main.js with a build step and read only data separately.
  • Keep what you read inside the plugin so it isn't read again — let table; … table ??= await podolay.package.json('data/ipa.json');.

15. Network — fetch · WebSocket · EventSource

These three exist only if the manifest has a network:<host> permission (otherwise they're undefined).

"permissions": ["network:api.example.com", "network:*.cdn.example.com", "network:api.example.com:8443"],
"privacyUrl": "https://example.com/privacy"
RuleDetails
HostsPublic names only — no IP addresses, localhost or private names (.local, .internal, etc.). *. covers one leading level only; broad patterns like *.com aren't allowed. Up to 10
Schemeshttps and wss only (development mode also allows http://localhost and 127.0.0.1)
RequestsNo cookies, no referrer, no cache. Origin is null — **the remote server must allow * or null via CORS**
URLsAbsolute URLs only. For a URL that isn't allowed, fetch rejects and WebSocket/EventSource throw a SecurityError
LoggingEach request is logged in the log window as [network] GET host → status (paths and bodies are never logged)
Never presentXMLHttpRequest, importScripts and creating workers — never available (you can't download and run code)
PanelsPanels have no network access — main.js fetches the data and passes it to the panel
DatabasesThis is browser code, so it can't connect to a database directly — put an HTTP API in front of the database

16. Data types

16.1 Colors

Writing (PodolayColor)Meaning
'#rgb' · '#rrggbb'RGB
{ cmyk: [c, m, y, k] } (0–100)CMYK — goes to print as these ink values
{ gray: k } (0–100)K ink only

When reading (Graphic.fill, etc.), values may also be spot colors { spot: 'name', tint: 0–100, rgb: '#rrggbb' }, or null if there's no fill.

16.2 Localized strings

Either a single string (for all languages) or { "en": "…", "ko": "…" }. The editor uses the value for the UI language, falling back to en, and then to any value. Each value is up to 200 characters (2000 for descriptions).

16.3 Local names · Frame — rectangles

  • Local name: starts with an ASCII letter, [A-Za-z0-9_-], up to 64 characters (graphic types; tool, command and panel ids; Inspector keys).
  • Frame: { x, y, width, height } in pt. Spread coordinates for objects.

16.4 Graphic — objects

{
  id: 101, kind: 'text',
  frame: { x: 40, y: 60, width: 200, height: 80 },   // spread coordinates (before rotation)
  rotation: 0, skew: 0, opacity: 1, visible: true, locked: false,
  fill: '#ffffff' | {cmyk} | {gray} | {spot, tint, rgb} | null,
  stroke: { color, width } | null,
  pluginData: …,            // this plugin's tag (null if none)
  plugin: { id, type, data? },   // plugin objects only — data only if it's your own plugin's
  text: '…',                // objects with text — the whole text (unformatted)
  image: '/media/…',        // objects with a placed image — its asset path (group children and inline objects too, from editor 2.14.13)
  children: [ … ],          // groups — from doc.graphics and doc.graphic
}

kind = rectangle ellipse polygon line path polyline text titletext (title text) image table group plugin other.

16.5 DocInfo · TextStatus · StyleInfo

§7.1 · §7.9 · §7.6.

16.6 Story — text

'First paragraph\nSecond paragraph'                            // a string = one paragraph per line
[
  { para: 'Title', text: 'Spring collection' },                // a single run
  { para: '$base', runs: [
      { text: 'List price ' },
      { text: '$12.00', char: 'Emphasis', size: 14, color: { cmyk: [0, 100, 100, 0] }, bold: true },
  ] },
]
Paragraph keyDescription
paraParagraph style name or id. The built-in "Basic" style is '$base'
runsArray of runs (up to 1000)
textShorthand for a single run
align1.6 Paragraph alignment — left · center · right · justify · justifyCenter · justifyRight · justifyAll · towardsSpine · awayFromSpine
leading1.6 Leading — a multiple of the font size (0–20, e.g. 1.6)
spaceBefore · spaceAfter1.6 Space before and after the paragraph, pt (−5000–5000)
indentLeft · indentRight · indentFirst1.6 Left, right and first-line indent, pt (−5000–5000)
Run keyDescription
textText (line breaks become spaces — split paragraphs with the array)
inline1.2 An inline object — §16.8. Don't use text in this run
charCharacter style name or id. '$base' · '$none'
fontFont name
sizept (0.5–2000)
colorColor
bold · italicboolean
tracking1.6 Tracking — 1/1000 em (−1000–10000)
hScale · vScale1.6 Horizontal and vertical scale, % (1–1000, default 100)
baselineShift1.6 Baseline shift, pt (+ = up, −1000–1000)
underline · strike · smallCaps1.6 Underline · strikethrough · small caps (boolean)
position1.6 'super' (superscript) · 'sub' (subscript) · 'normal'
  • Formatting order: base → paragraph style (+ that paragraph style's character style) → 1.6 the paragraph's direct formatting → the run's character style → the run's direct formatting.
  • 1.6 The paragraph's align … indentFirst and the run's tracking … position are accepted by setText, create({ kind: 'text', story }), appendText, replaceRange ({ runs }, { story }), formatRange (run formatting only) and text.measure. doc.text includes run formatting only when it differs from the default, and paragraph formatting only when it differs from the paragraph style — pass a Story you read back unchanged and the formatting stays.
  • Base: for tx.create, the document's "Basic" style; for tx.setText and text.measure({like}), the first paragraph and first character formatting of that text.
  • An unknown style name makes the whole edit fail (so text is never filled in with the wrong formatting).
  • One Story input can have up to 200,000 characters and 10,000 paragraphs — this is not a limit on the text itself. For longer text, append several times with appendText or copyRange, and when it overflows, thread frames onto new pages with autoflow.
  • Combining marks and phonetic symbols (editor 2.14.7 or later): a combining mark in the text (such as the U+0301 acute accent, Unicode categories Mn and Me) is drawn on top of the preceding letter, at the font's mark position (GPOS) — on screen and in PDF. There is no need to normalize to NFC first; letters with no precomposed form, such as ə́ (U+0259 U+0301) or ɔ́, can be written as they are. Two stacked marks (ế = e U+0302 U+0301) work too. IPA, extended Latin, spacing modifier letters (ː, ˈ), Greek and Cyrillic characters missing from the default font (Noto Sans KR) are drawn with Noto Sans (the same font on screen and in PDF). To use another IPA font, set it with font or a character style — since 2026-10-27 the built-in fonts include the phonetic fonts Charis (SIL; Regular, Italic, Medium, SemiBold, Bold and their italics, 8 faces; named 'Charis SIL' up to version 6) and Doulos SIL (Regular only). Both are serif fonts with the full IPA set and mark positioning (GPOS mark), so a whole transcription is drawn in one font (screen and PDF). The font list comes from the server, but the editor only downloads the font file for text whose font alone was changed from 2.14.10 on — in earlier versions such text is drawn with a fallback font until the font is picked once in the font picker or the layout is reopened (PDF uses the font from the start). Plugins relying on these fonts should declare "minEditor": "2.14.10". In fonts that cannot place marks on Latin letters, such as the default font (Noto Sans KR and other CJK fonts), only the letters followed by a combining mark are drawn with Noto Sans (Noto Serif for serif fonts) — ɑ́, á̀, ṍ (editor 2.14.8 or later). In editor 2.14.6 and earlier the mark is pushed aside or drawn over the next letter, and in 2.14.7 ɑ́ and two stacked marks are still misplaced in the default font — plugins that rely on this notation should declare "minEditor": "2.14.8" in the manifest.

16.7 FindQuery

§7.8.

16.8 Inline — inline objects

1.2 — The object given in a Story run { inline } or to tx.insertInline. It takes one of the forms below.

{ kind: 'image', asset, width: 40, height: 30, fit: 'fit' }            // a new object
{ kind: 'plugin', type: 'qr', data: { url }, width: 24, height: 24 }  // your own graphic (render is called to produce the drawing)
{ graphic: 205 }                                                      // moves an object from the spread into the text
{ index: 1 }                                                          // tx.setText only — keeps inline object #1 of the text as it was before
FormFields
New objectkind (plugin, rectangle, ellipse, line, text, image) + the fields of that kind in tx.create, with width and height (pt, greater than 0 and at most 5000) instead of frame, plus rotation? (degrees) and pluginData? (this plugin's tag — find it again with doc.inlines)
{ graphic: id }Moves an object placed directly on the spread (a group as a whole) — it leaves the spread and loses its id. Locked objects, threaded text frames and the text object itself aren't allowed. Only once per edit. Change its size, fill or tag beforehand with earlier ops in the same edit (tx.set, setPluginData)
{ index }tx.setText only — that inline object of the text before the replacement (just pass back the run returned by doc.text)
  • Inline objects sit in a line like characters — the bottom edge sits on the baseline, and a tall object makes its line taller. An object larger than the column becomes overset text.
  • 1.3 baseline (pt, from 0 up to the height) = the distance from the object's bottom edge up to the text baseline. The object is lowered by that much so the baseline crosses it, and the part below the baseline counts toward the line depth (selection and click area cover the whole object). Use it for formulas with subscripts and denominators. Give it in the new-object form and in tx.setInline.
  • A display list may draw outside the frame and isn't clipped — but selection, click area and line height only count the frame.
  • You can't put another inline object inside the text of an inline text object (kind: 'text').
  • Once placed, the user can double-click an inline object in the editor to edit it in place.

16.9 TextTarget — text targets

1.4 — the address of a text, used by the APIs that take text (doc.text · doc.texts · tx.setText · replaceText · replaceRange · formatRange · 1.6 copyRange · appendText · selection.setText) and by the id of the command's text argument, text.selection and textSelectionChange.

FormText it points to
12A text object id — any frame of a threaded story means the whole story (since 1.1)
{ id: table, cell: [row, col] }A table cell (zero-based; cells covered by a merge are not allowed)
{ id: story, inline: n }An inline text frame — the index of doc.inlines
{ id: story, inline: n, cell: [row, col] }A cell of an inline table
{ id: story, inline: n, path: [child index, …] }1.6 A text frame inside an inline group (§16.10)
{ id: story, inline: n, path, cell: [row, col] }1.6 A cell of a table inside an inline group
  • The object forms need apiVersion 1.4 or later (otherwise the error '… 의 글 자리 { id, cell } · { id, inline } 는 apiVersion 1.4 이상에서 씁니다').
  • Changing the text of an inline object is one Undo step that changes the host story. Locking follows the host story (for a table cell, the table).
  • Inline objects can't be inserted into the text of table cells or inline objects (the editor can't either yet). doc.texts lists every text target.
  • path needs apiVersion 1.6 or later.

16.10 InlineTarget — inline object addresses

1.6 — inline objects have no id, so they are addressed by host story and ordinal. path goes further into the children of an inline group (and of groups inside it).

{ id: 12, inline: 0 }              // inline object #0 of story 12
{ id: 12, inline: 0, path: [1] }   // child #1 of that inline group
{ id: 12, inline: 0, path: [1, 0] }// child #0 of that child (a group)
Accepted byWhat it does
doc.graphicReads the object (no id; group children carry path)
doc.table · tx.insertRows · tx.removeRowsAn inline table
tx.set · tx.setPluginData · tx.setImageChanges the object
Everything that takes a text target (§16.9)A text frame inside an inline group { id, inline, path } · a table cell { id, inline, path, cell }

1.8 Objects in text outside text frames — { in: text target, inline, path? }. in is a text target (a table cell { id, cell }, an inline table cell { id, inline, cell } …), inline is the index from doc.inlines(that text target). Every place in the table above takes it. { id, inline, cell } still means 'a cell of an inline table' — an object inside a cell is { in: { id, cell }, inline }.

{ in: { id: 30, cell: [1, 0] }, inline: 0 }            // inline object #0 in the text of cell [1, 0] of table 30
{ in: { id: 30, cell: [1, 0] }, inline: 0, path: [0] } // child #0 of that inline group (the picture frame)
  • inline = the index of doc.inlines; path = use the path that doc.inlines and doc.graphic put on group children (order inside the group, zero-based, up to 32 levels).
  • The editor builds the object from the host story's anchor data, changes it, and writes it back with one command that changes the host story — one Undo step; locking follows the host story. Changing the size re-measures the group frame and the inline slot, and the text reflows.
  • Ordinals count in the text as changed by earlier ops of the same edit (inserting or removing objects before it shifts them).
  • Text frames inside a group moved into text with { graphic: id } lose their ids, so use this address for them.
  • Inline object data in the document JSON (sandbox raw) also holds a numeric id, but nothing uses it — the editor builds the object anew from the data on every layout. Inline objects duplicated with tx.copyRange may carry the same id as the original; that's fine.

17. Display list

The drawing returned by render and by tool previews. render uses object-local coordinates (origin = the frame's top-left); tool previews use spread coordinates.

17.1 Items

tFieldsFill · stroke
rectx, y, w, hfill stroke opacity
ellipsecx, cy, rx, ryfill stroke opacity
linex1, y1, x2, y2, stroke (required)opacity
pathd (SVG path), rule?: 'nonzero'·'evenodd'fill stroke opacity
groupitemsopacity (multiplied with the items' opacity)
useref (definition name), x?, y?, scale? or m?: [a, b, c, d, e, f] 1.3fill stroke opacity (see 16.4)
[
  { t: 'rect', x: 0, y: 0, w: 100, h: 50, fill: '#ff0000', stroke: { color: { cmyk: [0, 0, 0, 100] }, width: 1, dash: [3, 2] } },
  { t: 'ellipse', cx: 50, cy: 25, rx: 20, ry: 10, fill: { gray: 30 } },
  { t: 'line', x1: 0, y1: 0, x2: 100, y2: 50, stroke: { color: '#000', width: 0.5, cap: 'round' } },
  { t: 'path', d: 'M0 0 L10 0 Q20 5 10 10 Z', fill: '#09f', rule: 'evenodd' },
  { t: 'group', opacity: 0.5, items: [ … ] },
]

17.2 Fill and stroke

KeyType
fillColor — open paths are not filled
stroke{ color, width, cap?: 'butt'·'round'·'square', join?: 'miter'·'round'·'bevel', dash?: [number…] (up to 16) }
opacity0–1

17.3 Path d

SVG path commands M L H V C S Q T A Z (uppercase = absolute, lowercase = relative). Q and T are converted to equivalent cubic curves, and arcs (A) to a series of cubic curves (compact flags are fine: a5 5 0 011 1). Each M starts a new subpath.

17.4 Reusable definitions (defs · use)

1.3 — To draw the same shape many times (e.g. glyph outlines in a formula), return { items, defs }. defs maps a name to an array of items, and { t: 'use', ref } places it (like SVG <symbol> / <use>). The drawing is stored in this compact form, so documents stay small.

  • Names: start with a letter; letters, digits, _, -; up to 32 characters.
  • Transform: x, y (translate) and scale (> 0, uniform), or a 2×3 matrix m (x' = a·x + c·y + e, y' = b·x + d·y + f). m cannot be combined with x/y/scale.
  • Paint: only path/rect/ellipse items in the definition that have neither fill nor stroke take the use's fill/stroke; items with their own paint keep it. opacity is multiplied.
  • Stroke width: a stroke given on use is measured where the use sits (its own scale/m is not applied). Strokes of items inside the definition scale with the transform.
  • Definitions may use other definitions (up to 4 levels). Cycles are rejected.
  • Editors older than 2.13 can't draw a drawing containing use (the object stays and shows as an empty box) — they don't load 1.3 plugins anyway.
{
  defs: { x: [{ t: 'path', d: 'M0 0 L6 0 L6 8 Z' }] },
  items: [
    { t: 'use', ref: 'x', x: 10, y: 20, fill: '#000' },
    { t: 'use', ref: 'x', x: 30, y: 20, scale: 0.7, fill: { cmyk: [0, 100, 0, 0] } },
  ],
}

17.5 Limits

ItemLimit
Number of items (incl. groups and definitions; a use counts as 1)5000
Group nesting depth8
d of a single path200,000 characters
Total of all path ds1,000,000 characters
Number of dash values16
Number of definitions (defs)1000
use nesting4 levels
Shapes after expanding all uses50,000
Total path d after expanding all uses5,000,000 characters

Definition paths are counted once (the 1,000,000-character total is measured on the stored drawing). A drawing that exceeds a limit or is malformed is rejected, and the reason is logged.

17.6 In other outputs

WhereThe display list
Screen · reader modeThe saved drawing as is (stretched if the size has changed)
PDFThe same drawing as vector paths — colors are printed as specified (CMYK, K)
IDML (InDesign)As a group of paths. InDesign has no even-odd fill, so holes may be filled in

18. Limits at a glance

ItemLimit
Answer to a single call (render, tool, event, panel message)8 s
A single command (commands)60 s (1.6 not a failure while that plugin's batch is running — nor is the 8 s for panel messages)
A whole batch · editor-side API callsNo time limit
Plugin start (top level of main.js)8 s
Hang detection (liveness check after a timeout)2 s more — turned off if there's no answer
Finishing a batch after cancel5 s
A single message4 million characters (1.6 ArrayBuffers don't count — only memory)
Ops per edit2000
data · pluginData · setDocData value256 KB JSON
storage64 KB JSON
assets.upload30 MB
Story (one input — no limit on the text itself)200,000 characters · 10,000 paragraphs · 1000 runs per paragraph
Inline object size · one insertInline op5000 pt · 1000 objects
doc.find resultsDefault 1000, max 5000
selection.set1000
New pages per edit1000 (plus the plan's page limit)
addPages.count · duplicatePages.times200 · 500
Network hosts10
Tools · panels · other contributes10 · 10 · 50
Package zip20 MB · 500 files · 40 MB unpacked (code, guide and samples together)
package.read20 MB per file · 200-character path
doc.texts results20,000
One ui.pickFiles5000 files · 1 GB in total
assets.list results5000
insertRows.count · autoflow.maxPages1000 · 1000 (default 100)
Screen redraws inside a batchOnce every 250 ms
Plugins per developer · versions per plugin50 · 100

19. Errors and timeouts

  • When an editor call fails, it rejects with an Error. Messages are in Korean and say what was wrong and why, for example 권한이 없습니다: document:write (매니페스트 permissions 에 넣으세요) ("permission missing: document:write — add it to the manifest's permissions"), 잠긴 개체는 지울 수 없습니다: 101 ("locked objects can't be deleted: 101"), 단락 스타일이 없습니다: 제목 ("no such paragraph style: 제목").
  • edit and batch are all or nothing.
  • Timeouts: when the editor calls the plugin (render, tools, commands, events, panel messages) and gets no answer in time, only that call is treated as failed. The editor then asks "are you alive?", and if there's no answer within 2 seconds — meaning synchronous code is blocking — it turns the plugin off and notifies the user (reopen the editor to turn it back on). Split heavy computation into small pieces with awaits in between. 1.6 Commands and panel messages keep waiting past their time while that plugin's batch is running, instead of failing (the liveness check continues). A batch itself and editor-side API calls have no time limit.
  • Error locations: errors from plugin code show the first main.js location — command boom: TypeError: … (com.example.qr/main.js:5:12), Uncaught ReferenceError: … (com.example.qr/main.js:8). Syntax errors appear as start failures with (…/main.js:line).
  • Log window: in development mode, editor › Plugins menu › Log: plugin name. It collects console.* output, uncaught errors, failed calls and network requests (the same content also goes to the browser console).
  • Developer tools: plugin code runs in a worker inside a sandboxed iframe. In Chrome DevTools, find <plugin id>/main.js under Sources to set breakpoints, and switch the Console's execution context to that worker to call podolay directly (await podolay.doc.info()). Panels are separate iframes.
  • Checking without the editor: node tools.mjs check <folder> — checks the manifest, definitions, render and permissions using the editor's rules. Logs are also printed in the development server's terminal, and the editor reloads the plugin when you change a file.
  • Mock editor: unit-test commands, edit and batch in Node with the SDK's podolay-mock.mjs (it emulates the rules in this document; text composition is approximate).
  • Sandbox editor: node tools.mjs dev <folder> --open — opens the real editor without signing in, on a new document that saves nothing to the server. Drive it from another terminal with tools.mjs run <command>, state, shot <page> (PNG) and test <folder> (the same test.mjs, in the real editor).
  • For the full debugging guide (checks, terminal logs, the log window, developer tools, common errors and how to fix them, the mock editor), see README.md §8 Debugging.

20. Changes between versions

VersionAdded
1.0Graphics, tools, commands, shortcuts, panels, Inspector fields, doc.info/graphics/graphic/raw/getData, selection, edit (create · set · remove · setData · setPluginData · setDocData), ui, storage, events, display lists
1.1Network permissions (network:), Story text (doc.styles/text/find, tx.create text story · setText · replaceText), images (assets.upload, tx.create image · setImage), pages (addPages · duplicatePages · removePages · applyMaster, master · label · masters in doc.info), text flow (link · unlink · doc.textStatus · text.measure), batch · ui.progress, results in the edit result, arc (A) curves
1.2Inline objects (inline anchors): Story run { inline }, tx.insertInline · removeInline, doc.inlines, object runs in doc.text; context menus: manifest menus[].context (header · object · text · canvas) · when (kind · type · single), the arg argument of commands invoked from a context menu. Added in editor 2.13 (same API version): the inline-object context menu with arg.inline, selectionChange.inline, documentChange.origins, the dataChange event
1.3Natural size (graphics[].autoSize · size(data, frame)), Inspector field options live · hideInline, tx.setInline · tx.replaceRange, baseline of inline objects, defs · use in display lists
1.4Commands while editing text (header menus and shortcuts also get the text argument; editing reopens afterwards), text.selection, the textSelectionChange event, selection.setText, the ctrl shortcut modifier (⌃ on Mac), tx.formatRange and { runs } in tx.replaceRange, text targets (table cells { id, cell }, inline objects { id, inline }) and doc.texts, podolay.package.read · text · json (package file types gain bin · csv · tsv). Also in editor 2.14.7 (independent of the API version): combining marks drawn on top of the preceding letter; IPA, extended Latin, Greek and Cyrillic characters missing from the default font drawn with Noto Sans — §16.6
1.5podolay.license.status() — licenses of paid plugins (price tiers, trials, permanent licenses, independent of plan) (editor 2.14.12 or later)
1.6For database publishing (editor 2.14.12 or later): tx.copyRange (duplicate text with its formatting), tx.appendText and { story } in tx.replaceRange (insert whole paragraphs), inline object addresses { id, inline, path? } (tx.set · setPluginData · setImage · doc.graphic · doc.table · text targets; path on group children in doc.inlines and doc.graphic), tables doc.table · tx.insertRows · tx.removeRows, tx.autoflow, tx.duplicate, podolay.ui.pickFiles and podolayUI.postMessage(msg, transfer) in panels, assets.list and reuse · sha256 · reused in assets.upload, Story run formatting (tracking · hScale · vScale · baselineShift · underline · strike · position · smallCaps) and paragraph formatting (align · leading · spaceBefore · spaceAfter · indent*). Editor side (independent of the API version): no command or panel-message timeout while a batch runs, screen redraws once every 250 ms inside a batch, copies of duplicatePages include group children
1.7Table and cell styles: doc.tableStyles(), tableStyle · cellStyles in doc.table, tx.setTableStyle · tx.setCellStyle (editor 2.16.2 or later)
1.9Table cell text takes inline objects too — when the text target is a table cell ({ id, cell }, { id|in, inline, path?, cell }), inline runs in setText, appendText and replaceRange ({ story }, { inline }) and ranges with inline objects in copyRange go in. Text frames inside inline objects still can't. An object wider than the cell's text width doesn't fit on a line (editor 2.18.0 or later)
1.8Inline objects in table cells { in: text target, inline, path? } · doc.inlines(text target), tx.mergeCells · tx.unmergeCells, copyFrom.table in insertRows (from another table, with cell borders), manifest dataFrom (pluginDataFrom · doc.getData(from) · doc.find({ from }) · storage.getFrom) (editor 2.17.2 or later). Editor side (any version): doc.texts also lists text frames inside inline groups as { id, inline, path }, development log lines up to 200,000 characters (longer ones end in …[+N chars])

The API is additive only — 1.0 plugins run unchanged in a 1.1 editor. An older editor won't load a plugin that uses 1.1 features (judged by the manifest's apiVersion). If you need a specific editor feature, set minEditor.