Google Docs to InDesign: Place Text on Specific Pages with a Script

A question on r/indesign described an existing 160-page InDesign document with five page templates in an irregular order. The author wanted to place the headline and body text for each page from a Google Doc instead of copying and pasting each one.

The script below does the transfer. You name the destination frames on the parent pages and add a marker such as [[120 body]] above each passage in the Doc. The script moves each passage into the named frame on that page and reports anything it cannot match.

What you still do by hand

You decide which passage belongs on each page and record that choice in the markers. You name the frames once, on the parent pages the document uses. For a 160-page book with a headline and a body on every page, that means adding 320 markers to the Doc. You still check the finished pages against the Doc, because a marker with the wrong page number that points to a real page is filled without complaint.

The example works on one open InDesign document whose pages and parent pages are already in place. It does not add pages or apply parent pages. I tested independent text frames and a body threaded through two frames on one parent page. I did not test parent-page threads that are already partly overridden on the document page.

Why Data Merge does not fit

Data Merge reads CSV or tab-delimited text and repeats a target layout for each record. This book starts with pages already arranged in five layouts, and the data file cannot carry italic or bold inside a paragraph.

Name the frames on the parent pages

Open each parent page and name its text frames in the Layers panel (click the frame's name in the list twice, slowly, and type the new name). Use the same name for the same job on every template, for example headline, body and caption.

Put a line of placeholder text in each named frame and apply the paragraph style that frame should use. When a frame already has text, the script applies the paragraph style of its first paragraph to every incoming paragraph. It does not clear overrides. Italic text inside a paragraph was preserved in my tests. A frame with no placeholder kept the paragraph style imported from Word.

You do not need to override the frames on the document pages. When a named frame exists only on the parent page, the script overrides it onto the page before filling it. In my test with a body threaded through two frames on one parent page, the script overrode both frames and linked them again in the same order.

The script replaces all text in the destination story, including text in its other threaded frames. It stops before replacing anything if two markers point to the same story, or if the story has a text frame on another page.

Mark the text in the Doc

Before each passage, add a line with the page name and the frame name in double square brackets:

[[120 headline]]
Spring planting calendar
[[120 body]]
The first paragraph of page 120 ...
The second paragraph ...
[[121 body]]
...

The text between one marker and the next is moved into the destination story. The result also depends on your Word import settings. I did not test with Remove Styles and Formatting from Text and Tables turned on.

The page name is the number shown in the Pages panel. I did not test documents with section numbering.

When the Doc is ready, download it with File > Download > Microsoft Word (.docx).

The script

Copy only the code inside the block below into a plain text editor such as Notepad. Save it as FillPagesFromDoc.jsx, not FillPagesFromDoc.jsx.txt.

// FillPagesFromDoc.jsx
// Fills named text frames on specific pages of the active InDesign document
// from a Word file (for example a Google Doc downloaded as .docx).
//
// In the Word file, put a marker line before each piece of text:
//   [[120 headline]]
//   The headline for page 120
//   [[120 body]]
//   The body text for page 120 ...
// "120" is the page name as shown in the Pages panel. "headline" is the frame
// name as shown in the Layers panel. The frame can be on the page or on its
// parent page; a frame on the parent page is overridden onto the page first.
//
// The script checks every marker before it replaces any text. It stops and
// lists the problems if a marker points to a missing page or frame, has no
// text, appears twice, shares a story with another marker, points to a
// story that continues onto another page, or points to a locked frame.
// Otherwise it replaces all text in each destination story, including text
// in its other threaded frames, with the text after the marker.
// One Edit > Undo reverts the whole run.

var MARKER = /^\[\[\s*(\S+)\s+(\S+)\s*\]\]\s*$/;

function isNamed(item) {
  return item.name !== "" && item.name.charAt(0) !== "<";
}

function pageIdOf(frame) {
  try { return frame.parentPage ? frame.parentPage.id : null; } catch (e) { return null; }
}

// Treat a frame that cannot be checked as locked.
function isLocked(frame) {
  try { return frame.locked || frame.itemLayer.locked; } catch (e) { return true; }
}

function chainLocked(chain) {
  for (var j = 0; j < chain.length; j++) if (isLocked(chain[j])) return true;
  return false;
}

// Find the destination for one marker without changing the document.
function findDestination(page, name) {
  var i, j, items = page.allPageItems, f, chain, shown = {};
  for (i = 0; i < items.length; i++) {
    f = items[i];
    if (f instanceof TextFrame && f.name === name) {
      chain = f.parentStory.textContainers;
      if (chainLocked(chain)) return { error: "'" + name + "' or a frame threaded to it is locked" };
      for (j = 0; j < chain.length; j++) {
        if (pageIdOf(chain[j]) !== page.id) return { error: "the '" + name + "' story continues onto another page" };
      }
      return { frame: f, parent: false, storyKey: page.id + ":" + f.parentStory.id };
    }
  }
  items = page.masterPageItems;
  for (i = 0; i < items.length; i++) shown[items[i].id] = true;
  for (i = 0; i < items.length; i++) {
    f = items[i];
    if (f instanceof TextFrame && f.name === name) {
      chain = f.parentStory.textContainers;
      if (chainLocked(chain)) return { error: "'" + name + "' or a frame threaded to it is locked" };
      for (j = 0; j < chain.length; j++) {
        if (!shown[chain[j].id]) return { error: "'" + name + "' is threaded to a frame that is not on this page" };
      }
      return { frame: f, parent: true, storyKey: page.id + ":" + f.parentStory.id };
    }
  }
  return { error: "no text frame named '" + name + "'" };
}

// Override a parent-page frame (and the frames threaded to it) onto the page.
function prepare(page, target) {
  if (!target.parent) return target.frame;
  var chain = target.frame.parentStory.textContainers, copies = [], first = null, j, o;
  for (j = 0; j < chain.length; j++) {
    o = chain[j].override(page);
    if (chain[j].id === target.frame.id) first = o;
    copies.push(o);
  }
  // In my two-frame test, overriding a threaded parent-page story from a
  // script left separate stories. Link the copies again; their placeholder
  // text is replaced anyway.
  for (j = 1; j < copies.length; j++) {
    if (copies[j].parentStory.id !== copies[0].parentStory.id) {
      copies[j].parentStory.contents = "";
      copies[j - 1].nextTextFrame = copies[j];
    }
  }
  return first;
}

function slotNames(page) {
  var names = {}, i, items = page.allPageItems;
  for (i = 0; i < items.length; i++) {
    if (items[i] instanceof TextFrame && isNamed(items[i])) names[items[i].name] = true;
  }
  items = page.masterPageItems;
  for (i = 0; i < items.length; i++) {
    if (items[i] instanceof TextFrame && isNamed(items[i])) names[items[i].name] = true;
  }
  return names;
}

function fillPages(doc, file) {
  var prevLayer = doc.activeLayer, result, failure = null, cleanupFailed = false;
  var layer = doc.layers.add({ name: "FillPagesFromDoc temp" });
  try {
    result = fillFromLayer(doc, file, layer);
  } catch (e) {
    failure = e;
  }
  try { layer.remove(); } catch (e1) { cleanupFailed = true; }
  try { doc.activeLayer = prevLayer; } catch (e2) {}
  var warning = "The temporary layer \"FillPagesFromDoc temp\" could not be removed. Delete it in the Layers panel.";
  if (failure !== null) {
    throw Error((failure.message || String(failure)) + (cleanupFailed ? "\n" + warning : ""));
  }
  if (cleanupFailed) result.summary += "\n\n" + warning;
  return result;
}

function fillFromLayer(doc, file, layer) {
  var notes = [], problems = [], lines = [];
  var spread = doc.spreads[0];
  var temp = spread.textFrames.add({ itemLayer: layer, geometricBounds: spread.pages[0].bounds });
  temp.insertionPoints[0].place(file, false);
  var src = temp.parentStory, whole = src.contents, n = src.characters.length;

  // Collect markers and the character range of the text after each one.
  var marks = [], paras = src.paragraphs, i, m, mk;
  for (i = 0; i < paras.length; i++) {
    m = MARKER.exec(paras[i].contents.replace(/\r$/, ""));
    if (m) marks.push({ page: m[1], slot: m[2], index: paras[i].index, len: paras[i].characters.length });
  }
  if (marks.length === 0) {
    return { summary: "No marker lines like [[120 body]] were found in the file. No destination text was replaced.", full: "" };
  }
  if (marks[0].index > 0) notes.push("Text before the first marker was ignored.");
  for (i = 0; i < marks.length; i++) {
    mk = marks[i];
    mk.key = mk.page + " " + mk.slot;
    mk.start = mk.index + mk.len;
    mk.end = (i + 1 < marks.length) ? marks[i + 1].index - 1 : n - 1;
    if (mk.end >= mk.start && whole.charAt(mk.end) === "\r") mk.end--;
  }

  // Check every marker before changing the pages.
  var seen = {}, stories = {}, page, r;
  for (i = 0; i < marks.length; i++) {
    mk = marks[i];
    if (seen[mk.key]) { problems.push("[[" + mk.key + "]] appears more than once."); continue; }
    seen[mk.key] = true;
    if (mk.end < mk.start || !/\S/.test(whole.substring(mk.start, mk.end + 1))) {
      problems.push("[[" + mk.key + "]] has no text after it.");
    }
    page = doc.pages.itemByName(mk.page);
    if (!page.isValid) { problems.push("[[" + mk.key + "]]: there is no page named " + mk.page + "."); continue; }
    r = findDestination(page, mk.slot);
    if (r.error) { problems.push("[[" + mk.key + "]]: on page " + mk.page + ", " + r.error + "."); continue; }
    if (stories[r.storyKey]) {
      problems.push("[[" + mk.key + "]] and [[" + stories[r.storyKey] + "]] point to the same story.");
      continue;
    }
    stories[r.storyKey] = mk.key;
    mk.pageObj = page;
    mk.target = r;
  }
  if (problems.length) {
    lines.push("No destination text was replaced. Fix these in the Word file or the layout, then run the script again:");
    lines.push("  " + problems.join("\n  "));
    lines.push("The styles that came in with the Word file are still in the document. Edit > Undo removes them.");
    return { summary: lines.join("\n"), full: lines.join("\n") };
  }

  // Fill, working backwards so earlier character positions stay valid.
  var overset = [], filled = {}, frame, story, oldStyle;
  for (i = marks.length - 1; i >= 0; i--) {
    mk = marks[i];
    frame = prepare(mk.pageObj, mk.target);
    story = frame.parentStory;
    oldStyle = story.characters.length > 0 ? story.paragraphs[0].appliedParagraphStyle : null;
    if (story.characters.length > 0) story.characters.itemByRange(0, story.characters.length - 1).remove();
    src.characters.itemByRange(mk.start, mk.end).move(LocationOptions.AT_BEGINNING, story.insertionPoints[0]);
    if (oldStyle) story.paragraphs.everyItem().applyParagraphStyle(oldStyle, false);
    if (story.overflows) overset.push("page " + mk.page + " '" + mk.slot + "'");
    filled[mk.key] = true;
  }

  // Named frames in the layout that no marker pointed to.
  var empty = [], pg, names, nm;
  for (i = 0; i < doc.pages.length; i++) {
    pg = doc.pages[i];
    names = slotNames(pg);
    for (nm in names) {
      if (!filled[pg.name + " " + nm]) empty.push("page " + pg.name + " '" + nm + "'");
    }
  }

  var head = "Filled " + marks.length + " destination stories from " + marks.length + " markers.";
  var full = [head], summary = [head];
  if (overset.length) {
    full.push("Overset after filling (" + overset.length + "):\n  " + overset.join("\n  "));
    summary.push("Overset after filling: " + overset.length);
  } else {
    full.push("Overset after filling: none");
  }
  full.push("Named frames without a matching marker (" + empty.length + "):" + (empty.length ? "\n  " + empty.join("\n  ") : " none"));
  summary.push("Named frames without a matching marker: " + empty.length);
  if (notes.length) { full.push(notes.join("\n")); summary.push(notes.join("\n")); }
  return { summary: summary.join("\n"), full: full.join("\n\n") };
}

if (typeof FILL_PAGES_TEST === "undefined") {
  (function () {
    if (app.documents.length === 0) { alert("Open the InDesign document first."); return; }
    var f = File.openDialog("Choose the Word file", "*.docx");
    if (!f) return;
    var doc = app.activeDocument, result;
    app.doScript(function () {
      try {
        result = fillPages(doc, f);
      } catch (e) {
        result = { summary: "The script stopped with an error: " + e + "\nUse Edit > Undo once to revert any changes.", full: "" };
      }
    }, ScriptLanguage.JAVASCRIPT, undefined, UndoModes.ENTIRE_SCRIPT, "Fill pages from Word file");
    if (result.full) {
      // Each run replaces the previous report for the same Word file.
      var rf = File(f.fsName.replace(/\.docx$/i, "") + " - fill report.txt"), saved = false;
      try {
        rf.encoding = "UTF-8";
        if (rf.open("w")) {
          saved = rf.write(result.full);
          saved = rf.close() && saved;
        }
      } catch (e) {
        saved = false;
      }
      result.summary += saved ? "\n\nFull report: " + rf.fsName : "\n\nThe full report could not be saved next to the Word file.";
    }
    alert(result.summary);
  })();
}

To install it, open Window > Utilities > Scripts, right-click the User folder, choose Reveal in Explorer (or Reveal in Finder), and put the file in that folder. Check that FillPagesFromDoc.jsx appears under User in the Scripts panel. If you have not run a script before, this walkthrough covers the steps.

Run it

The text in each destination story is replaced, including edits made on the document pages. Save a separate copy of the InDesign document and run the script on that copy. In my tests, one Edit > Undo restored the document.

  1. Open the .indd document you want to fill, so that it is the active document.
  2. Double-click FillPagesFromDoc.jsx in the Scripts panel.
  3. Choose the .docx file.

The script places the Word file into a temporary frame on a temporary layer, using your current Word import settings. Then it checks every marker before it replaces any text. It looks for missing pages and frames, markers with no text, repeated markers, and two markers for one story. It also rejects stories with a frame on another page, and destinations where the named frame or a frame threaded to it is locked.

If any marker fails, the script lists the problems and replaces no text. The styles that came in with the Word file stay in the document; one Edit > Undo removes them.

If every marker passes, the script fills the destination stories and shows a short summary. The full report is saved as a text file next to the .docx, and each run replaces the previous report for that file. If the report cannot be saved, the summary says so. The report lists:

  • destination stories that are overset after filling
  • named frames without a matching marker, page by page
  • text before the first marker, which is ignored

Even when every marker is filled and no overset text is reported, the script cannot tell whether you assigned the right page. Check the pages against the Doc.

What I tested

I built a nine-page document with four parent pages: headline and body; headline and a body threaded over two columns; an empty image frame with a caption and a body; and a body threaded to a frame on a locked layer. On the document pages I added a frame on a locked layer, two named frames in one story, and a named story that continues onto the next page.

A Word file with nine bad markers stopped the run. All nine were listed, and no text on the pages changed. A Word file with 15 valid markers filled all 15 destination stories. Italic text inside a paragraph stayed italic, and the long text ran into the second column. A single marker for the story with two named frames filled both frames. One Undo restored the document, including a headline I had edited by hand before the run.

I also made the script fail on purpose partway through a run. The temporary layer was removed, and one Undo restored the document.

In an earlier version, overriding the threaded body from the script broke the thread and left the text overset. That is why the script links the overridden frames again.

I tested on Windows with Word files written by a script, and with a short Google Doc downloaded as .docx. I did not test macOS, section numbering, facing-pages layouts, three or more threaded frames, partly overridden threads, or images.

Related

Share: X Email