Ahkab Illustrator Text — native Illustrator text (one paragraph = one text box)
By Ahmad Abdullahi Kabeer · Graphcent
Why
Illustrator always splits imported SVG and PDF text into one text object per line, grouped together. No SVG markup prevents this. So: keep the SVG pages for the artwork, and rebuild ALL text natively inside Illustrator with an ExtendScript (.jsx) build script that the user runs once (File > Scripts > Other Script…). The script saves a genuine .ai.
Workflow
- Record text while generating pages. Wherever the page generator emits
<text>, also store a record per page:{id, kind: 'area'|'point', layer: '05', x, y (first baseline, SVG y-down, pt), w (box width), lead, n (wrapped line count), size, anchor: 'start'|'middle'|'end', ls (letter-spacing pt), runs: [[text, weight, '#RRGGBB'], …], name}- Wrapped paragraphs (and multi-line titles) ->
area. Single-line labels, numbers, kickers ->point. - Use exactly the x/y/width/leading used to draw the SVG, so positions match the PDF.
- Give meaningful names (e.g.
Name — slot 01,Biography — slot 04).
- Wrapped paragraphs (and multi-line titles) ->
- SVG page requirements: files
pages/NN_Name.svg(NN = page number); top-level groupsid="L01_Backgrounds",L02_…etc. withdata-name= readable layer name. Keep text in the SVGs too (fallback + PDF rendering). - Generate the .jsx by embedding the records as a JS object literal (
json.dumps(..., ensure_ascii=True)— ExtendScript is ES3, no JSON.parse needed). - Dry-run the generated .jsx in Node against a mock of the Illustrator DOM (app, documents.add, open, textFrames.areaText/pointText, pathItems.rectangle, artboards, textFonts.getByName, enums). Check: counts of area/point frames, every frame has contents + font + name, rect coords are numbers, artboards = pages.
- Document in the README that the editable .ai comes from running the script; opening SVG/PDF directly still gives split lines. Say plainly the script was tested on a mock, not in Illustrator itself.
Core script logic (ExtendScript)
#target illustrator
var PAGE_TEXT = /* {"1":[records…], "2":[…]} */;
app.userInteractionLevel = UserInteractionLevel.DONTDISPLAYALERTS;
app.coordinateSystem = CoordinateSystem.DOCUMENTCOORDINATESYSTEM; // y increases upward
// 1) doc = app.documents.add(DocumentColorSpace.RGB, W, H); create named layers (doc.layers.add() adds on top).
// 2) per page p: artboard rect [left, top, left+W, top-H] with left = col*(W+GAP), top = -row*(H+GAP).
// src = app.open(svgFile); delete imported text:
// for (var i = src.textFrames.length-1; i >= 0; i--) src.textFrames[i].remove();
// find groups/sub-layers whose name matches /^L?(0[1-9])/ and duplicate into the matching layer:
// item.duplicate(targetLayer, ElementPlacement.PLACEATBEGINNING).translate(left - sr[0], top - sr[1]);
// (iterate container.pageItems, filter typename === 'GroupItem'; do not compare DOM objects with ===)
// src.close(SaveOptions.DONOTSAVECHANGES); then buildText(PAGE_TEXT[pageNo], left, top).
var FONT_CANDIDATES = { 300:["Kreadon-L","Outfit-Light"], 400:["Kreadon-R","Kreadon-M","Outfit-Regular"],
500:["Kreadon-M","Outfit-Medium"], 600:["Kreadon-Demi","Kreadon-D","Outfit-SemiBold"],
700:["Kreadon-B","Outfit-Bold"], 800:["Kreadon-EB","Outfit-ExtraBold"] }; // put the project's PostScript names first
function fontFor(w){ var n=FONT_CANDIDATES[w]||FONT_CANDIDATES[400]; for(var i=0;i<n.length;i++){ try{ return app.textFonts.getByName(n[i]); }catch(e){} } return null; }
function rgb(h){ var c=new RGBColor(); h=h.replace('#',''); c.red=parseInt(h.substr(0,2),16); c.green=parseInt(h.substr(2,2),16); c.blue=parseInt(h.substr(4,2),16); return c; }
function styleRange(r,w,size,col,lsPt,lead){ var a=r.characterAttributes, f=fontFor(w); if(f) a.textFont=f; a.size=size;
if(lead){ a.autoLeading=false; a.leading=lead; } a.fillColor=rgb(col); a.tracking=Math.round((lsPt||0)/size*1000); }
function buildText(recs, left, top){
for (var i=0;i<recs.length;i++){ var t=recs[i], layer=layerByKey[t.layer]||layerByKey['06'], s='', tf;
for (var r=0;r<t.runs.length;r++) s+=t.runs[r][0];
if (t.kind==='area'){ var lead=t.lead||t.size*1.4;
var rect=layer.pathItems.rectangle(top-(t.y-lead), left+t.x-1, t.w+2, (t.n+1)*lead); // +1 line slack for font swaps
tf=layer.textFrames.areaText(rect); tf.contents=s; styleRange(tf.textRange,t.runs[0][1],t.size,t.runs[0][2],t.ls,lead);
try{ tf.firstBaseline=FirstBaselineType.LEADING; }catch(e){ tf.translate(0,-(lead-t.size*0.95)); }
} else { tf=layer.textFrames.pointText([left+t.x, top-t.y]); tf.contents=s; styleRange(tf.textRange,t.runs[0][1],t.size,t.runs[0][2],t.ls,null); }
tf.textRange.paragraphAttributes.justification = t.anchor==='middle'?Justification.CENTER:(t.anchor==='end'?Justification.RIGHT:Justification.LEFT);
// rich spans: for runs after the first whose weight/colour differ, style chars [pos, pos+len):
// try { var rr=tf.textRange.characters[pos]; rr.length=len; styleRange(rr,…); } catch(e){ per-character loop }
tf.name = t.name || t.id; } }
// 3) IllustratorSaveOptions {pdfCompatible:true, compressed:true, embedLinkedFiles:true}; doc.saveAs(File(root+'/<Name>.ai'));
// Show a final alert with any notes (e.g. missing fonts). Wrap everything in try/catch and restore userInteractionLevel.
Checks before delivering
- Mock dry-run passes; the paragraph count matches the number of wrapped text blocks in the generator.
- Area boxes use the original width (+2pt) so line breaks match the PDF; with a wider replacement font they reflow inside the box.
- README explains: run the script for the editable .ai; Find/Replace Font if swapping typefaces; the script was not run inside Illustrator by Claude.