Skip to content

Office documents: the docx service

Using the extension

Content Script works with existing Office documents through three services:

Service Loads Returns
docx Word documents (.docx) CSWordDoc
xlsx Excel spreadsheets (.xlsx) CSSpreadsheet
pptx PowerPoint presentations (.pptx) CSPPDoc

A script loads a document, reads or changes it in memory, and saves the result to a node, a resource, or a file. Nothing changes in Content Server until you save.

def node = docman.getNode(123456)

def doc = docx.loadWordDoc(node)
doc.replaceFields([CustomerName: 'Acme Ltd'], true, true)
doc.save(node)                      // adds a new version to the node

To generate a new Word or PDF document from scratch, use the Document Builder service instead. The services on this page edit documents that already exist, such as templates, contracts under review, or reports.

Module Suite 4.1.0 adds a set of Word methods that read document structure and work with tracked changes, comments, bookmarks, and fields. It also adds named regions for Excel and named shapes for PowerPoint. Members marked 4.1.0 in the tables below are new in this release.

flowchart LR
    Source["Document node, version, resource or file"] --> Load["docx.loadWordDoc / xlsx.loadSpreadsheet / pptx.loadPresentation"]
    Load --> Read["Read structure: paragraphs, revisions, comments, fields"]
    Read --> Edit["Edit: fields, tracked changes, comments, shapes, regions"]
    Edit --> Save["save(...)"]
    Save --> Version["New version of a node"]
    Save --> NewDoc["New document in a folder"]
    Save --> Temp["CSResource or File"]

Loading documents

Method Description Since
docx.loadWordDoc(CSDocument wordDoc) Loads a Word document from a document node 1.0.0
docx.loadWordDoc(File file) Loads a Word document from a file 1.0.0
docx.loadWordDoc(CSResource resource) Loads a Word document from a CSResource, such as the content of a specific version or a temporary resource returned by another service. Returns null if the resource is null 4.1.0
xlsx.loadSpreadsheet(CSDocument xlsDoc) Loads a spreadsheet from a document node 1.0.0
xlsx.loadSpreadsheet(File file) Loads a spreadsheet from a file 1.0.0
xlsx.createSpreadsheet(String... name) Creates a new spreadsheet. Pass worksheet names to name the initial worksheets 1.0.0
pptx.loadPresentation(CSDocument pptDoc) Loads a presentation from a document node 1.0.0
pptx.loadPresentation(File file) Loads a presentation from a file 1.0.0

The new CSResource overload lets a script open a version other than the current one, or a document that exists only as a resource:

Load an earlier version
def node = docman.getNode(123456)
def doc = docx.loadWordDoc(node.versions[0].content)

Spreadsheet methods on docx are deprecated

docx.loadSpreadsheet and docx.createSpreadsheet have been deprecated since 1.7.0. Use the same methods on the xlsx service.

The docx service also works on whole documents:

Method Description Since
docx.compare(CSResource before, CSResource after) Compares two documents and returns a CSResource with the comparison result 2.7.0
docx.mergeDocuments(boolean newPageAfterSection, boolean keepHeaderFromFirstDocument, File... files) Merges Word files into one document 2.4.0
docx.mergeDocuments(boolean newPageAfterSection, boolean keepHeaderFromFirstDocument, CSWordDoc... files) Merges loaded Word documents into one document 3.0.0
docx.replaceFields(CSWordDoc wordDoc, Map parameters, boolean shouldProcessHeaderAndFooters, boolean removeFields) Service form of CSWordDoc.replaceFields 3.3.0
pptx.mergeDocuments(boolean keepOriginalLookAndFeel, File... files) Merges presentations into one presentation 1.0.0

Saving documents

CSWordDoc, CSSpreadsheet, and CSPPDoc share the same save methods. The docx, xlsx, and pptx services also offer save(doc, targetNode), save(doc, targetParent, targetName), save(doc, targetFile), and save(doc), with the document as the first parameter, for example docx.save(doc, node).

