InDesign ExtendScript Runner
Automate Adobe InDesign on macOS by generating an ExtendScript (.jsx) file and
executing it inside a running InDesign application via AppleScript.
InDesign ExtendScript is ECMAScript 3 (old JavaScript) plus an InDesign-specific
DOM and Adobe's File/Folder/$ host objects. It is not Node.js or modern JS —
no let/const/arrow functions/JSON.parse/template literals. Use var, classic
function expressions, and the idioms shown below.
Prerequisites
- macOS with Adobe InDesign installed (e.g. "Adobe InDesign 2026").
- The script runs against the currently running InDesign. Many scripts assume an
open document via
app.activeDocument — make sure InDesign is open with the
relevant document, or have the script create one with app.documents.add(...).
How to run a script
- Write the ExtendScript to a local file, e.g.
/tmp/indesign-script.jsx.
- Execute it with
osascript, telling InDesign to do script ... language javascript:
osascript -e 'with timeout of 300 seconds
tell application "Adobe InDesign 2026"
do script (POSIX file "/tmp/indesign-script.jsx") language javascript
end tell
end timeout'
- Replace
2026 with the installed version year the user has (2024, 2025, 2026, …).
Default to 2026 unless told otherwise.
- Always pass an absolute POSIX path to the
.jsx file.
- Always wrap in
with timeout of N seconds … end timeout. The default Apple event
timeout is ~60s; longer scripts (enumerating app.fonts, placing images, building
tables, exporting) fail with "AppleEvent timed out. (-1712)" without it. Verified:
a font-enumeration script timed out at the default and succeeded with with timeout.
stdout of osascript is the value of the last evaluated expression in the
.jsx. That is why the scripts below end with logs.join('\n') — it returns the
collected log lines to the shell so you can read the result.
A reusable one-liner (adjust version + path):
JSX=/tmp/indesign-script.jsx VER=2026
osascript -e "with timeout of 300 seconds
tell application \"Adobe InDesign $VER\"
do script (POSIX file \"$JSX\") language javascript
end tell
end timeout"
If the file does not exist, write it first.
Launch InDesign before running the tell block. AppleScript resolves the
do script terminology from InDesign's scripting dictionary at compile time, so if
the app is not already running the script fails to compile with a misleading
"Expected end of line but found "script". (-2741)" error (it is not really a syntax
error). Start it first and wait until it registers, then run:
open -a "Adobe InDesign 2026"
# wait until the process is registered before sending the Apple event:
until osascript -e 'tell application "System Events" to (name of processes) contains "Adobe InDesign 2026"' | grep -q true; do sleep 1; done
The first time, the user may need to grant the terminal Automation/Accessibility
permission for InDesign. Note also that sending Apple events to InDesign is a
cross-application action — if you run inside a command sandbox that blocks Apple
events, the osascript call must be allowed to run outside it.
Tip — put the AppleScript in a file, not in -e. Quoting the multi-line tell
block through repeated -e flags is fragile; writing it to a .applescript file and
running osascript /path/to/run.applescript avoids shell-quoting breakage (verified —
the -e form mis-parsed, the file form worked).
Writing ExtendScript: core idioms
Start every script with the target directive and a console.log shim that both prints
(to the ExtendScript console) and accumulates output to return to the shell:
//@target InDesign
var logs = [];
var console = {};
console.log = function(v){
$.writeln(v); // ExtendScript console
logs.push(v); // collected for the return value
};
// ... your work ...
console.log('OK');
logs.join('\n'); // LAST expression -> becomes osascript stdout
Key InDesign / ExtendScript specifics:
- Measurements are strings with units:
'210mm', '10mm', 36 (points for type).
geometricBounds is [top, left, bottom, right].
- Create a document:
app.documents.add({ documentPreferences: { pageWidth:'210mm', pageHeight:'297mm', facingPages:false } })
- Active document:
app.activeDocument. Collections (doc.fonts, doc.pages,
page.textFrames, doc.paragraphStyles, doc.swatches, …) are accessed by
.item(n), [i], or .itemByName(name), and have a .length.
- Enums are global, e.g.
SaveOptions.no, ExportFormat.PDF_TYPE,
PNGColorSpaceEnum.RGB, PNGExportRangeEnum.EXPORT_ALL.
- Export: set
app.<format>ExportPreferences.properties = {...} then
doc.exportFile(ExportFormat.PNG_FORMAT, File('/abs/out.png')).
- Close without saving:
doc.close(SaveOptions.no).
- File I/O uses Adobe's
File object (not fs):var file = new File('/abs/path.txt');
file.encoding = 'UTF-8';
if (file.open('w')) { file.write(text); file.close(); } // write
if (file.open('r')) { var text = file.read(); file.close(); } // read
- Current script directory:
File($.fileName).parent (has .fullName).
- Parse JSON (no
JSON.parse in ES3): eval('(' + jsonString + ');').
- Iterate with classic
for loops; there is no forEach. A common helper:var eachItem = function(list, fn){ for(var i=0;i<list.length;i++){ fn(list[i]); } };
Non-ASCII / CJK (Japanese etc.) content — important
Multibyte string literals embedded in the .jsx source can get corrupted depending
on how InDesign reads the file. The robust rule, verified building a Japanese document:
- Keep the
.jsx source pure ASCII. Do not paste Japanese (or any non-ASCII) text,
or Japanese font names, directly into the script.
- Read non-ASCII content from UTF-8 files at runtime with the Adobe
File API
(file.encoding = 'UTF-8'; file.open('r'); file.read()). Assigning that read-in
string to frame.contents / cell.contents renders correctly.
- Data-driven CJK (tables etc.): do the formatting (joining arrays, currency,
labels) in a non-JSX language and write a UTF-8 file of ready-to-render strings,
then have the
.jsx just read the pairs. E.g. Python turns
mentai-ficelle.json into {"rows":[["価格","¥380(税込)"], …]}; the script
eval('(' + read + ')')s it and fills cells. Keeps every Japanese byte out of the
.jsx source (verified building the spec table).
- Reference fonts by their ASCII PostScript name, not by their Japanese display
name. Look up the
Font object and assign it:var findFont = function(ps){
for (var i=0;i<app.fonts.length;i++){ if(app.fonts[i].postscriptName===ps) return app.fonts[i]; }
return null;
};
paraStyle.appliedFont = findFont('HiraMinProN-W3'); // assign the Font object
Japanese fonts confirmed available on this macOS + InDesign 2026 (PostScript names):
| Use |
PostScript name |
Display name |
| Mincho (serif) body |
HiraMinProN-W3, HiraMinProN-W6 |
ヒラギノ明朝 ProN |
| Gothic (sans) heading |
HiraKakuProN-W3, HiraKakuProN-W6 |
ヒラギノ角ゴ ProN |
| Adobe-bundled Mincho/Gothic |
KozMinPr6N-Regular, KozGoPr6N-Regular |
小塚明朝 / 小塚ゴシック Pr6N |
To discover fonts, enumerate app.fonts and read .name (family⇥style), .postscriptName,
.fontFamily, .fontStyleName (wrap the run in with timeout, the list is large).
Vertical Japanese text (縦書き / vertical-rl)
Vertical writing is set per story through storyPreferences.storyOrientation —
not storyDirection. Confusing the two is the single biggest time-sink here; they
are different axes:
storyOrientation = the writing axis: HorizontalOrVertical.HORIZONTAL
(default) vs HorizontalOrVertical.VERTICAL (縦書き). This is what makes text run
top-to-bottom. The enum object is HorizontalOrVertical (alias
StoryHorizontalOrVertical), members HORIZONTAL / VERTICAL.
storyDirection = the bidi reading direction (for Hebrew/Arabic), enum
StoryDirectionOptions with only LEFT_TO_RIGHT_DIRECTION,
RIGHT_TO_LEFT_DIRECTION, UNKNOWN_DIRECTION. It has no top-to-bottom value —
StoryDirectionOptions.TOP_TO_BOTTOM_DIRECTION does not exist and throws "Object does
not support the property or method … (55)". For Japanese 縦書き leave storyDirection
at its default (LeftToRightDirection); the right-to-left column flow comes for
free from vertical orientation.
// make a frame's text vertical — set it on the frame's STORY, not the frame
tf.parentStory.storyPreferences.storyOrientation = HorizontalOrVertical.VERTICAL;
doc.recompose();
- Set it on the story. Each unthreaded frame is its own story, so set
frame.parentStory.storyPreferences.storyOrientation on every frame you want
vertical. To make new frames inherit it, flip the document default
doc.storyPreferences.storyOrientation before creating them.
- Assign the named enum, not a bare integer.
storyDirection = 2 (or any int) throws
"値が無効です … 値 2 を受け取りました". (The value is stored as a 4-char code —
storyOrientation reads back 1752134266 = "horz" or 1986359924 = "vert" — but
always write the enum, e.g. HorizontalOrVertical.VERTICAL.)
vertical-rl falls out for free. In a vertical story a single-column frame
(textColumnCount:1) runs each line top→bottom and wraps the next line to the left —
i.e. native right-to-left column order; no RTL flag needed. For a band of running
vertical text use a wide, short frame: it yields many vertical lines reading
right-to-left. A vertical title down the right edge is just a tall, narrow
vertical frame at the right of the page. (Verified building pokemon_b.indd: vertical
title on the right edge; three stacked wide/short bands of right-to-left vertical body
text; images on the left.)
- Headings inside vertical text: prefer
psHeading.spanColumnType = SpanColumnTypeOptions.SINGLE_COLUMN so a heading sits
inline as a vertical run — SPAN_COLUMNS makes it span the vertical height and reads
oddly.
- Finding the right property/enum (reusable technique). When a guessed name throws
(55), reflect instead of guessing again:
tf.parentStory.storyPreferences.reflect.properties
listed both storyOrientation and storyDirection (revealing they are separate), and
SomeEnum.reflect.properties lists an enum's valid members. Reading the current value
(the 4-char int) then probing candidate enum names (eval('HorizontalOrVertical')…)
confirmed VERTICAL. This reflect-first loop is the fastest way out of "name throws 55".
- Verify vertical output visually: export PNG and read it back (see below). A
center crop is usually enough to confirm right-to-left order and that headings are
vertical — note
sips -c H W crops centered (its --cropOffset is unreliable) and
PIL/ImageMagick may be absent, so don't rely on cropping an exact top-left region.
Layout building blocks (verified)
- Multi-column text frame:
tf.textFramePreferences.properties = { textColumnCount: 3, textColumnGutter: '6mm' };
- Heading that spans all columns: set it on the paragraph style:
paraStyle.spanColumnType = SpanColumnTypeOptions.SPAN_COLUMNS;
- Flow text + apply styles by paragraph: join lines with
'\r' (the paragraph
separator), assign to tf.contents, then loop tf.parentStory.paragraphs[i] and set
.appliedParagraphStyle. Paragraph count equals the number of joined lines, so keep a
parallel array of styles. Use empty entries ('') as placeholders for objects you
will anchor later — they become empty paragraphs you can target by index.
- Make a paragraph style's font actually take effect — clear character overrides.
Applying
text.appliedParagraphStyle = ps does not remove existing
character-level formatting, so a font baked onto a run (e.g. from earlier editing or an
imported IDML) survives and the style's appliedFont appears ignored. Clear it:text.appliedParagraphStyle = ps;
text.clearOverrides(OverrideType.CHARACTER_ONLY); // now the style's font wins
Re-apply any intentional local emphasis (bold run, etc.) after clearing (verified:
this is what made a Yu-Gothic body style override an old Hiragino run).
- Anchored inline image (lands in the text flow / column):
var ip = story.paragraphs[idx].insertionPoints[0];
var frame = ip.textFrames.add();
frame.geometricBounds = ['0mm','0mm', h+'mm', w+'mm'];
frame.contentType = ContentType.graphicType;
frame.place(File('/abs/image.png'));
frame.fit(FitOptions.PROPORTIONALLY); // scale to frame, keep aspect
frame.fit(FitOptions.FRAME_TO_CONTENT); // shrink frame to the scaled image
By default the placed frame is anchored as an inline character on the text line.
An inline image taller than the line's leading visually overlaps the surrounding
body text (the image overflows its small line box). The fix is Above Line
anchoring — it puts the image on its own line and reserves vertical space, so text
flows cleanly above and below (verified: this removed image/text overlap in the
multi-column example). Configure anchoredObjectSettings (all verified):var it = frame.anchoredObjectSettings;
it.anchoredPosition = AnchorPosition.ABOVE_LINE;
it.horizontalAlignment = HorizontalAlignment.RIGHT_ALIGN; // LEFT_ALIGN | CENTER_ALIGN | RIGHT_ALIGN
it.anchorSpaceAbove = 3; // space above, in the document's units
Watch the exact property names — these are easy to get wrong and throw
"Object does not support the property or method … (55)":
- It is
horizontalAlignment, not alignment.
- It is
anchorSpaceAbove, not spaceAbove.
- Other useful
AnchoredObjectSetting members: verticalAlignment,
horizontalReferencePoint, anchorXoffset / anchorYoffset, anchorPoint,
pinPosition, lockPosition. To discover the supported members of any object at
runtime, reflect on it: obj.reflect.properties (each has a .name).
- Inline table at an insertion point:
ip.tables.add({headerRowCount:1, bodyRowCount:n, columnCount:c, width:'85mm'}).
A Table has no .texts — style cells individually via
table.cells.item(k).texts[0].properties = {...} (or loop table.cells). Set insets
with cell.properties = { topInset:1.5, ... }.
- Standalone (placed) table — to drop a table into a specific spot (e.g. a card's
bottom strip), make a text frame and add the table at its insertion point:
var tf = page.textFrames.add();
tf.geometricBounds = ['17mm','1.5mm','29mm','38.5mm'];
tf.textFramePreferences.insetSpacing = [0,0,0,0];
var tbl = tf.insertionPoints[0].tables.add({ bodyRowCount:n, columnCount:2, headerRowCount:0 });
- Per-column width:
tbl.columns.item(0).width = '9mm'; (set each; they should sum to
the table width). Fill cells with tbl.rows.item(i).cells.item(j).contents = '...';.
- Merge cells to span (e.g. a full-width footnote row):
row.cells.item(0).merge(row.cells.item(1));
— after the merge the row has one cell; set its .contents after merging.
- Cell shading + hairline grid (all verified property names): fill a cell with
cell.fillColor = swatch; and set borders per edge —
cell.topEdgeStrokeWeight = 0.25; cell.topEdgeStrokeColor = lineSwatch; (likewise
bottomEdge…, leftEdge…, rightEdge…). Tighten padding with
cell.leftInset/rightInset/topInset/bottomInset.
- Auto-fit a fixed-size table (sibling of the text-frame fit loop): a table can
overflow its frame and individual cells can overset. Shrink the whole table's font
until neither happens, recomposing each step:
function anyOverset(){
if (tf.overflows) return true;
for (var k=0;k<tbl.cells.length;k++){ if (tbl.cells.item(k).overflows) return true; }
return false;
}
var sz = 3.4; // applyFont(sz) sets every cell's pointSize/leading
applyFont(sz); doc.recompose();
while (anyOverset() && sz > 1.8){ sz -= 0.1; applyFont(sz); doc.recompose(); }
(Verified: packed a 7-row spec/allergen table into a 40×30mm card's bottom strip this
way, settling at ~3.4pt with no overset.)
- Measurement units: to avoid ambiguity, set
doc.viewPreferences.horizontalMeasurementUnits = MeasurementUnits.POINTS (and
vertical), use point numbers for type (pointSize, leading, spaceAfter) and
unit strings ('15mm') for geometry / table sizes.
pointSize/leading get silently scaled when the ruler is mm — pin the script
unit. app.scriptPreferences.measurementUnit defaults to AUTO_VALUE, which
follows the document's ruler. If the ruler is millimeters and you assign a bare
number like style.pointSize = 7, InDesign interprets it through that unit and stores
a different size (observed ≈0.71×, e.g. 7 → 4.96pt, 5.5 → 3.90pt). The text
then renders far smaller than intended. Fix (verified): setapp.scriptPreferences.measurementUnit = MeasurementUnits.POINTS;
once at the top, so numeric pointSize/leading are taken as real points — even
while the ruler stays mm for geometry. This was the difference between type coming
out at 2.4pt vs the intended ~4–5pt.
- Fit text to its frame by sizing on
overflows feedback. To fill a fixed frame
with a balanced margin (instead of guessing a size), grow the point size until the
frame just overflows, then back off a notch — calling doc.recompose() after each
change so overflows reflects the new layout:var sz = style.pointSize, g = 0;
doc.recompose();
while (!tf.overflows && sz < 60 && g++ < 400){ sz += 0.25; style.pointSize = sz; style.leading = sz*1.4; doc.recompose(); }
while ( tf.overflows && sz > 2 && g++ < 400){ sz -= 0.25; style.pointSize = sz; style.leading = sz*1.4; doc.recompose(); }
sz -= 0.5; style.pointSize = sz; style.leading = sz*1.4; // small bottom margin
- Check for hidden overset text after filling a fixed frame:
tf.overflows (boolean).
If true, shrink the font, enlarge the frame, or thread to more frames/pages.
Export formats (third arg of ExportFormat)
| Target |
Constant |
| PDF |
ExportFormat.PDF_TYPE |
| IDML |
ExportFormat.INDESIGN_MARKUP |
| PNG / JPG / EPS |
ExportFormat.PNG_FORMAT / JPG / EPS_TYPE |
Save a native .indd with doc.save(new File('/abs/out.indd')). Verified end-to-end:
a 3-column A4-landscape Japanese document with an inline image and a CSV-driven table was
built and exported to .indd, .idml, and .pdf in one run (see
scripts/20_build-multicolumn-doc-from-md-and-csv.jsx).
Detecting & fixing overset (hidden) text — verified
Overset text is content that does not fit its container and so is invisible in the
output (a table cell or text frame just looks blank). ExtendScript can detect it:
- Text frame / story:
textFrame.overflows (Boolean).
- Table cell:
cell.overflows (Boolean). Scan a whole table with
table.cells.everyItem() (loop table.cells.item(i)), or one column via
table.columns.item(c).cells. A cell's name is "column:row".
- Gotcha: an overset cell's
.contents returns "" even though text is
there — so detect with .overflows, never by testing for empty content.
Why a cell oversets: an unbreakable Latin token (a filename like chimchar.png,
a URL, a long English word) cannot wrap, so if it is wider than the column it spills out
and the cell renders blank. CJK (Japanese/Chinese) text breaks between characters, so it
wraps and grows the row height instead — it rarely oversets. So overset in a mixed table
is usually a too-narrow Latin column.
Fix by widening the column — borrow width from a "safe donor" (the widest column
that does not itself overset after shrinking), keeping the total width constant so the
table still fits its frame column. Call doc.recompose() after every width change —
otherwise .overflows reports the stale pre-change layout and the logic misbehaves
(this was the difference between a diverging and a converging fix):
var colHasOverset = function(t,c){
var cs = t.columns.item(c).cells;
for (var i=0;i<cs.length;i++){ if (cs.item(i).overflows) return true; }
return false;
};
var tableHasOverset = function(t){
for (var c=0;c<t.columnCount;c++){ if (colHasOverset(t,c)) return true; }
return false;
};
var step=2, floor=16, guard=0;
while (tableHasOverset(table) && guard<200){
guard++;
var widenC=-1;
for (var c=0;c<table.columnCount;c++){ if (colHasOverset(table,c)){ widenC=c; break; } }
if (widenC<0) break;
var donor=-1, donorW=-1; // widest column that survives a shrink
for (var c=0;c<table.columnCount;c++){
if (c===widenC || colHasOverset(table,c)) continue;
var col=table.columns.item(c), orig=col.width;
if (orig-step < floor) continue;
col.width = orig-step; doc.recompose(); // tentative
var bad = colHasOverset(table,c);
col.width = orig; doc.recompose(); // revert
if (!bad && orig>donorW){ donorW=orig; donor=c; }
}
if (donor<0) break; // nothing safe to borrow from
table.columns.item(widenC).width += step;
table.columns.item(donor).width -= step;
doc.recompose();
}
Verified: this cleared an overset filename cell in the pokemon table by widening the
image column ~2pt (borrowed from the numeric column) while leaving the English-name
column intact. Alternative fixes if no width can be borrowed: shrink the cell/table font
until overflows is false, enlarge the whole frame, or allow the token to break.
Session control recipes (verified on InDesign 2026 / 21.3)
These cover the essential "drive a live InDesign session" operations. All were tested
against a running Adobe InDesign 2026 on macOS.
- Is there an active document? Accessing
app.activeDocument when nothing is open
throws, so always gate on the count first:if (app.documents.length > 0) {
var doc = app.activeDocument; // safe
}
- Create a document if none is open:
var doc = (app.documents.length > 0)
? app.activeDocument
: app.documents.add({ documentPreferences: { pageWidth:'210mm', pageHeight:'297mm', facingPages:false } });
- Enumerate every open document (when multiple files are open):
for (var i=0; i<app.documents.length; i++) {
var d = app.documents.item(i); // index 0 is the frontmost/active one
console.log(d.name + ' saved=' + d.saved);
}
- Open a specific InDesign file:
var doc = app.open(new File('/abs/file.indd'));
Works for .indd and .idml. The opened doc becomes the active document.
- A crashed/aborted run leaves the doc open and modified in memory — reopen returns
the dirty copy. When an earlier script dies mid-way, the document stays open with its
half-built state; a later
app.open(samePath) hands back that in-memory dirty copy,
not the clean file on disk. During iterative builds this silently feeds you partial
state (observed: a re-harvest read 1 paragraph instead of 15 because a prior run had
already cleared the page before crashing). Start every rebuild by discarding open
copies, then open fresh:while (app.documents.length>0){ app.documents[0].close(SaveOptions.NO); }
var doc = app.open(new File('/abs/base.indd'));
- Save to a specific path:
doc.save(new File('/abs/out.indd'));
- Close:
doc.close(SaveOptions.NO); (or SaveOptions.YES to write changes first).
Letting Claude verify its own work (important)
Export the result to an image/PDF, then read it back:
var doc = app.activeDocument;
doc.exportFile(ExportFormat.PDF_TYPE, new File('/abs/out.pdf')); // PDF
// or PNG (set app.pngExportPreferences.properties first, see 02_export-doc-as-png.jsx)
doc.exportFile(ExportFormat.PNG_FORMAT, new File('/abs/out.png'));
- Prefer PNG/JPG for visual verification. Claude's file reader renders PNG/JPG
natively, so it can see the rendered page and confirm the result. Verified: an
exported PNG was read back and its on-page text was legible.
- PDF needs a renderer. Reading a PDF page requires
poppler (pdftoppm); if it is
not installed, export PNG/JPG instead, or brew install poppler.
Recipes / examples
This skill bundles 50+ working example scripts under scripts/, indexed in
examples-index.json (each entry has title, description, filepath). When a task
matches one of these, read the closest example and adapt it rather than writing from
scratch — they encode the correct InDesign DOM idioms.
Workflow:
- Skim
examples-index.json for a matching title/description.
Read the referenced scripts/*.jsx.
- Adapt paths, sizes, and content for the user's task.
- Write the result to a temp
.jsx and run it with the osascript command above.
- Report the returned stdout (the
logs.join('\n') output) back to the user.
Categories of bundled examples (prefix = rough grouping):
- 00 — inspect / utilities: console logging, current dir, active document filename,
selection info, list fonts (installed or used in doc), close all documents.
- 01 — documents: create a document with a text frame.
- 02 — export: export active/Hello-World doc as PDF (PDF/X-1a), PNG, JPG, EPS.
- 03 — tables: create a table, create + find a table, build a price-list table from JSON.
- 04 — images / PDF placement: place image at page center, into a rectangle, with a
graphic frame, as an inline graphic; place a PDF.
- 05 — IDML: list IDML files, merge IDML files, convert IDML → INDD.
- 06 — file I/O: save/read/parse JSON, save/read/parse TSV text (UTF-8).
- 07 — traversal / linked images: traverse all pages & page items, replace an image.
- 08 — vector drawing: graphic line, polygon, Bézier curves, fractal triangle,
fractal ginkgo leaf.
- 09 — styles & type: read paragraph/character styles, apply paragraph (and
character) styles to "Hello World" text.
- 10 — layers: find an existing layer or create one, assign page items to it.
- 11 — links: check linked-image metadata (paths, status).
- 12 — text frames: rounded corners, center-align contents, inline text frame,
inspect text object properties.
- 13 — pages: create a document with multiple pages.
- 14 — conditional text: read all condition names, delete all conditions.
- 15 — colors / swatches: read all color names, create a custom swatch.
Notes & gotchas
1---2name: indd3description: Run InDesign ExtendScript (.jsx) on macOS by writing a script to a local file and executing it in Adobe InDesign via AppleScript (osascript). Use when the user wants to automate Adobe InDesign — create/modify documents, place images, build tables, export PDF/PNG/JPG/EPS, read fonts/styles/colors, convert IDML, or otherwise drive InDesign from scripts on a Mac.4---56# InDesign ExtendScript Runner78Automate Adobe InDesign on macOS by generating an ExtendScript (`.jsx`) file and9executing it inside a running InDesign application via AppleScript.1011InDesign ExtendScript is **ECMAScript 3 (old JavaScript)** plus an InDesign-specific12DOM and Adobe's `File`/`Folder`/`$` host objects. It is *not* Node.js or modern JS —13no `let`/`const`/arrow functions/`JSON.parse`/template literals. Use `var`, classic14`function` expressions, and the idioms shown below.1516## Prerequisites1718- macOS with Adobe InDesign installed (e.g. "Adobe InDesign 2026").19- The script runs against the **currently running** InDesign. Many scripts assume an20 open document via `app.activeDocument` — make sure InDesign is open with the21 relevant document, or have the script create one with `app.documents.add(...)`.2223## How to run a script24251. Write the ExtendScript to a local file, e.g. `/tmp/indesign-script.jsx`.262. Execute it with `osascript`, telling InDesign to `do script ... language javascript`:2728```bash29osascript -e 'with timeout of 300 seconds30tell application "Adobe InDesign 2026"31do script (POSIX file "/tmp/indesign-script.jsx") language javascript32end tell33end timeout'34```3536- Replace `2026` with the installed version year the user has (2024, 2025, 2026, …).37 Default to **2026** unless told otherwise.38- Always pass an **absolute** POSIX path to the `.jsx` file.39- **Always wrap in `with timeout of N seconds … end timeout`.** The default Apple event40 timeout is ~60s; longer scripts (enumerating `app.fonts`, placing images, building41 tables, exporting) fail with *"AppleEvent timed out. (-1712)"* without it. **Verified:**42 a font-enumeration script timed out at the default and succeeded with `with timeout`.43- `stdout` of `osascript` is **the value of the last evaluated expression** in the44 `.jsx`. That is why the scripts below end with `logs.join('\n')` — it returns the45 collected log lines to the shell so you can read the result.4647A reusable one-liner (adjust version + path):4849```bash50JSX=/tmp/indesign-script.jsx VER=202651osascript -e "with timeout of 300 seconds52tell application \"Adobe InDesign $VER\"53do script (POSIX file \"$JSX\") language javascript54end tell55end timeout"56```5758If the file does not exist, write it first.5960**Launch InDesign *before* running the `tell` block.** AppleScript resolves the61`do script` terminology from InDesign's scripting dictionary at *compile* time, so if62the app is not already running the script fails to compile with a misleading63*"Expected end of line but found "script". (-2741)"* error (it is not really a syntax64error). Start it first and wait until it registers, then run:6566```bash67open -a "Adobe InDesign 2026"68# wait until the process is registered before sending the Apple event:69until osascript -e 'tell application "System Events" to (name of processes) contains "Adobe InDesign 2026"' | grep -q true; do sleep 1; done70```7172The first time, the user may need to grant the terminal **Automation/Accessibility**73permission for InDesign. Note also that sending Apple events to InDesign is a74cross-application action — if you run inside a command sandbox that blocks Apple75events, the `osascript` call must be allowed to run outside it.7677**Tip — put the AppleScript in a file, not in `-e`.** Quoting the multi-line `tell`78block through repeated `-e` flags is fragile; writing it to a `.applescript` file and79running `osascript /path/to/run.applescript` avoids shell-quoting breakage (verified —80the `-e` form mis-parsed, the file form worked).8182## Writing ExtendScript: core idioms8384Start every script with the target directive and a `console.log` shim that both prints85(to the ExtendScript console) and accumulates output to return to the shell:8687```javascript88//@target InDesign8990var logs = [];91var console = {};92console.log = function(v){93 $.writeln(v); // ExtendScript console94 logs.push(v); // collected for the return value95};9697// ... your work ...9899console.log('OK');100logs.join('\n'); // LAST expression -> becomes osascript stdout101```102103Key InDesign / ExtendScript specifics:104105- **Measurements** are strings with units: `'210mm'`, `'10mm'`, `36` (points for type).106- **`geometricBounds`** is `[top, left, bottom, right]`.107- **Create a document:**108 `app.documents.add({ documentPreferences: { pageWidth:'210mm', pageHeight:'297mm', facingPages:false } })`109- **Active document:** `app.activeDocument`. Collections (`doc.fonts`, `doc.pages`,110 `page.textFrames`, `doc.paragraphStyles`, `doc.swatches`, …) are accessed by111 `.item(n)`, `[i]`, or `.itemByName(name)`, and have a `.length`.112- **Enums** are global, e.g. `SaveOptions.no`, `ExportFormat.PDF_TYPE`,113 `PNGColorSpaceEnum.RGB`, `PNGExportRangeEnum.EXPORT_ALL`.114- **Export:** set `app.<format>ExportPreferences.properties = {...}` then115 `doc.exportFile(ExportFormat.PNG_FORMAT, File('/abs/out.png'))`.116- **Close without saving:** `doc.close(SaveOptions.no)`.117- **File I/O** uses Adobe's `File` object (not `fs`):118 ```javascript119 var file = new File('/abs/path.txt');120 file.encoding = 'UTF-8';121 if (file.open('w')) { file.write(text); file.close(); } // write122 if (file.open('r')) { var text = file.read(); file.close(); } // read123 ```124- **Current script directory:** `File($.fileName).parent` (has `.fullName`).125- **Parse JSON** (no `JSON.parse` in ES3): `eval('(' + jsonString + ');')`.126- **Iterate** with classic `for` loops; there is no `forEach`. A common helper:127 ```javascript128 var eachItem = function(list, fn){ for(var i=0;i<list.length;i++){ fn(list[i]); } };129 ```130131## Non-ASCII / CJK (Japanese etc.) content — important132133Multibyte string **literals embedded in the `.jsx` source can get corrupted** depending134on how InDesign reads the file. The robust rule, verified building a Japanese document:1351361. **Keep the `.jsx` source pure ASCII.** Do not paste Japanese (or any non-ASCII) text,137 or Japanese font names, directly into the script.1382. **Read non-ASCII *content* from UTF-8 files at runtime** with the Adobe `File` API139 (`file.encoding = 'UTF-8'; file.open('r'); file.read()`). Assigning that read-in140 string to `frame.contents` / `cell.contents` renders correctly.141 - **Data-driven CJK (tables etc.):** do the *formatting* (joining arrays, currency,142 labels) in a non-JSX language and write a UTF-8 file of ready-to-render strings,143 then have the `.jsx` just read the pairs. E.g. Python turns144 `mentai-ficelle.json` into `{"rows":[["価格","¥380(税込)"], …]}`; the script145 `eval('(' + read + ')')`s it and fills cells. Keeps every Japanese byte out of the146 `.jsx` source (verified building the spec table).1473. **Reference fonts by their ASCII PostScript name**, not by their Japanese display148 name. Look up the `Font` object and assign it:149 ```javascript150 var findFont = function(ps){151 for (var i=0;i<app.fonts.length;i++){ if(app.fonts[i].postscriptName===ps) return app.fonts[i]; }152 return null;153 };154 paraStyle.appliedFont = findFont('HiraMinProN-W3'); // assign the Font object155 ```156157Japanese fonts confirmed available on this macOS + InDesign 2026 (PostScript names):158159| Use | PostScript name | Display name |160|-----|-----------------|--------------|161| Mincho (serif) body | `HiraMinProN-W3`, `HiraMinProN-W6` | ヒラギノ明朝 ProN |162| Gothic (sans) heading | `HiraKakuProN-W3`, `HiraKakuProN-W6` | ヒラギノ角ゴ ProN |163| Adobe-bundled Mincho/Gothic | `KozMinPr6N-Regular`, `KozGoPr6N-Regular` | 小塚明朝 / 小塚ゴシック Pr6N |164165To discover fonts, enumerate `app.fonts` and read `.name` (family⇥style), `.postscriptName`,166`.fontFamily`, `.fontStyleName` (wrap the run in `with timeout`, the list is large).167168## Vertical Japanese text (縦書き / `vertical-rl`)169170Vertical writing is set **per story** through `storyPreferences.storyOrientation` —171**not** `storyDirection`. Confusing the two is the single biggest time-sink here; they172are *different axes*:173174- **`storyOrientation`** = the writing **axis**: `HorizontalOrVertical.HORIZONTAL`175 (default) vs `HorizontalOrVertical.VERTICAL` (縦書き). This is what makes text run176 top-to-bottom. The enum object is `HorizontalOrVertical` (alias177 `StoryHorizontalOrVertical`), members `HORIZONTAL` / `VERTICAL`.178- **`storyDirection`** = the **bidi** reading direction (for Hebrew/Arabic), enum179 `StoryDirectionOptions` with *only* `LEFT_TO_RIGHT_DIRECTION`,180 `RIGHT_TO_LEFT_DIRECTION`, `UNKNOWN_DIRECTION`. **It has no top-to-bottom value** —181 `StoryDirectionOptions.TOP_TO_BOTTOM_DIRECTION` does not exist and throws *"Object does182 not support the property or method … (55)"*. For Japanese 縦書き leave `storyDirection`183 at its default (`LeftToRightDirection`); the right-to-left **column** flow comes for184 free from vertical orientation.185186```javascript187// make a frame's text vertical — set it on the frame's STORY, not the frame188tf.parentStory.storyPreferences.storyOrientation = HorizontalOrVertical.VERTICAL;189doc.recompose();190```191192- **Set it on the story.** Each unthreaded frame is its own story, so set193 `frame.parentStory.storyPreferences.storyOrientation` on *every* frame you want194 vertical. To make new frames inherit it, flip the document default195 `doc.storyPreferences.storyOrientation` *before* creating them.196- **Assign the named enum, not a bare integer.** `storyDirection = 2` (or any int) throws197 *"値が無効です … 値 2 を受け取りました"*. (The value is stored as a 4-char code —198 `storyOrientation` reads back `1752134266` = `"horz"` or `1986359924` = `"vert"` — but199 always write the enum, e.g. `HorizontalOrVertical.VERTICAL`.)200- **`vertical-rl` falls out for free.** In a vertical story a single-column frame201 (`textColumnCount:1`) runs each line top→bottom and wraps the next line to the **left** —202 i.e. native right-to-left column order; no RTL flag needed. For a band of running203 vertical text use a **wide, short** frame: it yields many vertical lines reading204 right-to-left. A **vertical title down the right edge** is just a **tall, narrow**205 vertical frame at the right of the page. (Verified building `pokemon_b.indd`: vertical206 title on the right edge; three stacked wide/short bands of right-to-left vertical body207 text; images on the left.)208- **Headings inside vertical text:** prefer209 `psHeading.spanColumnType = SpanColumnTypeOptions.SINGLE_COLUMN` so a heading sits210 inline as a vertical run — `SPAN_COLUMNS` makes it span the vertical *height* and reads211 oddly.212- **Finding the right property/enum (reusable technique).** When a guessed name throws213 (55), reflect instead of guessing again: `tf.parentStory.storyPreferences.reflect.properties`214 listed both `storyOrientation` and `storyDirection` (revealing they are separate), and215 `SomeEnum.reflect.properties` lists an enum's valid members. Reading the current value216 (the 4-char int) then probing candidate enum names (`eval('HorizontalOrVertical')`…)217 confirmed `VERTICAL`. This reflect-first loop is the fastest way out of "name throws 55".218- **Verify vertical output visually:** export PNG and read it back (see below). A219 **center crop** is usually enough to confirm right-to-left order and that headings are220 vertical — note `sips -c H W` crops **centered** (its `--cropOffset` is unreliable) and221 `PIL`/ImageMagick may be absent, so don't rely on cropping an exact top-left region.222223## Layout building blocks (verified)224225- **Multi-column text frame:**226 ```javascript227 tf.textFramePreferences.properties = { textColumnCount: 3, textColumnGutter: '6mm' };228 ```229- **Heading that spans all columns:** set it on the paragraph style:230 ```javascript231 paraStyle.spanColumnType = SpanColumnTypeOptions.SPAN_COLUMNS;232 ```233- **Flow text + apply styles by paragraph:** join lines with `'\r'` (the paragraph234 separator), assign to `tf.contents`, then loop `tf.parentStory.paragraphs[i]` and set235 `.appliedParagraphStyle`. Paragraph count equals the number of joined lines, so keep a236 parallel array of styles. Use **empty** entries (`''`) as placeholders for objects you237 will anchor later — they become empty paragraphs you can target by index.238- **Make a paragraph style's font actually take effect — clear character overrides.**239 Applying `text.appliedParagraphStyle = ps` does **not** remove existing240 character-level formatting, so a font baked onto a run (e.g. from earlier editing or an241 imported IDML) survives and the style's `appliedFont` appears ignored. Clear it:242 ```javascript243 text.appliedParagraphStyle = ps;244 text.clearOverrides(OverrideType.CHARACTER_ONLY); // now the style's font wins245 ```246 Re-apply any intentional local emphasis (bold run, etc.) *after* clearing (verified:247 this is what made a Yu-Gothic body style override an old Hiragino run).248- **Anchored inline image** (lands in the text flow / column):249 ```javascript250 var ip = story.paragraphs[idx].insertionPoints[0];251 var frame = ip.textFrames.add();252 frame.geometricBounds = ['0mm','0mm', h+'mm', w+'mm'];253 frame.contentType = ContentType.graphicType;254 frame.place(File('/abs/image.png'));255 frame.fit(FitOptions.PROPORTIONALLY); // scale to frame, keep aspect256 frame.fit(FitOptions.FRAME_TO_CONTENT); // shrink frame to the scaled image257 ```258 By default the placed frame is anchored as an **inline** character on the text line.259 An inline image **taller than the line's leading visually overlaps the surrounding260 body text** (the image overflows its small line box). The fix is **Above Line**261 anchoring — it puts the image on its own line and reserves vertical space, so text262 flows cleanly above and below (verified: this removed image/text overlap in the263 multi-column example). Configure `anchoredObjectSettings` (all verified):264 ```javascript265 var it = frame.anchoredObjectSettings;266 it.anchoredPosition = AnchorPosition.ABOVE_LINE;267 it.horizontalAlignment = HorizontalAlignment.RIGHT_ALIGN; // LEFT_ALIGN | CENTER_ALIGN | RIGHT_ALIGN268 it.anchorSpaceAbove = 3; // space above, in the document's units269 ```270 **Watch the exact property names** — these are easy to get wrong and throw271 *"Object does not support the property or method … (55)"*:272 - It is **`horizontalAlignment`**, *not* `alignment`.273 - It is **`anchorSpaceAbove`**, *not* `spaceAbove`.274 - Other useful `AnchoredObjectSetting` members: `verticalAlignment`,275 `horizontalReferencePoint`, `anchorXoffset` / `anchorYoffset`, `anchorPoint`,276 `pinPosition`, `lockPosition`. To discover the supported members of any object at277 runtime, reflect on it: `obj.reflect.properties` (each has a `.name`).278- **Inline table** at an insertion point: `ip.tables.add({headerRowCount:1, bodyRowCount:n, columnCount:c, width:'85mm'})`.279 A **`Table` has no `.texts`** — style cells individually via280 `table.cells.item(k).texts[0].properties = {...}` (or loop `table.cells`). Set insets281 with `cell.properties = { topInset:1.5, ... }`.282- **Standalone (placed) table** — to drop a table into a specific spot (e.g. a card's283 bottom strip), make a text frame and add the table at *its* insertion point:284 ```javascript285 var tf = page.textFrames.add();286 tf.geometricBounds = ['17mm','1.5mm','29mm','38.5mm'];287 tf.textFramePreferences.insetSpacing = [0,0,0,0];288 var tbl = tf.insertionPoints[0].tables.add({ bodyRowCount:n, columnCount:2, headerRowCount:0 });289 ```290- **Per-column width:** `tbl.columns.item(0).width = '9mm';` (set each; they should sum to291 the table width). Fill cells with `tbl.rows.item(i).cells.item(j).contents = '...';`.292- **Merge cells to span** (e.g. a full-width footnote row): `row.cells.item(0).merge(row.cells.item(1));`293 — after the merge the row has **one** cell; set its `.contents` *after* merging.294- **Cell shading + hairline grid** (all verified property names): fill a cell with295 `cell.fillColor = swatch;` and set borders per edge —296 `cell.topEdgeStrokeWeight = 0.25; cell.topEdgeStrokeColor = lineSwatch;` (likewise297 `bottomEdge…`, `leftEdge…`, `rightEdge…`). Tighten padding with298 `cell.leftInset/rightInset/topInset/bottomInset`.299- **Auto-fit a fixed-size table** (sibling of the text-frame fit loop): a table can300 overflow its frame *and* individual cells can overset. Shrink the whole table's font301 until neither happens, recomposing each step:302 ```javascript303 function anyOverset(){304 if (tf.overflows) return true;305 for (var k=0;k<tbl.cells.length;k++){ if (tbl.cells.item(k).overflows) return true; }306 return false;307 }308 var sz = 3.4; // applyFont(sz) sets every cell's pointSize/leading309 applyFont(sz); doc.recompose();310 while (anyOverset() && sz > 1.8){ sz -= 0.1; applyFont(sz); doc.recompose(); }311 ```312 (Verified: packed a 7-row spec/allergen table into a 40×30mm card's bottom strip this313 way, settling at ~3.4pt with no overset.)314- **Measurement units:** to avoid ambiguity, set315 `doc.viewPreferences.horizontalMeasurementUnits = MeasurementUnits.POINTS` (and316 vertical), use **point numbers** for type (`pointSize`, `leading`, `spaceAfter`) and317 **unit strings** (`'15mm'`) for geometry / table sizes.318- **`pointSize`/`leading` get silently scaled when the ruler is mm — pin the script319 unit.** `app.scriptPreferences.measurementUnit` defaults to `AUTO_VALUE`, which320 follows the document's ruler. If the ruler is **millimeters** and you assign a bare321 number like `style.pointSize = 7`, InDesign interprets it through that unit and stores322 a *different* size (observed **≈0.71×**, e.g. `7 → 4.96pt`, `5.5 → 3.90pt`). The text323 then renders far smaller than intended. **Fix (verified):** set324 ```javascript325 app.scriptPreferences.measurementUnit = MeasurementUnits.POINTS;326 ```327 once at the top, so numeric `pointSize`/`leading` are taken as real points — even328 while the *ruler* stays mm for geometry. This was the difference between type coming329 out at 2.4pt vs the intended ~4–5pt.330- **Fit text to its frame by sizing on `overflows` feedback.** To fill a fixed frame331 with a balanced margin (instead of guessing a size), grow the point size until the332 frame just overflows, then back off a notch — calling `doc.recompose()` after each333 change so `overflows` reflects the new layout:334 ```javascript335 var sz = style.pointSize, g = 0;336 doc.recompose();337 while (!tf.overflows && sz < 60 && g++ < 400){ sz += 0.25; style.pointSize = sz; style.leading = sz*1.4; doc.recompose(); }338 while ( tf.overflows && sz > 2 && g++ < 400){ sz -= 0.25; style.pointSize = sz; style.leading = sz*1.4; doc.recompose(); }339 sz -= 0.5; style.pointSize = sz; style.leading = sz*1.4; // small bottom margin340 ```341- **Check for hidden overset text** after filling a fixed frame: `tf.overflows` (boolean).342 If `true`, shrink the font, enlarge the frame, or thread to more frames/pages.343344### Export formats (third arg of `ExportFormat`)345346| Target | Constant |347|--------|----------|348| PDF | `ExportFormat.PDF_TYPE` |349| **IDML** | `ExportFormat.INDESIGN_MARKUP` |350| PNG / JPG / EPS | `ExportFormat.PNG_FORMAT` / `JPG` / `EPS_TYPE` |351352Save a native `.indd` with `doc.save(new File('/abs/out.indd'))`. **Verified end-to-end:**353a 3-column A4-landscape Japanese document with an inline image and a CSV-driven table was354built and exported to `.indd`, `.idml`, and `.pdf` in one run (see355`scripts/20_build-multicolumn-doc-from-md-and-csv.jsx`).356357## Detecting & fixing overset (hidden) text — verified358359Overset text is content that does not fit its container and so is **invisible** in the360output (a table cell or text frame just looks blank). ExtendScript can detect it:361362- **Text frame / story:** `textFrame.overflows` (Boolean).363- **Table cell:** `cell.overflows` (Boolean). Scan a whole table with364 `table.cells.everyItem()` (loop `table.cells.item(i)`), or one column via365 `table.columns.item(c).cells`. A cell's `name` is `"column:row"`.366- **Gotcha:** an overset cell's **`.contents` returns `""`** even though text *is*367 there — so detect with `.overflows`, never by testing for empty content.368369**Why a cell oversets:** an **unbreakable Latin token** (a filename like `chimchar.png`,370a URL, a long English word) cannot wrap, so if it is wider than the column it spills out371and the cell renders blank. CJK (Japanese/Chinese) text breaks between characters, so it372wraps and grows the row height instead — it rarely oversets. So overset in a mixed table373is usually a too-narrow **Latin** column.374375**Fix by widening the column** — borrow width from a "safe donor" (the widest column376that does *not* itself overset after shrinking), keeping the total width constant so the377table still fits its frame column. **Call `doc.recompose()` after every width change** —378otherwise `.overflows` reports the *stale* pre-change layout and the logic misbehaves379(this was the difference between a diverging and a converging fix):380381```javascript382var colHasOverset = function(t,c){383 var cs = t.columns.item(c).cells;384 for (var i=0;i<cs.length;i++){ if (cs.item(i).overflows) return true; }385 return false;386};387var tableHasOverset = function(t){388 for (var c=0;c<t.columnCount;c++){ if (colHasOverset(t,c)) return true; }389 return false;390};391var step=2, floor=16, guard=0;392while (tableHasOverset(table) && guard<200){393 guard++;394 var widenC=-1;395 for (var c=0;c<table.columnCount;c++){ if (colHasOverset(table,c)){ widenC=c; break; } }396 if (widenC<0) break;397 var donor=-1, donorW=-1; // widest column that survives a shrink398 for (var c=0;c<table.columnCount;c++){399 if (c===widenC || colHasOverset(table,c)) continue;400 var col=table.columns.item(c), orig=col.width;401 if (orig-step < floor) continue;402 col.width = orig-step; doc.recompose(); // tentative403 var bad = colHasOverset(table,c);404 col.width = orig; doc.recompose(); // revert405 if (!bad && orig>donorW){ donorW=orig; donor=c; }406 }407 if (donor<0) break; // nothing safe to borrow from408 table.columns.item(widenC).width += step;409 table.columns.item(donor).width -= step;410 doc.recompose();411}412```413**Verified:** this cleared an overset filename cell in the pokemon table by widening the414image column ~2pt (borrowed from the numeric column) while leaving the English-name415column intact. Alternative fixes if no width can be borrowed: shrink the cell/table font416until `overflows` is false, enlarge the whole frame, or allow the token to break.417418## Session control recipes (verified on InDesign 2026 / 21.3)419420These cover the essential "drive a live InDesign session" operations. All were tested421against a running Adobe InDesign 2026 on macOS.422423- **Is there an active document?** Accessing `app.activeDocument` when nothing is open424 **throws**, so always gate on the count first:425 ```javascript426 if (app.documents.length > 0) {427 var doc = app.activeDocument; // safe428 }429 ```430- **Create a document if none is open:**431 ```javascript432 var doc = (app.documents.length > 0)433 ? app.activeDocument434 : app.documents.add({ documentPreferences: { pageWidth:'210mm', pageHeight:'297mm', facingPages:false } });435 ```436- **Enumerate every open document** (when multiple files are open):437 ```javascript438 for (var i=0; i<app.documents.length; i++) {439 var d = app.documents.item(i); // index 0 is the frontmost/active one440 console.log(d.name + ' saved=' + d.saved);441 }442 ```443- **Open a specific InDesign file:** `var doc = app.open(new File('/abs/file.indd'));`444 Works for `.indd` and `.idml`. The opened doc becomes the active document.445- **A crashed/aborted run leaves the doc open *and modified* in memory — reopen returns446 the dirty copy.** When an earlier script dies mid-way, the document stays open with its447 half-built state; a later `app.open(samePath)` hands back that **in-memory dirty copy**,448 *not* the clean file on disk. During iterative builds this silently feeds you partial449 state (observed: a re-harvest read **1 paragraph instead of 15** because a prior run had450 already cleared the page before crashing). **Start every rebuild by discarding open451 copies, then open fresh:**452 ```javascript453 while (app.documents.length>0){ app.documents[0].close(SaveOptions.NO); }454 var doc = app.open(new File('/abs/base.indd'));455 ```456- **Save to a specific path:** `doc.save(new File('/abs/out.indd'));`457- **Close:** `doc.close(SaveOptions.NO);` (or `SaveOptions.YES` to write changes first).458459### Letting Claude verify its own work (important)460461Export the result to an image/PDF, then read it back:462463```javascript464var doc = app.activeDocument;465doc.exportFile(ExportFormat.PDF_TYPE, new File('/abs/out.pdf')); // PDF466// or PNG (set app.pngExportPreferences.properties first, see 02_export-doc-as-png.jsx)467doc.exportFile(ExportFormat.PNG_FORMAT, new File('/abs/out.png'));468```469470- **Prefer PNG/JPG for visual verification.** Claude's file reader renders PNG/JPG471 natively, so it can *see* the rendered page and confirm the result. **Verified:** an472 exported PNG was read back and its on-page text was legible.473- **PDF needs a renderer.** Reading a PDF page requires `poppler` (`pdftoppm`); if it is474 not installed, export PNG/JPG instead, or `brew install poppler`.475476## Recipes / examples477478This skill bundles 50+ working example scripts under `scripts/`, indexed in479`examples-index.json` (each entry has `title`, `description`, `filepath`). When a task480matches one of these, **read the closest example and adapt it** rather than writing from481scratch — they encode the correct InDesign DOM idioms.482483Workflow:4841. Skim `examples-index.json` for a matching `title`/`description`.4852. `Read` the referenced `scripts/*.jsx`.4863. Adapt paths, sizes, and content for the user's task.4874. Write the result to a temp `.jsx` and run it with the `osascript` command above.4885. Report the returned stdout (the `logs.join('\n')` output) back to the user.489490Categories of bundled examples (prefix = rough grouping):491492- **00 — inspect / utilities:** console logging, current dir, active document filename,493 selection info, list fonts (installed or used in doc), close all documents.494- **01 — documents:** create a document with a text frame.495- **02 — export:** export active/Hello-World doc as PDF (PDF/X-1a), PNG, JPG, EPS.496- **03 — tables:** create a table, create + find a table, build a price-list table from JSON.497- **04 — images / PDF placement:** place image at page center, into a rectangle, with a498 graphic frame, as an inline graphic; place a PDF.499- **05 — IDML:** list IDML files, merge IDML files, convert IDML → INDD.500- **06 — file I/O:** save/read/parse JSON, save/read/parse TSV text (UTF-8).501- **07 — traversal / linked images:** traverse all pages & page items, replace an image.502- **08 — vector drawing:** graphic line, polygon, Bézier curves, fractal triangle,503 fractal ginkgo leaf.504- **09 — styles & type:** read paragraph/character styles, apply paragraph (and505 character) styles to "Hello World" text.506- **10 — layers:** find an existing layer or create one, assign page items to it.507- **11 — links:** check linked-image metadata (paths, status).508- **12 — text frames:** rounded corners, center-align contents, inline text frame,509 inspect text object properties.510- **13 — pages:** create a document with multiple pages.511- **14 — conditional text:** read all condition names, delete all conditions.512- **15 — colors / swatches:** read all color names, create a custom swatch.513514## Notes & gotchas515516- The shell receives only the **last evaluated expression**. Don't rely on `$.writeln`517 alone for results you need programmatically — push to `logs` and end with518 `logs.join('\n')`.519- ExtendScript errors surface in `osascript` stderr / the return string; if output is520 empty or an error, check that a document is open and the InDesign version matches.521- **Wrap a build script in try/catch and return the error — on an uncaught throw522 `osascript` returns *nothing useful*.** A failure surfaces only as a generic523 `execution error` with no logs and no checkpoint output, so you can't see how far it524 got. Wrap the body and log the message *with its line number*, then end as usual:525 ```javascript526 try { /* ... build ... */ }527 catch(err){ console.log('ERROR: ' + err + ' @line ' + err.line); }528 logs.join('\n');529 ```530 `err.line` alone usually pinpoints the failing statement (this is how the `storyDirection`531 and `'mm'`-arithmetic errors below were located).532- **Don't do arithmetic on `'NNmm'` measurement strings.** `'190mm'` is just a string, so533 `'190mm' + 0.5` yields the invalid `'190mm0.5'`, which InDesign rejects with *"要求された534 種類に関する利用可能なデータはありません / there is no data available for the requested535 type"*. Compute in plain numbers and append the unit last: `(190 + 0.5) + 'mm'`. A handy536 helper is `function mm(n){ return n + 'mm'; }` with all bounds math done numerically.537- **`Rectangle` has no `cornerRadius` property** (throws *55*); corner rounding lives on538 the per-corner option/radius family (`topLeftCornerOption` + `topLeftCornerRadius`, …),539 or just omit it.540- Use absolute paths everywhere (script file, image/PDF inputs, export outputs).541- **Do not write InDesign export/output files into `/tmp`.** On macOS `/tmp` is a542 symlink to `/private/tmp`, and InDesign's `File` export fails with *"Folder … not543 found" (error 48)*. The `.jsx` script itself can live in `/tmp` (it's read by544 `osascript`, not by InDesign's File API), but export targets must be a real directory545 such as the user's project folder or `~/Desktop`. **Verified:** `/tmp` export failed;546 exporting to a real path succeeded.547- This is macOS-only; it depends on AppleScript (`osascript`) and the InDesign548 AppleScript `do script` command.