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;