Method Description Since
save(CSNode node) Saves the document as a new version of the node and returns the node 1.0.0
save(CSNode parentNode, String docname) Creates a new document in parentNode and returns the CSDocument 1.0.0
save(CSResource file) Writes the document to an existing CSResource 3.3.0
save(File file) Writes the document to a file 1.0.0
save() Writes the document to a temporary file and returns the File 1.0.0
Save to a node, a folder, or a resource
def doc = docx.loadWordDoc(docman.getNode(123456))

doc.save(docman.getNode(123456))                       // new version
doc.save(docman.getNode(2000), 'Contract - final.docx') // new document

def res = docman.getTempResource('out', 'docx')
doc.save(res)                                          // temporary resource

Document properties

All three document types read and write core, extended, and custom properties, and synchronize custom properties with a category. These members have been available since 1.0.0.

Method Description Since
listProperties(), listCoreProperties(), listExtendedProperties(), listCustomProperties() Return the properties as a Map of String names and values 1.0.0
setProperties(Map properties, String dateFormat) Updates several properties at once. dateFormat defaults to dd.MM.yyyy 1.0.0
syncFromCategory(CSNode node, String categoryName, String dateFormat) Copies the category attributes of node to custom properties. dateFormat defaults to dd/MM/yyyy 1.0.0
syncToCategory(CSNode node, String categoryName) Copies custom properties back to the category of node 1.0.0

Core properties are also available as fields: category, contentStatus, contentType, created, creator, description, identifier, keywords, language, lastModifiedBy, modified, revision, subject, title, and version.

Synchronize category metadata to a document
def node = docman.getNode(123456)
def doc = docx.loadWordDoc(node)
doc.title = 'Service Agreement'
doc.syncFromCategory(node, 'Contract')
node.addVersion(doc.save())

Word documents

Existing members

Method Description Since
replaceVariables(Map context) Replaces ${..} placeholders with values from context 1.0.0
evaluateTemplate() Runs the document through the templating engine and evaluates every ${..} expression 1.0.0
replaceFields(Map parameters, boolean shouldProcessHeaderAndFooters, boolean removeFields) Replaces merge fields. Both booleans default to true 3.3.0
addRowsToTable(Integer tableNumber, Integer targetRow, List replacements, Integer copies) Clones a table row. See Cloning table rows 1.0.0
removeRowsFromTable(Integer tableNumber, Integer targetRow, Integer rowNum) Removes rowNum rows (default 1) starting at targetRow 1.0.0
removeComments() Removes every comment. See Removing comments 3.0.0
listComments(boolean withHtmlContext) Lists comments, insertions, and deletions 3.0.0
listCommentsWithPageNumbers(boolean withHtmlContext, boolean foViaXSLT) Same as listComments, with page numbers. Both booleans default to true 3.0.0
listCustomXmlBinding(), getCustomXmlBinding(String bindingName), updateCustomXmlBinding(String customXmlBinding, xmlBinding) Read and update custom XML data-binding files 2.3.0
updateOpenDoPEBindings(xmlBinding, boolean removeControls, boolean removeXML) Updates OpenDoPE data bindings from a String or a parsed XML node 2.3.0
updateOpenDoPEBindingsWithComponents(xmlBinding, Map componentsMap, boolean removeControls, boolean removeXML) Same, with components 3.0.0
getOpenDopeDataBinding(), getOpenDopeConditionsBinding(), getOpenDopeXPathsBinding(), getOpenDopeComponentsBinding() and their update... counterparts Read and update individual OpenDoPE parts 3.0.0

Reading structure: paragraphs, text, and numbering

Most 4.1.0 methods identify a paragraph by its index. The index comes from listParagraphs, which walks the document body in traversal order, including table cells. Use the same index with every paragraph-indexed method.

Method Description Since
listParagraphs() Returns every paragraph as a Map with index, text, and style (String or null) 4.1.0
listParagraphs(Map options) Returns every paragraph, including those in text boxes, with structure details and optional list numbers 4.1.0
toText(Map options) Returns the plain text of the body, one paragraph per line 4.1.0

