diff --git a/.gitignore b/.gitignore index 4233d232997..ef25b06c459 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ node_modules # build dist +build # Documentation docs/api/ diff --git a/demo/28-table-of-contents.ts b/demo/28-table-of-contents.ts index 20339b6e3f9..142dfdc97c0 100644 --- a/demo/28-table-of-contents.ts +++ b/demo/28-table-of-contents.ts @@ -1,7 +1,7 @@ // Table of contents import * as fs from "fs"; -import { File, HeadingLevel, Packer, Paragraph, StyleLevel, TableOfContents } from "docx"; +import { Bookmark, File, HeadingLevel, Packer, Paragraph, StyleLevel, TableOfContents } from "docx"; // WordprocessingML docs for TableOfContents can be found here: // http://officeopenxml.com/WPtableOfContents.php @@ -30,15 +30,46 @@ const doc = new File({ sections: [ { children: [ - new TableOfContents("Summary", { - hyperlink: true, - headingStyleRange: "1-5", - stylesWithLevels: [new StyleLevel("MySpectacularStyle", 1)], - }), + new TableOfContents( + "Summary", + { + hyperlink: true, + headingStyleRange: "1-5", + stylesWithLevels: [new StyleLevel("MySpectacularStyle", 1)], + }, + [ + { + title: "Header #1", + level: 1, + page: 1, + href: "anchorForHeader1", + }, + { + title: "Header #2", + level: 1, + page: 2, + }, + { + title: "Header #2.1", + level: 2, + }, + { + title: "My Spectacular Style #1", + level: 1, + page: 3, + }, + ], + ), new Paragraph({ text: "Header #1", heading: HeadingLevel.HEADING_1, pageBreakBefore: true, + children: [ + new Bookmark({ + id: "anchorForHeader1", + children: [], + }), + ], }), new Paragraph("I'm a little text very nicely written.'"), new Paragraph({ diff --git a/demo/index.ts b/demo/index.ts index b687008792c..22ad2a9e631 100644 --- a/demo/index.ts +++ b/demo/index.ts @@ -22,31 +22,38 @@ const getFileNumber = (file: string): number => { const demoFiles = keys.filter((file) => !isNaN(getFileNumber(file))).sort((a, b) => getFileNumber(a) - getFileNumber(b)); -const answers = await inquirer.prompt([ - { - type: "list", - name: "type", - message: "Select demo from a list or via number", - choices: ["list", "number"], - }, - { - type: "list", - name: "demoFile", - message: "What demo do you wish to run?", - choices: demoFiles, - filter: (input) => parseInt(input.split("-")[0], 10), - when: (a) => a.type === "list", - }, - { - type: "number", - name: "demoNumber", - message: "What demo do you wish to run? (Enter a number)", - default: 1, - when: (a) => a.type === "number", - }, -]); - -const demoNumber = answers.demoNumber ?? answers.demoFile ?? 1; +const firstArg = process.argv[2]; +const firstArgNumber = Number.parseInt(firstArg, 10); +let demoNumber: number; +if (firstArg && !isNaN(firstArgNumber)) { + demoNumber = firstArgNumber; +} else { + const answers = await inquirer.prompt([ + { + type: "list", + name: "type", + message: "Select demo from a list or via number", + choices: ["list", "number"], + }, + { + type: "list", + name: "demoFile", + message: "What demo do you wish to run?", + choices: demoFiles, + filter: (input) => parseInt(input.split("-")[0], 10), + when: (a) => a.type === "list", + }, + { + type: "number", + name: "demoNumber", + message: "What demo do you wish to run? (Enter a number)", + default: 1, + when: (a) => a.type === "number", + }, + ]); + demoNumber = answers.demoNumber ?? answers.demoFile ?? 1; +} + const files = fs.readdirSync(dir).filter((fn) => fn.startsWith(demoNumber.toString())); if (files.length === 0) { diff --git a/docs/usage/table-of-contents.md b/docs/usage/table-of-contents.md index 4a88508bc8b..a13949c09ef 100644 --- a/docs/usage/table-of-contents.md +++ b/docs/usage/table-of-contents.md @@ -31,9 +31,9 @@ const doc = new Document({ heading: HeadingLevel.HEADING_1, pageBreakBefore: true, }), - ] - } - ] + ], + }, + ], }); ``` @@ -60,8 +60,52 @@ Here is the list of all options that you can use to generate your tables of cont | preserveNewLineInEntries | boolean | `\x` | Preserves newline characters within table entries. | | hideTabAndPageNumbersInWebView | boolean | `\z` | Hides tab leader and page numbers in web page view (ยง17.18.102). | +## Cached entries + +If you already know what might be in the table, you can provide those entries to the constructor as well, and the table of contents will be populated with that data. + +```ts +const doc = new Document({ + features: { + updateFields: true, + }, + sections: [ + { + children: [ + new TableOfContents( + "Summary", + { + hyperlink: true, + headingStyleRange: "1-5", + }, + // Cached entries + [ + { + text: "Header #1", + level: 1, + page: 1, + href: "anchorForHeader1", + }, + ], + ), + new Paragraph({ + text: "Header #1", + heading: HeadingLevel.HEADING_1, + pageBreakBefore: true, + children: [ + new Bookmark({ + id: "anchorForHeader1", + }), + ], + }), + ], + }, + ], +}); +``` + ## Examples -[Example](https://raw.githubusercontent.com/dolanmiu/docx/master/demo/28-table-of-contents.ts ':include') +[Example](https://raw.githubusercontent.com/dolanmiu/docx/master/demo/28-table-of-contents.ts ":include") _Source: https://github.com/dolanmiu/docx/blob/master/demo/28-table-of-contents.ts_ diff --git a/src/file/table-of-contents/table-of-contents.spec.ts b/src/file/table-of-contents/table-of-contents.spec.ts index 0d011446a94..a8c6ffe0f01 100644 --- a/src/file/table-of-contents/table-of-contents.spec.ts +++ b/src/file/table-of-contents/table-of-contents.spec.ts @@ -39,6 +39,410 @@ describe("Table of Contents", () => { const tree = new Formatter().format(toc); expect(tree).to.be.deep.equal(COMPLETE_TOC); }); + + describe("cached content", () => { + it("should construct a TOC with cached content", () => { + const cachedContent = [ + { title: "Introduction", level: 1, page: 1 }, + { title: "Getting Started", level: 2, page: 3 }, + { title: "Advanced Topics", level: 2, page: 10 }, + ]; + + const toc = new TableOfContents("Table of Contents", undefined, cachedContent); + const tree = new Formatter().format(toc); + + const expectedParagraphs = [ + { + "w:p": [ + { + "w:pPr": [ + { + "w:pStyle": { + _attr: { + "w:val": "TOC1", + }, + }, + }, + { + "w:tabs": [ + { + "w:tab": { + _attr: { + "w:val": "clear", + "w:pos": 9026, + }, + }, + }, + { + "w:tab": { + _attr: { + "w:val": "right", + "w:pos": 9025, + "w:leader": "dot", + }, + }, + }, + ], + }, + ], + }, + { + "w:r": [ + { + "w:fldChar": { + _attr: { + "w:fldCharType": "begin", + "w:dirty": true, + }, + }, + }, + { + "w:instrText": [ + { + _attr: { + "xml:space": "preserve", + }, + }, + "TOC", + ], + }, + { + "w:fldChar": { + _attr: { + "w:fldCharType": "separate", + }, + }, + }, + ], + }, + { + "w:r": [ + { + "w:t": [ + { + _attr: { + "xml:space": "default", + }, + }, + "Introduction", + ], + }, + { + "w:tab": {}, + }, + { + "w:t": [ + { + _attr: { + "xml:space": "default", + }, + }, + "1", + ], + }, + ], + }, + ], + }, + { + "w:p": [ + { + "w:pPr": [ + { + "w:pStyle": { + _attr: { + "w:val": "TOC2", + }, + }, + }, + { + "w:tabs": [ + { + "w:tab": { + _attr: { + "w:val": "clear", + "w:pos": 8306, + }, + }, + }, + { + "w:tab": { + _attr: { + "w:val": "right", + "w:pos": 9025, + "w:leader": "dot", + }, + }, + }, + ], + }, + ], + }, + { + "w:r": [ + { + "w:t": [ + { + _attr: { + "xml:space": "default", + }, + }, + "Getting Started", + ], + }, + { + "w:tab": {}, + }, + { + "w:t": [ + { + _attr: { + "xml:space": "default", + }, + }, + "3", + ], + }, + ], + }, + ], + }, + { + "w:p": [ + { + "w:pPr": [ + { + "w:pStyle": { + _attr: { + "w:val": "TOC2", + }, + }, + }, + { + "w:tabs": [ + { + "w:tab": { + _attr: { + "w:val": "clear", + "w:pos": 8306, + }, + }, + }, + { + "w:tab": { + _attr: { + "w:val": "right", + "w:pos": 9025, + "w:leader": "dot", + }, + }, + }, + ], + }, + ], + }, + { + "w:r": [ + { + "w:t": [ + { + _attr: { + "xml:space": "default", + }, + }, + "Advanced Topics", + ], + }, + { + "w:tab": {}, + }, + { + "w:t": [ + { + _attr: { + "xml:space": "default", + }, + }, + "10", + ], + }, + ], + }, + { + "w:r": [ + { + "w:fldChar": { + _attr: { + "w:fldCharType": "end", + }, + }, + }, + ], + }, + ], + }, + ]; + + const expectedTree = { + "w:sdt": [ + { + "w:sdtPr": [ + { + "w:alias": { + _attr: { + "w:val": "Table of Contents", + }, + }, + }, + ], + }, + { + "w:sdtContent": expectedParagraphs, + }, + ], + }; + expect(tree).to.be.deep.equal(expectedTree); + }); + + it("should fill in an end paragraph if only one cached entry is provided", () => { + const cachedContent = [{ title: "Only Entry", level: 1, page: 1 }]; + const toc = new TableOfContents("Table of Contents", undefined, cachedContent); + const tree = new Formatter().format(toc); + + const expectedParagraphs = [ + // cached entry paragraph + { + "w:p": [ + { + "w:pPr": [ + { + "w:pStyle": { + _attr: { + "w:val": "TOC1", + }, + }, + }, + { + "w:tabs": [ + { + "w:tab": { + _attr: { + "w:val": "clear", + "w:pos": 9026, + }, + }, + }, + { + "w:tab": { + _attr: { + "w:val": "right", + "w:pos": 9025, + "w:leader": "dot", + }, + }, + }, + ], + }, + ], + }, + { + "w:r": [ + { + "w:fldChar": { + _attr: { + "w:fldCharType": "begin", + "w:dirty": true, + }, + }, + }, + { + "w:instrText": [ + { + _attr: { + "xml:space": "preserve", + }, + }, + "TOC", + ], + }, + { + "w:fldChar": { + _attr: { + "w:fldCharType": "separate", + }, + }, + }, + ], + }, + { + "w:r": [ + { + "w:t": [ + { + _attr: { + "xml:space": "default", + }, + }, + "Only Entry", + ], + }, + { + "w:tab": {}, + }, + { + "w:t": [ + { + _attr: { + "xml:space": "default", + }, + }, + "1", + ], + }, + ], + }, + ], + }, + + // End paragraph + { + "w:p": [ + { + "w:r": [ + { + "w:fldChar": { + _attr: { + "w:fldCharType": "end", + }, + }, + }, + ], + }, + ], + }, + ]; + + const expectedTree = { + "w:sdt": [ + { + "w:sdtPr": [ + { + "w:alias": { + _attr: { + "w:val": "Table of Contents", + }, + }, + }, + ], + }, + { + "w:sdtContent": expectedParagraphs, + }, + ], + }; + expect(tree).to.be.deep.equal(expectedTree); + }); + }); }); }); diff --git a/src/file/table-of-contents/table-of-contents.ts b/src/file/table-of-contents/table-of-contents.ts index e3a110bb4a7..6699f13aa29 100644 --- a/src/file/table-of-contents/table-of-contents.ts +++ b/src/file/table-of-contents/table-of-contents.ts @@ -1,42 +1,124 @@ // http://officeopenxml.com/WPtableOfContents.php // http://www.datypic.com/sc/ooxml/e-w_sdt-1.html import { FileChild } from "@file/file-child"; -import { Paragraph } from "@file/paragraph"; -import { Run } from "@file/paragraph/run"; +import { InternalHyperlink, Paragraph, TabStopDefinition } from "@file/paragraph"; +import { Run, Tab } from "@file/paragraph/run"; import { Begin, End, Separate } from "@file/paragraph/run/field"; +import { Text } from "@file/paragraph/run/run-components/text"; import { FieldInstruction } from "./field-instruction"; import { StructuredDocumentTagContent } from "./sdt-content"; import { StructuredDocumentTagProperties } from "./sdt-properties"; import { ITableOfContentsOptions } from "./table-of-contents-properties"; +type ToCEntry = { + readonly title: string; + readonly level: number; + readonly page?: number; + readonly href?: string; +}; + export class TableOfContents extends FileChild { - public constructor(alias: string = "Table of Contents", properties?: ITableOfContentsOptions) { + public constructor(alias: string = "Table of Contents", properties?: ITableOfContentsOptions, cachedContent: readonly ToCEntry[] = []) { super("w:sdt"); this.root.push(new StructuredDocumentTagProperties(alias)); const content = new StructuredDocumentTagContent(); - const beginParagraph = new Paragraph({ - children: [ - new Run({ - children: [new Begin(true), new FieldInstruction(properties), new Separate()], - }), - ], + const beginParagraphMandatoryChidlren = [ + new Run({ + children: [new Begin(true), new FieldInstruction(properties), new Separate()], + }), + ]; + + const endParagraphMandatoryChildren = [ + new Run({ + children: [new End()], + }), + ]; + + const cachedParagraphs = cachedContent.map((entry, i) => { + const contentChild = this.buildCachedContentParagraphChild(entry, properties); + const children = + i === 0 + ? [...beginParagraphMandatoryChidlren, contentChild] + : i === cachedContent.length - 1 + ? [contentChild, ...endParagraphMandatoryChildren] + : [contentChild]; + + return new Paragraph({ + style: `TOC${entry.level}`, + tabStops: this.getTabStopsForLevel(entry.level), + children, + }); }); - content.addChildElement(beginParagraph); + let paragraphs = cachedParagraphs; + if (cachedContent.length <= 0) { + paragraphs = [ + new Paragraph({ + children: beginParagraphMandatoryChidlren, + }), + new Paragraph({ + children: endParagraphMandatoryChildren, + }), + ]; + } else if (cachedContent.length <= 1) { + paragraphs = [ + ...cachedParagraphs, + new Paragraph({ + children: endParagraphMandatoryChildren, + }), + ]; + } - const endParagraph = new Paragraph({ + for (const paragraph of paragraphs) { + content.addChildElement(paragraph); + } + + this.root.push(content); + } + + private getTabStopsForLevel(level: number, pageWidth: number = 9025): readonly TabStopDefinition[] { + const levelSpace = 720; + const levelPosition = pageWidth + 1 - (level - 1) * levelSpace; // TODO: should be equal to page width + 1 - level margin + return [ + { + type: "clear", + position: levelPosition, + }, + { + type: "right", + position: pageWidth, + leader: "dot", + }, + ]; + } + + private buildCachedContentRun(entry: ToCEntry, properties?: ITableOfContentsOptions): Run { + return new Run({ + style: properties?.hyperlink && entry.href !== undefined ? "IndexLink" : undefined, children: [ - new Run({ - children: [new End()], + new Text({ + text: entry.title, + }), + new Tab(), + new Text({ + text: entry.page?.toString() ?? "", }), ], }); + } - content.addChildElement(endParagraph); + private buildCachedContentParagraphChild(entry: ToCEntry, properties?: ITableOfContentsOptions): Run | InternalHyperlink { + const run = this.buildCachedContentRun(entry); + if (properties?.hyperlink && entry.href !== undefined) { + return new InternalHyperlink({ + anchor: entry.href, + children: [run], + }); + } - this.root.push(content); + return run; } }