diff --git a/README.md b/README.md index 782e70c..a622876 100755 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Template-based docx report creation for both Node and the browser. ([See the blo * **Express your data needs (queries) in the template itself** (`QUERY` command), in whatever query language you want (e.g. in GraphQL). This is similar to _the Relay way™_: in [Relay](https://facebook.github.io/relay/), data requirements are declared alongside the React components that need the data * **Execute JavaScript snippets** (`EXEC` command, or `!` for short) * **Insert the result of JavaScript snippets** in your document (`INS`, `=` or just *nothing*) -* **Embed images, hyperlinks and even HTML dynamically** (`IMAGE`, `LINK`, `HTML`). Dynamic images can be great for on-the-fly QR codes, downloading photos straight to your reports, charts… even maps! +* **Embed images, hyperlinks, bookmarks and even HTML dynamically** (`IMAGE`, `LINK`, `BOOKMARK`, `HTML`). Dynamic images can be great for on-the-fly QR codes, downloading photos straight to your reports, charts… even maps! * Add **loops** with `FOR`/`END-FOR` commands, with support for table rows, nested loops, and JavaScript processing of elements (filter, sort, etc) * Include contents **conditionally**, `IF` a certain JavaScript expression is truthy * Define custom **aliases** for some commands (`ALIAS`) — useful for writing table templates! @@ -38,6 +38,7 @@ Contributions are welcome! - [`EXEC` (`!`)](#exec-) - [`IMAGE`](#image) - [`LINK`](#link) + - [`BOOKMARK`](#bookmark) - [`HTML`](#html) - [`FOR` and `END-FOR`](#for-and-end-for) - [`IF` and `END-IF`](#if-and-end-if) @@ -459,6 +460,26 @@ Includes a hyperlink with the data resulting from evaluating a JavaScript snippe If the `label` is not specified, the URL is used as a label. +To link to an internal bookmark (see [`BOOKMARK`](#bookmark) below), use the `bookmark` field instead of `url`: + +``` ++++LINK ({ bookmark: 'mySection', label: 'Go to My Section' })+++ +``` + +### `BOOKMARK` + +Inserts a bookmark anchor at the current position, which can be targeted by internal `LINK` commands: + +``` ++++BOOKMARK 'mySection'+++ +``` + +Place this command inside a paragraph (e.g. a heading styled paragraph in your template). The argument is a JavaScript expression that evaluates to the bookmark name string. An internal link pointing to this bookmark can then be created with: + +``` ++++LINK ({ bookmark: 'mySection', label: 'Go to My Section' })+++ +``` + ### `HTML` Takes the HTML resulting from evaluating a JavaScript snippet and converts it to Word contents. diff --git a/src/__tests__/__snapshots__/templating.test.ts.snap b/src/__tests__/__snapshots__/templating.test.ts.snap index acf224d..5de8b1c 100644 --- a/src/__tests__/__snapshots__/templating.test.ts.snap +++ b/src/__tests__/__snapshots__/templating.test.ts.snap @@ -26978,6 +26978,595 @@ exports[`noSandbox Template processing Access current element index from FOR loo " `; +exports[`noSandbox Template processing BOOKMARK and LINK with bookmark inside FOR loop 1`] = ` +{ + "_attrs": { + "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "xmlns:w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:id": "1", + "w:name": "section1", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkStart", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + { + "_attrs": { + "w:id": "1", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkEnd", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Introduction", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:id": "2", + "w:name": "section2", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkStart", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + { + "_attrs": { + "w:id": "2", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkEnd", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Details", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:anchor": "section1", + "w:history": "1", + }, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:val": "single", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:u", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:rPr", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Introduction", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:hyperlink", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:anchor": "section2", + "w:history": "1", + }, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:val": "single", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:u", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:rPr", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Details", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:hyperlink", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:sectPr", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:body", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " +", + }, + ], + "_fTextNode": false, + "_tag": "w:document", +} +`; + +exports[`noSandbox Template processing BOOKMARK inserts bookmarkStart and bookmarkEnd nodes around the run 1`] = ` +{ + "_attrs": { + "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "xmlns:w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:id": "1", + "w:name": "mySection", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkStart", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Section Heading", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + { + "_attrs": { + "w:id": "1", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkEnd", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Some body text.", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:sectPr", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:body", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " +", + }, + ], + "_fTextNode": false, + "_tag": "w:document", +} +`; + exports[`noSandbox Template processing Dynamic table columns 1`] = ` " @@ -27892,22 +28481,217 @@ exports[`noSandbox Template processing INS in header and footer 1`] = ` " `; -exports[`noSandbox Template processing Nested IF statements on same line 1`] = `[Error: Invalid IF command nested into another IF command on the same line: IF b]`; - -exports[`noSandbox Template processing Nested IF statements on same row1 1`] = `[Error: Invalid IF command nested into another IF command on the same table row: IF b]`; - -exports[`noSandbox Template processing Nested IF statements on same row2 1`] = `[Error: Invalid IF command nested into another IF command on the same table row: IF b]`; - -exports[`noSandbox Template processing Preserves anchored elements on empty paragraph 1`] = ` -" - - - - - - - - +exports[`noSandbox Template processing LINK with bookmark field creates an internal anchor link (no relationship entry) 1`] = ` +{ + "_attrs": { + "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "xmlns:w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:id": "1", + "w:name": "mySection", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkStart", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Section Heading", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + { + "_attrs": { + "w:id": "1", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkEnd", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:anchor": "mySection", + "w:history": "1", + }, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:val": "single", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:u", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:rPr", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Go to heading", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:hyperlink", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:sectPr", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:body", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " +", + }, + ], + "_fTextNode": false, + "_tag": "w:document", +} +`; + +exports[`noSandbox Template processing Nested IF statements on same line 1`] = `[Error: Invalid IF command nested into another IF command on the same line: IF b]`; + +exports[`noSandbox Template processing Nested IF statements on same row1 1`] = `[Error: Invalid IF command nested into another IF command on the same table row: IF b]`; + +exports[`noSandbox Template processing Nested IF statements on same row2 1`] = `[Error: Invalid IF command nested into another IF command on the same table row: IF b]`; + +exports[`noSandbox Template processing Preserves anchored elements on empty paragraph 1`] = ` +" + + + + + + + + @@ -59812,339 +60596,928 @@ exports[`sandbox Template processing 131 correctly handles Office 365 .docx file } `; -exports[`sandbox Template processing Access current element index from FOR loop 1`] = ` -" - - - - - - - - - Company - - - - - - - ( - 0 - - - - - - - - - - - - - - - - - ) - - - - - - - : - - - MEGACORP - - - - - - - - - - - - - - - - - - - - - Executives: - - - - - - - - - - - - 0 - - - - - - - - - - - - Excellent CEO - - - - - - - - - - - - 1 - - - - - - - - - - - - Someone - - - - - - - - - - Company - - - - - - - ( - 1 - - - - - - - - - - - - - - - - - ) - - - - - - - : - - - SUPERCORP - - - - - - - - - - - - - - - - - - - - - Executives: - - - - - - - - - - - - 0 - - - - - - - - - - - - John Smith - - - - - - - - - - Company - - - - - - - ( - 2 - - - - - - - - - - - - - - - - - ) - - - - - - - : - - - ULTRACORP - - - - - - - - - - - - - - - - - - - - - Executives: - - - - - - - - - - - - 0 - - - - - - - - - - - - Who else - - - - - - - - - - - - 1 - - - - - - - - - - - - Can't be me - - - - - - - - - - - -" -`; - +exports[`sandbox Template processing Access current element index from FOR loop 1`] = ` +" + + + + + + + + + Company + + + + + + + ( + 0 + + + + + + + + + + + + + + + + + ) + + + + + + + : + + + MEGACORP + + + + + + + + + + + + + + + + + + + + + Executives: + + + + + + + + + + + + 0 + + + + + + + + + + + + Excellent CEO + + + + + + + + + + + + 1 + + + + + + + + + + + + Someone + + + + + + + + + + Company + + + + + + + ( + 1 + + + + + + + + + + + + + + + + + ) + + + + + + + : + + + SUPERCORP + + + + + + + + + + + + + + + + + + + + + Executives: + + + + + + + + + + + + 0 + + + + + + + + + + + + John Smith + + + + + + + + + + Company + + + + + + + ( + 2 + + + + + + + + + + + + + + + + + ) + + + + + + + : + + + ULTRACORP + + + + + + + + + + + + + + + + + + + + + Executives: + + + + + + + + + + + + 0 + + + + + + + + + + + + Who else + + + + + + + + + + + + 1 + + + + + + + + + + + + Can't be me + + + + + + + + + + + +" +`; + +exports[`sandbox Template processing BOOKMARK and LINK with bookmark inside FOR loop 1`] = ` +{ + "_attrs": { + "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "xmlns:w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:id": "1", + "w:name": "section1", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkStart", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + { + "_attrs": { + "w:id": "1", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkEnd", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Introduction", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:id": "2", + "w:name": "section2", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkStart", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + { + "_attrs": { + "w:id": "2", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkEnd", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Details", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:anchor": "section1", + "w:history": "1", + }, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:val": "single", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:u", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:rPr", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Introduction", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:hyperlink", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:anchor": "section2", + "w:history": "1", + }, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:val": "single", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:u", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:rPr", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Details", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:hyperlink", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:sectPr", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:body", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " +", + }, + ], + "_fTextNode": false, + "_tag": "w:document", +} +`; + +exports[`sandbox Template processing BOOKMARK inserts bookmarkStart and bookmarkEnd nodes around the run 1`] = ` +{ + "_attrs": { + "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "xmlns:w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:id": "1", + "w:name": "mySection", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkStart", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Section Heading", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + { + "_attrs": { + "w:id": "1", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkEnd", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Some body text.", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:sectPr", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:body", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " +", + }, + ], + "_fTextNode": false, + "_tag": "w:document", +} +`; + exports[`sandbox Template processing Dynamic table columns 1`] = ` " @@ -61059,6 +62432,201 @@ exports[`sandbox Template processing INS in header and footer 1`] = ` " `; +exports[`sandbox Template processing LINK with bookmark field creates an internal anchor link (no relationship entry) 1`] = ` +{ + "_attrs": { + "xmlns:r": "http://schemas.openxmlformats.org/officeDocument/2006/relationships", + "xmlns:w": "http://schemas.openxmlformats.org/wordprocessingml/2006/main", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:id": "1", + "w:name": "mySection", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkStart", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + { + "_attrs": { + "xml:space": "preserve", + }, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Section Heading", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + { + "_attrs": { + "w:id": "1", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:bookmarkEnd", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:anchor": "mySection", + "w:history": "1", + }, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": {}, + "_children": [ + { + "_attrs": { + "w:val": "single", + }, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:u", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:rPr", + }, + { + "_attrs": {}, + "_children": [ + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": "Go to heading", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:t", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:r", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:hyperlink", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:p", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + { + "_attrs": {}, + "_children": [], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:sectPr", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " + ", + }, + ], + "_fTextNode": false, + "_parent": [Circular], + "_tag": "w:body", + }, + { + "_children": [], + "_fTextNode": true, + "_parent": [Circular], + "_text": " +", + }, + ], + "_fTextNode": false, + "_tag": "w:document", +} +`; + exports[`sandbox Template processing Nested IF statements on same line 1`] = `[Error: Invalid IF command nested into another IF command on the same line: IF b]`; exports[`sandbox Template processing Nested IF statements on same row1 1`] = `[Error: Invalid IF command nested into another IF command on the same table row: IF b]`; diff --git a/src/__tests__/fixtures/bookmark-basic.docx b/src/__tests__/fixtures/bookmark-basic.docx new file mode 100644 index 0000000..19bec6c Binary files /dev/null and b/src/__tests__/fixtures/bookmark-basic.docx differ diff --git a/src/__tests__/fixtures/bookmark-link.docx b/src/__tests__/fixtures/bookmark-link.docx new file mode 100644 index 0000000..280ac02 Binary files /dev/null and b/src/__tests__/fixtures/bookmark-link.docx differ diff --git a/src/__tests__/fixtures/bookmark-loop.docx b/src/__tests__/fixtures/bookmark-loop.docx new file mode 100644 index 0000000..56eff6c Binary files /dev/null and b/src/__tests__/fixtures/bookmark-loop.docx differ diff --git a/src/__tests__/templating.test.ts b/src/__tests__/templating.test.ts index 9e21c50..ba3449f 100755 --- a/src/__tests__/templating.test.ts +++ b/src/__tests__/templating.test.ts @@ -954,6 +954,96 @@ Morbi dignissim consequat ex, non finibus est faucibus sodales. Integer sed just expect(await createReport(opts, 'JS')).toMatchSnapshot(); }); + it('BOOKMARK inserts bookmarkStart and bookmarkEnd nodes around the run', async () => { + const template = await fs.promises.readFile( + path.join(__dirname, 'fixtures', 'bookmark-basic.docx') + ); + const opts = { noSandbox, template, data: {} }; + expect(await createReport(opts, 'JS')).toMatchSnapshot(); + + // Verify the generated XML contains bookmarkStart/End with the correct name + const zip = await JSZip.loadAsync(await createReport(opts)); + const docXml = await zip.file('word/document.xml')!.async('string'); + expect(docXml).toContain('w:bookmarkStart'); + expect(docXml).toContain('w:bookmarkEnd'); + expect(docXml).toContain('w:name="mySection"'); + // bookmarkStart and bookmarkEnd must share the same w:id + const startId = docXml.match(/w:bookmarkStart[^>]*w:id="(\d+)"/)?.[1]; + const endId = docXml.match(/w:bookmarkEnd[^>]*w:id="(\d+)"/)?.[1]; + expect(startId).toBeDefined(); + expect(startId).toEqual(endId); + }); + + it('LINK with bookmark field creates an internal anchor link (no relationship entry)', async () => { + const template = await fs.promises.readFile( + path.join(__dirname, 'fixtures', 'bookmark-link.docx') + ); + const opts = { noSandbox, template, data: {} }; + expect(await createReport(opts, 'JS')).toMatchSnapshot(); + + const zip = await JSZip.loadAsync(await createReport(opts)); + const docXml = await zip.file('word/document.xml')!.async('string'); + // Internal link uses w:anchor, not r:id + expect(docXml).toContain('w:anchor="mySection"'); + expect(docXml).not.toContain('r:id="link'); + // No hyperlink relationship should be added + const relsXml = await zip + .file('word/_rels/document.xml.rels')! + .async('string'); + expect(relsXml).not.toContain('hyperlink'); + }); + + it('BOOKMARK and LINK with bookmark inside FOR loop', async () => { + const template = await fs.promises.readFile( + path.join(__dirname, 'fixtures', 'bookmark-loop.docx') + ); + const loopData = { + items: [ + { anchor: 'section1', title: 'Introduction' }, + { anchor: 'section2', title: 'Details' }, + ], + }; + const opts = { noSandbox, template, data: loopData }; + expect(await createReport(opts, 'JS')).toMatchSnapshot(); + + const zip = await JSZip.loadAsync(await createReport(opts)); + const docXml = await zip.file('word/document.xml')!.async('string'); + // Both bookmarks should be present + expect(docXml).toContain('w:name="section1"'); + expect(docXml).toContain('w:name="section2"'); + // Both internal links should be present + expect(docXml).toContain('w:anchor="section1"'); + expect(docXml).toContain('w:anchor="section2"'); + // No external hyperlink relationships should be added + const relsXml = await zip + .file('word/_rels/document.xml.rels')! + .async('string'); + expect(relsXml).not.toContain('hyperlink'); + }); + + it('BOOKMARK IDs are unique across multiple bookmarks', async () => { + const template = await fs.promises.readFile( + path.join(__dirname, 'fixtures', 'bookmark-loop.docx') + ); + const opts = { + noSandbox, + template, + data: { + items: [ + { anchor: 'alpha', title: 'Alpha' }, + { anchor: 'beta', title: 'Beta' }, + ], + }, + }; + const zip = await JSZip.loadAsync(await createReport(opts)); + const docXml = await zip.file('word/document.xml')!.async('string'); + const ids = Array.from( + docXml.matchAll(/w:bookmarkStart[^>]*w:id="(\d+)"/g) + ).map(m => m[1]); + expect(ids).toHaveLength(2); + expect(new Set(ids).size).toBe(2); // all IDs are unique + }); + it('112a failFast: false lists all errors in the document before failing.', async () => { const template = await fs.promises.readFile( path.join(__dirname, 'fixtures', 'invalidMultipleErrors.docx') diff --git a/src/processTemplate.ts b/src/processTemplate.ts index 2d11876..c5be8b4 100755 --- a/src/processTemplate.ts +++ b/src/processTemplate.ts @@ -56,6 +56,7 @@ export function newContext( images: {}, linkId: 0, links: {}, + bookmarkId: 0, htmlId: 0, htmls: {}, vars: {}, @@ -407,6 +408,29 @@ export async function walkTemplate( delete ctx.pendingLinkNode; } + // If a bookmark was generated, insert bookmarkStart/End around the `w:r` node + if ( + ctx.pendingBookmarkNodes && + !nodeOut._fTextNode && + nodeOut._tag === 'w:r' + ) { + const [bookmarkStart, bookmarkEnd] = ctx.pendingBookmarkNodes; + const parent = nodeOut._parent; + if (parent) { + const idx = parent._children.indexOf(nodeOut); + bookmarkStart._parent = parent; + bookmarkEnd._parent = parent; + parent._children.splice(idx, 0, bookmarkStart); + parent._children.splice(idx + 2, 0, bookmarkEnd); + + // Prevent containing paragraph or table row from being removed + ctx.buffers['w:p'].fInsertedText = true; + ctx.buffers['w:tr'].fInsertedText = true; + ctx.buffers['w:tc'].fInsertedText = true; + } + delete ctx.pendingBookmarkNodes; + } + // If a html page was generated, replace the parent `w:p` node with // the html node if ( @@ -739,6 +763,17 @@ const processCmd: CommandProcessor = async ( if (pars != null) await processLink(ctx, pars); } + // BOOKMARK + } else if (cmdName === 'BOOKMARK') { + if (!isLoopExploring(ctx)) { + const bookmarkName: string | undefined = await runUserJsAndGetRaw( + data, + cmdRest, + ctx + ); + if (bookmarkName != null) processBookmark(ctx, bookmarkName); + } + // HTML } else if (cmdName === 'HTML') { if (!isLoopExploring(ctx)) { @@ -1135,21 +1170,49 @@ function getImageData(imagePars: ImagePars): Image { } const processLink = async (ctx: Context, linkPars: LinkPars) => { - const { url, label = url } = linkPars; - ctx.linkId += 1; - const id = String(ctx.linkId); - const relId = `link${id}`; - ctx.links[relId] = { url }; + const { url, label, bookmark } = linkPars; const node = newNonTextNode; const { textRunPropsNode } = ctx; - const link = node('w:hyperlink', { 'r:id': relId, 'w:history': '1' }, [ - node('w:r', {}, [ - textRunPropsNode || - node('w:rPr', {}, [node('w:u', { 'w:val': 'single' })]), - node('w:t', {}, [newTextNode(label)]), - ]), - ]); - ctx.pendingLinkNode = link; + const runProps = + textRunPropsNode || node('w:rPr', {}, [node('w:u', { 'w:val': 'single' })]); + + if (bookmark != null) { + const displayLabel = label ?? bookmark; + const link = node( + 'w:hyperlink', + { 'w:anchor': bookmark, 'w:history': '1' }, + [ + node('w:r', {}, [ + runProps, + node('w:t', {}, [newTextNode(displayLabel)]), + ]), + ] + ); + ctx.pendingLinkNode = link; + } else { + const displayLabel = label ?? url ?? ''; + ctx.linkId += 1; + const id = String(ctx.linkId); + const relId = `link${id}`; + ctx.links[relId] = { url: url ?? '' }; + const link = node('w:hyperlink', { 'r:id': relId, 'w:history': '1' }, [ + node('w:r', {}, [ + runProps, + node('w:t', {}, [newTextNode(displayLabel)]), + ]), + ]); + ctx.pendingLinkNode = link; + } +}; + +const processBookmark = (ctx: Context, bookmarkName: string) => { + ctx.bookmarkId += 1; + const id = String(ctx.bookmarkId); + const node = newNonTextNode; + ctx.pendingBookmarkNodes = [ + node('w:bookmarkStart', { 'w:id': id, 'w:name': bookmarkName }), + node('w:bookmarkEnd', { 'w:id': id }), + ]; }; const processHtml = async (ctx: Context, data: string) => { diff --git a/src/types.ts b/src/types.ts index 608fce2..ad85a65 100755 --- a/src/types.ts +++ b/src/types.ts @@ -190,6 +190,8 @@ export type Context = { pendingLinkNode?: NonTextNode; linkId: number; links: Links; + pendingBookmarkNodes?: [NonTextNode, NonTextNode]; + bookmarkId: number; pendingHtmlNode?: TextNode | NonTextNode; htmlId: number; htmls: Htmls; @@ -282,8 +284,9 @@ export type ImagePars = { }; export type LinkPars = { - url: string; + url?: string; label?: string; + bookmark?: string; }; export type CommandSummary = { @@ -307,4 +310,5 @@ export const BUILT_IN_COMMANDS = [ 'IMAGE', 'LINK', 'HTML', + 'BOOKMARK', ] as const;