Each entry returned by listParagraphs(options) contains:

Key Type Description
index int Paragraph index, the same as listParagraphs()
text String Paragraph text. Includes hyperlinks, content controls, and field results, never field codes
style String or null Paragraph style
paraId String or null Word's w14:paraId
inTable boolean The paragraph is in a table
outlineLevel int 0-8 or null Outline level, from the paragraph or its style

With numbering: true, each entry also contains:

Key Type Description
numberLabel String or null The list number as Word displays it, for example 9.1
numberingLevel int or null List level
numId String or null Numbering definition id
isBullet boolean The paragraph is a bullet item

The options of listParagraphs(options):

Option Default Description
revisions 'accepted' 'accepted' returns the text with every tracked change accepted. 'original' returns the text before the tracked changes. 'asIs' returns the same text as listParagraphs()
numbering false Computes list numbers

toText(options) uses the same paragraphs and text, and accepts revisions plus:

Option Default Description
includeNumbering false Prefixes numbered paragraphs with their list number, for example 9.1
paragraphSeparator '\n' String placed between paragraphs

An unrecognised option value raises InvalidParamException.

Find clauses by their Word number
def doc = docx.loadWordDoc(docman.getNode(123456))

def clauses = doc.listParagraphs([numbering: true])
def clause = clauses.find { it.numberLabel == '9.1' }
log.info("Clause 9.1 is paragraph ${clause?.index}: ${clause?.text}")

def before = doc.toText([revisions: 'original', includeNumbering: true])
def after  = doc.toText([revisions: 'accepted', includeNumbering: true])

Which text to use

listParagraphs() without options returns the text as it is stored. listParagraphs(options) defaults to the accepted text. Character offsets used by listRevisions and replaceText refer to the accepted text, so read paragraphs with listParagraphs([:]) or listParagraphs([revisions: 'accepted']) when you compute offsets.

Bookmarks and fields

Method Description Since
listBookmarks() Returns the bookmarks of the body 4.1.0
listFields() Returns the fields of the body in the order they start: cross-references, page numbers, merge fields, hyperlinks, and others 4.1.0

Each bookmark is a Map with name, id (long), hidden (boolean, true for Word's internal bookmarks such as _Ref or _Toc), startParagraphIndex and endParagraphIndex (int or null), and text.

Each field is a Map with:

Key Description
type Field type, for example REF, PAGEREF, or MERGEFIELD
instruction The full field code
result The displayed text
target The bookmark of a REF, PAGEREF, or NOTEREF field, or the address of a HYPERLINK. Otherwise null
paragraphIndex, endParagraphIndex Paragraphs where the field starts and ends
simple true for a w:fldSimple field
deleted true if the field is inside a tracked deletion

Combine the two lists to resolve a cross-reference to the paragraph it points to:

Resolve cross-references
def doc = docx.loadWordDoc(docman.getNode(123456))
def bookmarks = doc.listBookmarks().collectEntries { [(it.name): it] }

doc.listFields().findAll { it.type == 'REF' && !it.deleted }.each { f ->
    def target = bookmarks[f.target]
    log.info("'${f.result}' in paragraph ${f.paragraphIndex} points to paragraph ${target?.startParagraphIndex}")
}

Merge fields and MERGEFIELD formatting

replaceFields(parameters, shouldProcessHeaderAndFooters, removeFields) fills MERGEFIELD fields with the values in parameters.

  • shouldProcessHeaderAndFooters (default true) also fills fields in headers and footers.
  • removeFields (default true) replaces each field with plain text. With false, the fields stay in the document and show the new value as their result.
  • A field that has no entry in parameters is filled with !!FieldName!!, which makes missing values easy to spot.

When the fields are kept (removeFields: false), the merged text keeps the formatting of the field in the template, such as its colour. Only MERGEFIELD results are affected. Other fields such as PAGE, TOC, or DOCPROPERTY are left as they are. This step is best effort: if it fails, a warning is logged and the merge result is kept.

List merge fields, then fill them and keep the fields
def doc = docx.loadWordDoc(docman.getNode(123456))

def names = doc.listFields().findAll { it.type == 'MERGEFIELD' }*.instruction
log.info("Merge fields: ${names}")

doc.replaceFields([CustomerName: 'Acme Ltd', ContractDate: '27.10.2026'], true, false)

Tracked changes

Listing revisions

Method Description Since
enableTrackChanges(boolean enabled) Turns track changes on or off for the document. When on, later edits made in Word are tracked. enabled defaults to true 4.1.0
listRevisions() Returns the tracked changes of the body in document order 4.1.0

Each revision is a Map with:

Key Description
id long. Use it with the ids filter of acceptRevisions and rejectRevisions
type insertion, deletion, moveFrom, moveTo, paragraphInsertion, paragraphDeletion, runFormatting, or paragraphFormatting
author String
date Date or null
paragraphIndex int, as in listParagraphs
text The text the revision affects
originalStart, originalEnd Offsets of that text in the paragraph's 'original' text
acceptedStart, acceptedEnd Offsets of that text in the paragraph's 'accepted' text

A deletion is empty in the accepted text, and an insertion is empty in the original text. paragraphInsertion and paragraphDeletion mean that a paragraph mark was inserted or deleted, so paragraphs were split or joined.

Revisions that are not listed

Table row and cell, section, and numbering revisions are not listed, nor are revisions in headers, footers, footnotes, and comments. acceptRevisions and rejectRevisions leave them untouched.

Accepting and rejecting

Method Description Since
acceptRevisions(Map filter) Accepts tracked changes, as Word's Accept command does 4.1.0
rejectRevisions(Map filter) Rejects tracked changes, as Word's Reject command does 4.1.0

Accepting an insertion keeps its text, accepting a deletion removes it, and accepting a formatting change keeps the new formatting. Accepting a deleted paragraph mark joins the paragraph with the next one. Rejecting does the opposite: an insertion is removed, a deletion is restored, the previous formatting comes back, and rejecting an inserted paragraph mark joins the paragraph with the next one.

Without a filter, or with an empty one, every change is accepted or rejected. Filter keys are combined, so a change must match all of them:

Key Description
authors List of String. Only changes by these authors (case-insensitive)
excludeAuthors List of String. Never changes by these authors (case-insensitive)
ids List of revision ids from listRevisions
types List of revision types from listRevisions
Accept formatting changes, reject one reviewer
def doc = docx.loadWordDoc(docman.getNode(123456))

doc.acceptRevisions([types: ['runFormatting', 'paragraphFormatting']])
   .rejectRevisions([authors: ['External Counsel']])

Word-level editing

These methods change part of a paragraph or a whole paragraph, optionally as tracked changes. They return the ids of the tracked changes they created, as listRevisions() reports them, or an empty list when the edit is not tracked.

Method Description Since
replaceText(int paragraphIndex, int start, int end, String text, Map options) Replaces part of a paragraph's text. Inserts text when start equals end, deletes it when text is empty 4.1.0
insertParagraph(int paragraphIndex, String text, Map options) Inserts a new paragraph after, or before, an existing one 4.1.0
deleteParagraph(int paragraphIndex, Map options) Deletes a paragraph 4.1.0

All three accept these options:

Option Default Description
track Whether track changes is enabled on the document Records the edit as tracked changes
author 'System' Author of the tracked changes
date Now Date of the tracked changes

insertParagraph also accepts:

Option Default Description
before false Inserts before the paragraph instead of after it
likeParagraph paragraphIndex The paragraph whose properties (style, numbering, indentation) and text formatting are copied

Details:

  • start and end in replaceText are offsets in the paragraph's accepted text, as returned by listParagraphs(options) and listRevisions().
  • With tracking, the replaced text becomes a tracked deletion and the new text a tracked insertion. The new text takes the formatting of the first replaced run. For a pure insertion, it takes the formatting of the run before the position.
  • insertParagraph copies the properties of a reference paragraph, so a new clause is numbered like its neighbours.
  • deleteParagraph with tracking turns the text and paragraph mark into a tracked deletion. Without tracking, the paragraph is removed.
  • New text cannot contain line breaks. Tabs are allowed.

What cannot be edited

Text inside an existing tracked insertion, a field, or a content control cannot be edited. A paragraph that contains such text, or that ends a section, cannot be deleted. In these cases, and when a paragraph or offset does not exist, the methods raise InvalidParamException.

Re-read paragraphs after structural edits

Inserting a paragraph, or deleting one without tracking, changes the indexes of the paragraphs that follow. Call listParagraphs again before you use stored indexes.

Replace one word as a tracked change
def doc = docx.loadWordDoc(docman.getNode(123456))

def para = doc.listParagraphs([:]).find { it.text.contains('thirty (30) days') }
int start = para.text.indexOf('thirty (30)')
int end = start + 'thirty (30)'.length()

def ids = doc.replaceText(para.index, start, end, 'sixty (60)',
        [track: true, author: 'Legal Review'])
log.info("Created revisions ${ids}")

Paragraph-level helpers

Two simpler methods follow the track-changes setting of the document:

Method Description Since
replaceParagraphText(int paragraphIndex, String newText, String author) Replaces the whole text of a paragraph. Keeps the paragraph properties and the formatting of the first run. When track changes is enabled, the replacement is recorded as a deletion and an insertion. author defaults to 'System' 4.1.0
addParagraph(String text, int position, String style, String author) Adds a paragraph to the body. position is an index in the body content list, not a paragraph index, and -1 (default) appends at the end. style is a Word style id such as Normal, Heading1, or Heading2 (default Normal). When track changes is enabled, the paragraph is a tracked insertion 4.1.0

Comments and comment anchors

Method Description Since
listCommentAnchors() Returns the comments of the document with the text they are attached to 4.1.0
addComment(String searchText, String commentText, String author, String initials, boolean matchAll, boolean fallbackToParagraph) Comments a passage found by a case-insensitive search. Returns the number of comments added, 0 if the text is not found 4.1.0
addCommentToParagraph(int fromParagraph, int toParagraph, String commentText, String author, String initials) Comments one or more whole paragraphs. toParagraph is inclusive. Use the same value for a single paragraph 4.1.0
addDocumentComment(String commentText, String author, String initials) Adds a comment that is not attached to any text. Its reference is placed at the end of the body 4.1.0
removeComments() Removes every comment 3.0.0

Each comment anchor is a Map with:

Key Description
id long
author, initials String
date Date or null
text The comment text
startParagraphIndex, endParagraphIndex int or null, as in listParagraphs
anchoredText The commented text, with tracked changes accepted. Empty for a comment attached to a single point
parentId long or null. The comment this one replies to
done boolean. The comment is resolved in Word

Defaults for the add methods: author is 'System', initials is the first two characters of the author, matchAll is false (only the first occurrence), and fallbackToParagraph is true (if the exact text is not found, the first paragraph with a partial match is commented).

Review comment threads
def doc = docx.loadWordDoc(docman.getNode(123456))

def open = doc.listCommentAnchors().findAll { !it.done && it.parentId == null }
open.each { c ->
    log.info("${c.author} on '${c.anchoredText}': ${c.text}")
}

doc.addComment('limitation of liability', 'Check against the 2026 policy.', 'Legal Review', 'LR', true, false)

Removing comments

removeComments() removes the comment markers from the body and also the comment parts of the package, which hold the comment text and authors. The comments are gone from the saved file, not only hidden. Tracked changes are not affected. To remove them, accept or reject them first.

Removing personal information

Method Description Since
removePersonalInformation(Map options) Removes information that identifies people or editing sessions before the document leaves the organisation 4.1.0

Text, comments, and tracked changes are kept. Combine this method with removeComments and acceptRevisions for a clean copy.

Option Default Description
coreProperties true Clears author and last-modified-by
appProperties true Clears company and manager
customProperties false Removes all custom document properties
customXml false Removes custom XML parts. Breaks content controls bound to them
people true Removes the list of reviewers Word keeps for comments
rsids true Removes editing-session identifiers
authorAlias none Name that replaces the author of every tracked change and comment, and the document author
removeOnSave false Asks Word to remove personal information whenever the document is saved

Cloning table rows

addRowsToTable(tableNumber, targetRow, replacements, copies) copies a template row and fills placeholders in the copies. tableNumber and targetRow are 0-based positions. The copies are inserted directly below targetRow.

  • copies (default 1) is the number of copies. Each copy is independent of the others, so filling one row does not change another, even in large tables.
  • replacements (default empty) is a List of Maps, one per row, starting with the template row itself. In each Map, every key is literal text to find in the row and its value is the text that replaces it.
  • Where a replacement is made, the paragraph text is rewritten in its first run, so the paragraph takes the formatting of that run.
  • A failure raises ExecutionFaultException.
One table row per line item
def doc = docx.loadWordDoc(docman.getNode(123456))

def items = [
    [ITEM: 'Licence', QTY: '10', PRICE: '1,000.00'],
    [ITEM: 'Support', QTY: '1',  PRICE: '250.00'],
    [ITEM: 'Training', QTY: '2', PRICE: '600.00']
]

// Table 0, row 1 holds the placeholders ITEM, QTY and PRICE.
// Two copies plus the template row give three filled rows.
doc.addRowsToTable(0, 1, items, items.size() - 1)

Spreadsheets: named regions

Method Description Since
createNamedRegion(String name, String formula) Creates a named region 1.0.0
readNamedRegionRange(String name) Returns the range of a named region, for example Sheet1!$A$1:$C$10, or null 4.0.0
readNamedRegionValue(String name, int rowIndex, int colIndex) Returns the value at a 1-based position in a named region, or null 4.0.0
writeNamedRegion(String name, Object value) Writes a value to the top-left cell of a named region 4.1.0

writeNamedRegion is the Excel equivalent of filling a merge field by name. Names are those in Formulas > Name Manager in Excel. Numbers, dates, and booleans are written as such, null clears the cell, and any other value is written as text. An unknown name is logged and skipped, so one map of values can be applied to several templates. Running it twice on the same file gives the same result.

Fill named cells
def node = docman.getNode(234567)
def sheet = xlsx.loadSpreadsheet(node)

[CustomerName: 'Acme Ltd', Total: 1250.5, InvoiceDate: new Date()].each { name, value ->
    sheet.writeNamedRegion(name, value)
}
sheet.save(node)

The existing spreadsheet API is unchanged: cell reading (readCell, readCellAsInteger, readCellAsDouble, readCellAsDate, readCellAsBoolean), worksheets (getAllWorksheets, getWorksheet, getWorksheetByName, addWorksheet), rows (addRows, newRow), data validation (add...Constraint), formulas (updateFormulas, updateCellsFormulas, forceUpdateFormulas), and replaceVariables.

Presentations: named shapes

Method Description Since
replaceVariables(Map context, Integer slideIndex) Replaces ${..} placeholders, on one slide or on all slides (-1) 1.0.0
replaceSingleChar(String oldChar, String expression, Integer slide) Replaces a single-character placeholder 1.0.0
duplicateSlide(int SLIDE_INDEX, int INSERT_AT, int times) Duplicates a slide 1.0.0
updateNamedShapes(Map values, boolean includeContent, boolean includeLayouts, boolean includeMaster) Replaces the full text of each text shape whose name is a key in values 4.1.0
setNamedShapesVisible(Map visibility, boolean includeContent, boolean includeLayouts, boolean includeMaster) Shows (true) or hides (false) each shape whose name is a key in visibility 4.1.0

Shape names are the names in PowerPoint's Selection Pane. By default only content slides are scanned: includeContent is true, includeLayouts and includeMaster are false. Set them to also update slide layouts or slide masters.

updateNamedShapes replaces the whole text of a shape, so it is safe to run several times on the same file. Hidden shapes stay in the file, and a later run can show them again.

Fill and toggle shapes
def node = docman.getNode(345678)
def deck = pptx.loadPresentation(node)

deck.updateNamedShapes([Title: 'Q3 Results', Footer: 'Confidential'], true, false, true)
    .setNamedShapesVisible([DraftBanner: false], true, false, false)
deck.save(node)

Complete examples

Prepare a reviewed contract for signature

The script below reads the review state of a contract, accepts the internal changes, rejects one external change set, updates a clause as a tracked change, comments the change, and saves a clean copy for the counterparty.

sequenceDiagram
    participant Script
    participant Doc as CSWordDoc
    participant CS as Content Server
    Script->>CS: docman.getNode(id)
    Script->>Doc: docx.loadWordDoc(node)
    Script->>Doc: listRevisions() and listCommentAnchors()
    Script->>Doc: acceptRevisions(filter) and rejectRevisions(filter)
    Script->>Doc: listParagraphs(options)
    Script->>Doc: replaceText(index, start, end, text, options)
    Script->>Doc: addCommentToParagraph(...)
    Script->>CS: save(node) as a new version
    Script->>Doc: removeComments() and removePersonalInformation(options)
    Script->>CS: save(folder, name) as a new document
Contract review
def node = docman.getNode(123456)
def doc = docx.loadWordDoc(node)

// 1. Review state
def revisions = doc.listRevisions()
def openComments = doc.listCommentAnchors().findAll { !it.done }
log.info("${revisions.size()} tracked changes, ${openComments.size()} open comments")

// 2. Accept the internal team's changes, reject the counterparty's deletions
doc.acceptRevisions([authors: ['Anna Weber', 'Marco Rossi']])
doc.rejectRevisions([authors: ['External Counsel'], types: ['deletion']])

// 3. Change the payment term in clause 9.1 as a tracked change
def clause = doc.listParagraphs([numbering: true]).find { it.numberLabel == '9.1' }
if (clause) {
    int start = clause.text.indexOf('30 days')
    if (start >= 0) {
        doc.replaceText(clause.index, start, start + '30 days'.length(), '60 days',
                [track: true, author: 'Legal Review'])
        doc.addCommentToParagraph(clause.index, clause.index,
                'Payment term aligned with the framework agreement.', 'Legal Review', 'LR')
    }
}

// 4. Keep the reviewed document as a new version
doc.save(node)

// 5. Produce a clean copy for the counterparty
doc.acceptRevisions([:])
   .removeComments()
   .removePersonalInformation([authorAlias: 'Acme Legal'])
doc.save(node.parent, 'Contract - for signature.docx')

Compare two versions as text

Text of the last two versions
def node = docman.getNode(123456)
def versions = node.versions

def older = docx.loadWordDoc(versions[-2].content)
def newer = docx.loadWordDoc(versions[-1].content)

def opts = [revisions: 'accepted', includeNumbering: true]
def oldLines = older.toText(opts).readLines()
def newLines = newer.toText(opts).readLines()

def added = newLines - oldLines
def removed = oldLines - newLines
log.info("Added: ${added.size()}, removed: ${removed.size()}")

Fill a Word template, a spreadsheet, and a deck from the same values

One value map for three templates
def values = [CustomerName: 'Acme Ltd', ContractDate: '27.10.2026', Total: '1,250.50']
def folder = docman.getNode(2000)

def word = docx.loadWordDoc(docman.getNode(111111))
word.replaceFields(values, true, true)
word.save(folder, 'Acme - contract.docx')

def sheet = xlsx.loadSpreadsheet(docman.getNode(222222))
values.each { name, value -> sheet.writeNamedRegion(name, value) }   // unknown names are skipped
sheet.save(folder, 'Acme - pricing.xlsx')

def deck = pptx.loadPresentation(docman.getNode(333333))
deck.updateNamedShapes(values, true, false, false)
deck.save(folder, 'Acme - proposal.pptx')
  • Document Builder service: generate new Word and PDF documents from a Groovy builder.
  • Agents: agent tools that open attached Word files with docx.loadWordDoc and read revisions and comment anchors.
  • Content Management Object: nodes, versions, and resources used to load and save documents.