From 0b88cb0ca57fedd258f56cc48c7e3e111a2d6c60 Mon Sep 17 00:00:00 2001 From: Dolan Date: Fri, 12 Mar 2021 03:58:05 +0000 Subject: [PATCH] #548 #508 Restart numbered lists --- demo/29-numbered-lists.ts | 30 ++++-- package-lock.json | 22 +--- package.json | 3 +- src/convenience-functions.spec.ts | 17 ++- src/convenience-functions.ts | 12 +++ src/export/packer/numbering-replacer.ts | 9 +- src/file/media/media.spec.ts | 29 +++-- src/file/media/media.ts | 9 +- src/file/numbering/abstract-numbering.ts | 10 +- src/file/numbering/concrete-numbering.spec.ts | 102 ++++++++++++------ src/file/numbering/num.ts | 58 +++++----- src/file/numbering/numbering.spec.ts | 74 +++++++++++++ src/file/numbering/numbering.ts | 78 +++++++++----- src/file/paragraph/links/bookmark.ts | 5 +- src/file/paragraph/links/hyperlink.ts | 5 +- src/file/paragraph/paragraph.spec.ts | 22 ++-- src/file/paragraph/paragraph.ts | 4 +- src/file/paragraph/properties.spec.ts | 69 ++++++++++++ src/file/paragraph/properties.ts | 23 +++- src/index.spec.ts | 12 +++ 20 files changed, 430 insertions(+), 163 deletions(-) create mode 100644 src/file/paragraph/properties.spec.ts create mode 100644 src/index.spec.ts diff --git a/demo/29-numbered-lists.ts b/demo/29-numbered-lists.ts index 77f50381d1..b1aa736479 100644 --- a/demo/29-numbered-lists.ts +++ b/demo/29-numbered-lists.ts @@ -1,7 +1,8 @@ // Numbered lists +// The lists can also be restarted by specifying the instance number // Import from 'docx' rather than '../build' if you install from npm import * as fs from "fs"; -import { AlignmentType, convertInchesToTwip, Document, LevelFormat, Packer, Paragraph } from "../build"; +import { AlignmentType, convertInchesToTwip, Document, HeadingLevel, LevelFormat, Packer, Paragraph } from "../build"; const doc = new Document({ numbering: { @@ -125,11 +126,16 @@ doc.addSection({ level: 0, }, }), + new Paragraph({ + text: "Next", + heading: HeadingLevel.HEADING_2, + }), new Paragraph({ text: "test", numbering: { reference: "padded-numbering-reference", level: 0, + instance: 2, }, }), new Paragraph({ @@ -137,6 +143,19 @@ doc.addSection({ numbering: { reference: "padded-numbering-reference", level: 0, + instance: 2, + }, + }), + new Paragraph({ + text: "Next", + heading: HeadingLevel.HEADING_2, + }), + new Paragraph({ + text: "test", + numbering: { + reference: "padded-numbering-reference", + level: 0, + instance: 3, }, }), new Paragraph({ @@ -144,6 +163,7 @@ doc.addSection({ numbering: { reference: "padded-numbering-reference", level: 0, + instance: 3, }, }), new Paragraph({ @@ -151,14 +171,12 @@ doc.addSection({ numbering: { reference: "padded-numbering-reference", level: 0, + instance: 3, }, }), new Paragraph({ - text: "test", - numbering: { - reference: "padded-numbering-reference", - level: 0, - }, + text: "Next", + heading: HeadingLevel.HEADING_2, }), new Paragraph({ text: "test", diff --git a/package-lock.json b/package-lock.json index 8a44167697..d67a18b7ad 100644 --- a/package-lock.json +++ b/package-lock.json @@ -605,12 +605,6 @@ "@types/request": "*" } }, - "@types/shortid": { - "version": "0.0.29", - "resolved": "https://registry.npmjs.org/@types/shortid/-/shortid-0.0.29.tgz", - "integrity": "sha1-gJPuBBam4r8qpjOBCRFLP7/6Dps=", - "dev": true - }, "@types/sinon": { "version": "9.0.11", "resolved": "https://registry.npmjs.org/@types/sinon/-/sinon-9.0.11.tgz", @@ -4875,7 +4869,7 @@ }, "jsesc": { "version": "1.3.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", + "resolved": "http://registry.npmjs.org/jsesc/-/jsesc-1.3.0.tgz", "integrity": "sha1-RsP+yMGJKxKwgz25vHYiF226s0s=", "dev": true }, @@ -5592,9 +5586,9 @@ "optional": true }, "nanoid": { - "version": "2.1.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-2.1.11.tgz", - "integrity": "sha512-s/snB+WGm6uwi0WjsZdaVcuf3KJXlfGl2LcxgwkEwJF0D/BWzVWAZW/XY4bFaiR7s0Jk3FPvlnepg1H1b1UwlA==" + "version": "3.1.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.1.20.tgz", + "integrity": "sha512-a1cQNyczgKbLX9jwbS/+d7W8fX/RfgYR7lVWwWOGIPNgK2m0MWvrGF6/m4kk6U3QcFMnZf3RIhL0v2Jgh/0Uxw==" }, "nanomatch": { "version": "1.2.13", @@ -7360,14 +7354,6 @@ "vscode-textmate": "^5.2.0" } }, - "shortid": { - "version": "2.2.16", - "resolved": "https://registry.npmjs.org/shortid/-/shortid-2.2.16.tgz", - "integrity": "sha512-Ugt+GIZqvGXCIItnsL+lvFJOiN7RYqlGy7QE41O3YC1xbNSeDGIRO7xg2JJXIAj1cAGnOeC1r7/T9pgrtQbv4g==", - "requires": { - "nanoid": "^2.1.0" - } - }, "signal-exit": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.2.tgz", diff --git a/package.json b/package.json index 12f789713a..9ac065ce73 100644 --- a/package.json +++ b/package.json @@ -52,7 +52,7 @@ "@types/jszip": "^3.1.4", "@types/node": "^14.0.5", "jszip": "^3.1.5", - "shortid": "^2.2.15", + "nanoid": "^3.1.20", "xml": "^1.0.1", "xml-js": "^1.6.8" }, @@ -66,7 +66,6 @@ "@types/chai": "^4.2.15", "@types/mocha": "^8.0.0", "@types/request-promise": "^4.1.42", - "@types/shortid": "0.0.29", "@types/sinon": "^9.0.4", "@types/webpack": "^4.4.24", "awesome-typescript-loader": "^3.4.1", diff --git a/src/convenience-functions.spec.ts b/src/convenience-functions.spec.ts index 3333fdd2d0..eede5ef5be 100644 --- a/src/convenience-functions.spec.ts +++ b/src/convenience-functions.spec.ts @@ -1,5 +1,6 @@ import { expect } from "chai"; -import { convertInchesToTwip, convertMillimetersToTwip } from "./convenience-functions"; + +import { convertInchesToTwip, convertMillimetersToTwip, uniqueId, uniqueNumericId } from "./convenience-functions"; describe("Utility", () => { describe("#convertMillimetersToTwip", () => { @@ -15,4 +16,18 @@ describe("Utility", () => { expect(convertInchesToTwip(0.25)).to.equal(360); }); }); + + describe("#uniqueNumericId", () => { + it("should generate a unique ID", () => { + // tslint:disable-next-line: no-unused-expression + expect(uniqueNumericId()).to.not.be.empty; + }); + }); + + describe("#uniqueId", () => { + it("should call the underlying header's addChildElement", () => { + // tslint:disable-next-line: no-unused-expression + expect(uniqueId()).to.not.be.empty; + }); + }); }); diff --git a/src/convenience-functions.ts b/src/convenience-functions.ts index 6fe110b72b..0e2cb5afdb 100644 --- a/src/convenience-functions.ts +++ b/src/convenience-functions.ts @@ -1,3 +1,7 @@ +import { customAlphabet, nanoid } from "nanoid/non-secure"; + +const numericNanoId = customAlphabet("0123456789", 15); + // Twip - twentieths of a point export const convertMillimetersToTwip = (millimeters: number): number => { return Math.floor((millimeters / 25.4) * 72 * 20); @@ -6,3 +10,11 @@ export const convertMillimetersToTwip = (millimeters: number): number => { export const convertInchesToTwip = (inches: number): number => { return Math.floor(inches * 72 * 20); }; + +export const uniqueNumericId = (): number => { + return parseFloat(numericNanoId()); +}; + +export const uniqueId = (): string => { + return nanoid().toLowerCase(); +}; diff --git a/src/export/packer/numbering-replacer.ts b/src/export/packer/numbering-replacer.ts index 4ac5dcbf1d..eeecad929f 100644 --- a/src/export/packer/numbering-replacer.ts +++ b/src/export/packer/numbering-replacer.ts @@ -5,11 +5,10 @@ export class NumberingReplacer { let currentXmlData = xmlData; for (const concreteNumbering of concreteNumberings) { - if (!concreteNumbering.reference) { - continue; - } - - currentXmlData = currentXmlData.replace(new RegExp(`{${concreteNumbering.reference}}`, "g"), concreteNumbering.id.toString()); + currentXmlData = currentXmlData.replace( + new RegExp(`{${concreteNumbering.reference}-${concreteNumbering.instance}}`, "g"), + concreteNumbering.numId.toString(), + ); } return currentXmlData; diff --git a/src/file/media/media.spec.ts b/src/file/media/media.spec.ts index e566213ee5..d623e0e929 100644 --- a/src/file/media/media.spec.ts +++ b/src/file/media/media.spec.ts @@ -1,7 +1,8 @@ // tslint:disable:object-literal-key-quotes import { expect } from "chai"; -import { stub } from "sinon"; +import { SinonStub, stub } from "sinon"; +import * as convenienceFunctions from "convenience-functions"; import { Formatter } from "export/formatter"; import { File } from "../file"; @@ -9,6 +10,14 @@ import { Paragraph } from "../paragraph"; import { Media } from "./media"; describe("Media", () => { + before(() => { + stub(convenienceFunctions, "uniqueId").callsFake(() => "test"); + }); + + after(() => { + (convenienceFunctions.uniqueId as SinonStub).restore(); + }); + describe("#addImage", () => { it("should add image", () => { const file = new File(); @@ -23,7 +32,6 @@ describe("Media", () => { it("should ensure the correct relationship id is used when adding image", () => { // tslint:disable-next-line:no-any - stub(Media as any, "generateId").callsFake(() => "testId"); const file = new File(); const image1 = Media.addImage(file, "test"); @@ -33,7 +41,7 @@ describe("Media", () => { expect(graphicData["a:graphic"][1]["a:graphicData"][1]["pic:pic"][2]["pic:blipFill"][0]["a:blip"]).to.deep.equal({ _attr: { - "r:embed": `rId{testId.png}`, + "r:embed": `rId{test.png}`, cstate: "none", }, }); @@ -45,7 +53,7 @@ describe("Media", () => { expect(graphicData2["a:graphic"][1]["a:graphicData"][1]["pic:pic"][2]["pic:blipFill"][0]["a:blip"]).to.deep.equal({ _attr: { - "r:embed": `rId{testId.png}`, + "r:embed": `rId{test.png}`, cstate: "none", }, }); @@ -54,9 +62,6 @@ describe("Media", () => { describe("#addMedia", () => { it("should add media", () => { - // tslint:disable-next-line:no-any - (Media as any).generateId = () => "test"; - const image = new Media().addMedia(""); expect(image.fileName).to.equal("test.png"); expect(image.dimensions).to.deep.equal({ @@ -74,8 +79,6 @@ describe("Media", () => { it("should return UInt8Array if atob is present", () => { // tslint:disable-next-line ((process as any).atob as any) = () => "atob result"; - // tslint:disable-next-line:no-any - (Media as any).generateId = () => "test"; const image = new Media().addMedia(""); expect(image.stream).to.be.an.instanceof(Uint8Array); @@ -84,8 +87,6 @@ describe("Media", () => { it("should use data as is if its not a string", () => { // tslint:disable-next-line ((process as any).atob as any) = () => "atob result"; - // tslint:disable-next-line:no-any - (Media as any).generateId = () => "test"; const image = new Media().addMedia(new Buffer("")); expect(image.stream).to.be.an.instanceof(Uint8Array); @@ -94,9 +95,6 @@ describe("Media", () => { describe("#getMedia", () => { it("should get media", () => { - // tslint:disable-next-line:no-any - (Media as any).generateId = () => "test"; - const media = new Media(); media.addMedia(""); @@ -124,9 +122,6 @@ describe("Media", () => { describe("#Array", () => { it("Get images as array", () => { - // tslint:disable-next-line:no-any - (Media as any).generateId = () => "test"; - const media = new Media(); media.addMedia(""); diff --git a/src/file/media/media.ts b/src/file/media/media.ts index 65a7f1bf58..857128d254 100644 --- a/src/file/media/media.ts +++ b/src/file/media/media.ts @@ -1,3 +1,5 @@ +import { uniqueId } from "convenience-functions"; + import { IDrawingOptions } from "../drawing"; import { File } from "../file"; import { PictureRun } from "../paragraph"; @@ -17,11 +19,6 @@ export class Media { return new PictureRun(mediaData, drawingOptions); } - private static generateId(): string { - // https://gist.github.com/6174/6062387 - return Math.random().toString(36).substring(2, 15) + Math.random().toString(36).substring(2, 15); - } - private readonly map: Map; constructor() { @@ -39,7 +36,7 @@ export class Media { } public addMedia(buffer: Buffer | string | Uint8Array | ArrayBuffer, width: number = 100, height: number = 100): IMediaData { - const key = `${Media.generateId()}.png`; + const key = `${uniqueId()}.png`; return this.createMedia( key, diff --git a/src/file/numbering/abstract-numbering.ts b/src/file/numbering/abstract-numbering.ts index 2eed73fe39..363d233ac9 100644 --- a/src/file/numbering/abstract-numbering.ts +++ b/src/file/numbering/abstract-numbering.ts @@ -3,12 +3,10 @@ import { XmlAttributeComponent, XmlComponent } from "file/xml-components"; import { ILevelsOptions, Level } from "./level"; import { MultiLevelType } from "./multi-level-type"; -interface IAbstractNumberingAttributesProperties { - readonly abstractNumId?: number; - readonly restartNumberingAfterBreak?: number; -} - -class AbstractNumberingAttributes extends XmlAttributeComponent { +class AbstractNumberingAttributes extends XmlAttributeComponent<{ + readonly abstractNumId: number; + readonly restartNumberingAfterBreak: number; +}> { protected readonly xmlKeys = { abstractNumId: "w:abstractNumId", restartNumberingAfterBreak: "w15:restartNumberingAfterBreak", diff --git a/src/file/numbering/concrete-numbering.spec.ts b/src/file/numbering/concrete-numbering.spec.ts index 42ccc87f2b..76ef39ccc7 100644 --- a/src/file/numbering/concrete-numbering.spec.ts +++ b/src/file/numbering/concrete-numbering.spec.ts @@ -2,35 +2,59 @@ import { expect } from "chai"; import { Formatter } from "export/formatter"; -import { LevelForOverride } from "./level"; import { ConcreteNumbering } from "./num"; describe("ConcreteNumbering", () => { describe("#overrideLevel", () => { - let concreteNumbering: ConcreteNumbering; - beforeEach(() => { - concreteNumbering = new ConcreteNumbering(0, 1); - }); - it("sets a new override level for the given level number", () => { - concreteNumbering.overrideLevel(3); + const concreteNumbering = new ConcreteNumbering({ + numId: 0, + abstractNumId: 1, + reference: "1", + instance: 0, + overrideLevel: { + num: 3, + }, + }); + const tree = new Formatter().format(concreteNumbering); - expect(tree["w:num"]).to.include({ - "w:lvlOverride": [ - { _attr: { "w:ilvl": 3 } }, + + expect(tree).to.deep.equal({ + "w:num": [ { - "w:lvl": [ - { _attr: { "w:ilvl": 3, "w15:tentative": 1 } }, - { "w:start": { _attr: { "w:val": 1 } } }, - { "w:lvlJc": { _attr: { "w:val": "start" } } }, - ], + _attr: { + "w:numId": 0, + }, + }, + { + "w:abstractNumId": { + _attr: { + "w:val": 1, + }, + }, + }, + { + "w:lvlOverride": { + _attr: { + "w:ilvl": 3, + }, + }, }, ], }); }); it("sets the startOverride element if start is given", () => { - concreteNumbering.overrideLevel(1, 9); + const concreteNumbering = new ConcreteNumbering({ + numId: 0, + abstractNumId: 1, + reference: "1", + instance: 0, + overrideLevel: { + num: 1, + start: 9, + }, + }); const tree = new Formatter().format(concreteNumbering); expect(tree["w:num"]).to.include({ "w:lvlOverride": [ @@ -46,31 +70,41 @@ describe("ConcreteNumbering", () => { }, }, }, - { - "w:lvl": [ - { _attr: { "w:ilvl": 1, "w15:tentative": 1 } }, - { "w:start": { _attr: { "w:val": 1 } } }, - { "w:lvlJc": { _attr: { "w:val": "start" } } }, - ], - }, ], }); }); it("sets the lvl element if overrideLevel.Level is accessed", () => { - const ol = concreteNumbering.overrideLevel(1); - expect(ol.Level).to.be.instanceof(LevelForOverride); + const concreteNumbering = new ConcreteNumbering({ + numId: 0, + abstractNumId: 1, + reference: "1", + instance: 0, + overrideLevel: { + num: 1, + }, + }); const tree = new Formatter().format(concreteNumbering); - - expect(tree["w:num"]).to.include({ - "w:lvlOverride": [ - { _attr: { "w:ilvl": 1 } }, + expect(tree).to.deep.equal({ + "w:num": [ { - "w:lvl": [ - { _attr: { "w:ilvl": 1, "w15:tentative": 1 } }, - { "w:start": { _attr: { "w:val": 1 } } }, - { "w:lvlJc": { _attr: { "w:val": "start" } } }, - ], + _attr: { + "w:numId": 0, + }, + }, + { + "w:abstractNumId": { + _attr: { + "w:val": 1, + }, + }, + }, + { + "w:lvlOverride": { + _attr: { + "w:ilvl": 1, + }, + }, }, ], }); diff --git a/src/file/numbering/num.ts b/src/file/numbering/num.ts index e459b03d11..526d0f1b9f 100644 --- a/src/file/numbering/num.ts +++ b/src/file/numbering/num.ts @@ -1,5 +1,4 @@ import { Attributes, XmlAttributeComponent, XmlComponent } from "file/xml-components"; -import { LevelForOverride } from "./level"; class AbstractNumId extends XmlComponent { constructor(value: number) { @@ -12,32 +11,46 @@ class AbstractNumId extends XmlComponent { } } -interface INumAttributesProperties { +class NumAttributes extends XmlAttributeComponent<{ readonly numId: number; -} - -class NumAttributes extends XmlAttributeComponent { +}> { protected readonly xmlKeys = { numId: "w:numId" }; } -export class ConcreteNumbering extends XmlComponent { - public readonly id: number; +export interface IConcreteNumberingOptions { + readonly numId: number; + readonly abstractNumId: number; + readonly reference: string; + readonly instance: number; + readonly overrideLevel?: { + readonly num: number; + readonly start?: number; + }; +} - constructor(numId: number, abstractNumId: number, public readonly reference?: string) { +export class ConcreteNumbering extends XmlComponent { + public readonly numId: number; + public readonly reference: string; + public readonly instance: number; + + constructor(options: IConcreteNumberingOptions) { super("w:num"); + + this.numId = options.numId; + this.reference = options.reference; + this.instance = options.instance; + this.root.push( new NumAttributes({ - numId: numId, + numId: options.numId, }), ); - this.root.push(new AbstractNumId(abstractNumId)); - this.id = numId; - } - public overrideLevel(num: number, start?: number): LevelOverride { - const olvl = new LevelOverride(num, start); - this.root.push(olvl); - return olvl; + this.root.push(new AbstractNumId(options.abstractNumId)); + + if (options.overrideLevel) { + this.root.push(new LevelOverride(options.overrideLevel.num, options.overrideLevel.start)); + } } } @@ -46,23 +59,12 @@ class LevelOverrideAttributes extends XmlAttributeComponent<{ readonly ilvl: num } export class LevelOverride extends XmlComponent { - private readonly lvl: LevelForOverride; - - constructor(private readonly levelNum: number, start?: number) { + constructor(levelNum: number, start?: number) { super("w:lvlOverride"); this.root.push(new LevelOverrideAttributes({ ilvl: levelNum })); if (start !== undefined) { this.root.push(new StartOverride(start)); } - - this.lvl = new LevelForOverride({ - level: this.levelNum, - }); - this.root.push(this.lvl); - } - - public get Level(): LevelForOverride { - return this.lvl; } } diff --git a/src/file/numbering/numbering.spec.ts b/src/file/numbering/numbering.spec.ts index 79a824b67a..6053a5e3cd 100644 --- a/src/file/numbering/numbering.spec.ts +++ b/src/file/numbering/numbering.spec.ts @@ -1,10 +1,20 @@ import { expect } from "chai"; +import { SinonStub, stub } from "sinon"; +import * as convenienceFunctions from "convenience-functions"; import { Formatter } from "export/formatter"; import { Numbering } from "./numbering"; describe("Numbering", () => { + before(() => { + stub(convenienceFunctions, "uniqueNumericId").callsFake(() => 0); + }); + + after(() => { + (convenienceFunctions.uniqueNumericId as SinonStub).restore(); + }); + describe("#constructor", () => { it("creates a default numbering with one abstract and one concrete instance", () => { const numbering = new Numbering({ @@ -38,5 +48,69 @@ describe("Numbering", () => { // {"w:ind": [{"_attr": {"w:left": 720, "w:hanging": 360}}]}]}, }); }); + + describe("#createConcreteNumberingInstance", () => { + it("should create a concrete numbering instance", () => { + const numbering = new Numbering({ + config: [ + { + reference: "test-reference", + levels: [ + { + level: 0, + }, + ], + }, + ], + }); + expect(numbering.ConcreteNumbering).to.have.length(1); + + numbering.createConcreteNumberingInstance("test-reference", 0); + + expect(numbering.ConcreteNumbering).to.have.length(2); + }); + + it("should not create a concrete numbering instance if reference is invalid", () => { + const numbering = new Numbering({ + config: [ + { + reference: "test-reference", + levels: [ + { + level: 0, + }, + ], + }, + ], + }); + expect(numbering.ConcreteNumbering).to.have.length(1); + + numbering.createConcreteNumberingInstance("invalid-reference", 0); + + expect(numbering.ConcreteNumbering).to.have.length(1); + }); + + it("should not create a concrete numbering instance if one already exists", () => { + const numbering = new Numbering({ + config: [ + { + reference: "test-reference", + levels: [ + { + level: 0, + }, + ], + }, + ], + }); + + expect(numbering.ConcreteNumbering).to.have.length(1); + + numbering.createConcreteNumberingInstance("test-reference", 0); + numbering.createConcreteNumberingInstance("test-reference", 0); + + expect(numbering.ConcreteNumbering).to.have.length(2); + }); + }); }); }); diff --git a/src/file/numbering/numbering.ts b/src/file/numbering/numbering.ts index bfe0e99f0b..7798d373d6 100644 --- a/src/file/numbering/numbering.ts +++ b/src/file/numbering/numbering.ts @@ -1,5 +1,6 @@ // http://officeopenxml.com/WPnumbering.php -import { convertInchesToTwip } from "convenience-functions"; +// https://stackoverflow.com/questions/58622437/purpose-of-abstractnum-and-numberinginstance +import { convertInchesToTwip, uniqueNumericId } from "convenience-functions"; import { AlignmentType } from "file/paragraph"; import { IContext, IXmlableObject, XmlComponent } from "file/xml-components"; @@ -16,11 +17,8 @@ export interface INumberingOptions { } export class Numbering extends XmlComponent { - // tslint:disable-next-line:readonly-keyword - private nextId: number; - - private readonly abstractNumbering: AbstractNumbering[] = []; - private readonly concreteNumbering: ConcreteNumbering[] = []; + private readonly abstractNumberingMap = new Map(); + private readonly concreteNumberingMap = new Map(); constructor(options: INumberingOptions) { super("w:numbering"); @@ -46,9 +44,7 @@ export class Numbering extends XmlComponent { }), ); - this.nextId = 0; - - const abstractNumbering = this.createAbstractNumbering([ + const abstractNumbering = new AbstractNumbering(uniqueNumericId(), [ { level: 0, format: LevelFormat.BULLET, @@ -150,33 +146,67 @@ export class Numbering extends XmlComponent { }, ]); - this.createConcreteNumbering(abstractNumbering); + this.concreteNumberingMap.set( + "default-bullet-numbering", + new ConcreteNumbering({ + numId: 0, + abstractNumId: abstractNumbering.id, + reference: "default-bullet-numbering", + instance: 0, + overrideLevel: { + num: 0, + start: 1, + }, + }), + ); + + this.abstractNumberingMap.set("default-bullet-numbering", abstractNumbering); for (const con of options.config) { - const currentAbstractNumbering = this.createAbstractNumbering(con.levels); - this.createConcreteNumbering(currentAbstractNumbering, con.reference); + this.abstractNumberingMap.set(con.reference, new AbstractNumbering(uniqueNumericId(), con.levels)); } } public prepForXml(context: IContext): IXmlableObject | undefined { - this.abstractNumbering.forEach((x) => this.root.push(x)); - this.concreteNumbering.forEach((x) => this.root.push(x)); + for (const numbering of this.abstractNumberingMap.values()) { + this.root.push(numbering); + } + + for (const numbering of this.concreteNumberingMap.values()) { + this.root.push(numbering); + } return super.prepForXml(context); } - private createConcreteNumbering(abstractNumbering: AbstractNumbering, reference?: string): ConcreteNumbering { - const num = new ConcreteNumbering(this.nextId++, abstractNumbering.id, reference); - this.concreteNumbering.push(num); - return num; - } + public createConcreteNumberingInstance(reference: string, instance: number): void { + const abstractNumbering = this.abstractNumberingMap.get(reference); - private createAbstractNumbering(options: ILevelsOptions[]): AbstractNumbering { - const num = new AbstractNumbering(this.nextId++, options); - this.abstractNumbering.push(num); - return num; + if (!abstractNumbering) { + return; + } + + const fullReference = `${reference}-${instance}`; + + if (this.concreteNumberingMap.has(fullReference)) { + return; + } + + this.concreteNumberingMap.set( + fullReference, + new ConcreteNumbering({ + numId: uniqueNumericId(), + abstractNumId: abstractNumbering.id, + reference, + instance, + overrideLevel: { + num: 0, + start: 1, + }, + }), + ); } public get ConcreteNumbering(): ConcreteNumbering[] { - return this.concreteNumbering; + return Array.from(this.concreteNumberingMap.values()); } } diff --git a/src/file/paragraph/links/bookmark.ts b/src/file/paragraph/links/bookmark.ts index cf217155a2..b7d32bdbad 100644 --- a/src/file/paragraph/links/bookmark.ts +++ b/src/file/paragraph/links/bookmark.ts @@ -1,6 +1,7 @@ // http://officeopenxml.com/WPbookmark.php +import { uniqueId } from "convenience-functions"; import { XmlComponent } from "file/xml-components"; -import * as shortid from "shortid"; + import { TextRun } from "../run"; import { BookmarkEndAttributes, BookmarkStartAttributes } from "./bookmark-attributes"; @@ -10,7 +11,7 @@ export class Bookmark { public readonly end: BookmarkEnd; constructor(options: { readonly id: string; readonly children: TextRun[] }) { - const linkId = shortid.generate().toLowerCase(); + const linkId = uniqueId(); this.start = new BookmarkStart(options.id, linkId); this.children = options.children; diff --git a/src/file/paragraph/links/hyperlink.ts b/src/file/paragraph/links/hyperlink.ts index a7512e7beb..7ced10d07c 100644 --- a/src/file/paragraph/links/hyperlink.ts +++ b/src/file/paragraph/links/hyperlink.ts @@ -1,6 +1,5 @@ // http://officeopenxml.com/WPhyperlink.php -import * as shortid from "shortid"; - +import { uniqueId } from "convenience-functions"; import { XmlComponent } from "file/xml-components"; import { ParagraphChild } from "../paragraph"; @@ -33,7 +32,7 @@ export class ConcreteHyperlink extends XmlComponent { export class InternalHyperlink extends ConcreteHyperlink { constructor(options: { readonly child: ParagraphChild; readonly anchor: string }) { - super(options.child, shortid.generate().toLowerCase(), options.anchor); + super(options.child, uniqueId(), options.anchor); } } diff --git a/src/file/paragraph/paragraph.spec.ts b/src/file/paragraph/paragraph.spec.ts index 8536a3e84e..a6ba9c0446 100644 --- a/src/file/paragraph/paragraph.spec.ts +++ b/src/file/paragraph/paragraph.spec.ts @@ -1,7 +1,7 @@ import { assert, expect } from "chai"; -import * as shortid from "shortid"; -import { stub } from "sinon"; +import { SinonStub, stub } from "sinon"; +import * as convenienceFunctions from "convenience-functions"; import { Formatter } from "export/formatter"; import { EMPTY_OBJECT } from "file/xml-components"; @@ -14,6 +14,16 @@ import { Paragraph } from "./paragraph"; import { TextRun } from "./run"; describe("Paragraph", () => { + before(() => { + stub(convenienceFunctions, "uniqueId").callsFake(() => { + return "test-unique-id"; + }); + }); + + after(() => { + (convenienceFunctions.uniqueId as SinonStub).restore(); + }); + describe("#constructor()", () => { it("should create valid JSON", () => { const paragraph = new Paragraph(""); @@ -603,6 +613,7 @@ describe("Paragraph", () => { numbering: { reference: "test id", level: 0, + instance: 4, }, }); const tree = new Formatter().format(paragraph); @@ -612,7 +623,7 @@ describe("Paragraph", () => { "w:pPr": [ { "w:pStyle": { _attr: { "w:val": "ListParagraph" } } }, { - "w:numPr": [{ "w:ilvl": { _attr: { "w:val": 0 } } }, { "w:numId": { _attr: { "w:val": "{test id}" } } }], + "w:numPr": [{ "w:ilvl": { _attr: { "w:val": 0 } } }, { "w:numId": { _attr: { "w:val": "{test id-4}" } } }], }, ], }, @@ -634,7 +645,7 @@ describe("Paragraph", () => { { "w:pPr": [ { - "w:numPr": [{ "w:ilvl": { _attr: { "w:val": 0 } } }, { "w:numId": { _attr: { "w:val": "{test id}" } } }], + "w:numPr": [{ "w:ilvl": { _attr: { "w:val": 0 } } }, { "w:numId": { _attr: { "w:val": "{test id-0}" } } }], }, ], }, @@ -644,9 +655,6 @@ describe("Paragraph", () => { }); it("it should add bookmark", () => { - stub(shortid, "generate").callsFake(() => { - return "test-unique-id"; - }); const paragraph = new Paragraph({ children: [ new Bookmark({ diff --git a/src/file/paragraph/paragraph.ts b/src/file/paragraph/paragraph.ts index f5a64aee57..e707b499a7 100644 --- a/src/file/paragraph/paragraph.ts +++ b/src/file/paragraph/paragraph.ts @@ -1,6 +1,6 @@ // http://officeopenxml.com/WPparagraph.php -import * as shortid from "shortid"; +import { uniqueId } from "convenience-functions"; import { FootnoteReferenceRun } from "file/footnotes/footnote/run/reference-run"; import { IContext, IXmlableObject, XmlComponent } from "file/xml-components"; @@ -79,7 +79,7 @@ export class Paragraph extends XmlComponent { for (const element of this.root) { if (element instanceof ExternalHyperlink) { const index = this.root.indexOf(element); - const concreteHyperlink = new ConcreteHyperlink(element.options.child, shortid.generate().toLowerCase()); + const concreteHyperlink = new ConcreteHyperlink(element.options.child, uniqueId()); context.viewWrapper.Relationships.createRelationship( concreteHyperlink.linkId, "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink", diff --git a/src/file/paragraph/properties.spec.ts b/src/file/paragraph/properties.spec.ts new file mode 100644 index 0000000000..538131f29e --- /dev/null +++ b/src/file/paragraph/properties.spec.ts @@ -0,0 +1,69 @@ +import { expect } from "chai"; + +import { Formatter } from "export/formatter"; +import { DocumentWrapper } from "../document-wrapper"; +import { File } from "../file"; + +import { ParagraphProperties } from "./properties"; + +describe("ParagraphProperties", () => { + describe("#constructor()", () => { + it("creates an initially empty property object", () => { + const properties = new ParagraphProperties(); + + expect(() => new Formatter().format(properties)).to.throw("XMLComponent did not format correctly"); + }); + + it("should create", () => { + const properties = new ParagraphProperties({ + numbering: { + reference: "test-reference", + level: 0, + instance: 0, + }, + }); + const tree = new Formatter().format(properties, { + // tslint:disable-next-line: no-object-literal-type-assertion + file: { + Numbering: { + createConcreteNumberingInstance: (_: string, __: number) => { + return; + }, + }, + } as File, + // tslint:disable-next-line: no-object-literal-type-assertion + viewWrapper: new DocumentWrapper({ background: {} }), + }); + + expect(tree).to.deep.equal({ + "w:pPr": [ + { + "w:pStyle": { + _attr: { + "w:val": "ListParagraph", + }, + }, + }, + { + "w:numPr": [ + { + "w:ilvl": { + _attr: { + "w:val": 0, + }, + }, + }, + { + "w:numId": { + _attr: { + "w:val": "{test-reference-0}", + }, + }, + }, + ], + }, + ], + }); + }); + }); +}); diff --git a/src/file/paragraph/properties.ts b/src/file/paragraph/properties.ts index b12bfedd8e..f1e15d8c3f 100644 --- a/src/file/paragraph/properties.ts +++ b/src/file/paragraph/properties.ts @@ -1,5 +1,6 @@ // http://officeopenxml.com/WPparagraphProperties.php -import { IgnoreIfEmptyXmlComponent, XmlComponent } from "file/xml-components"; +import { IContext, IgnoreIfEmptyXmlComponent, IXmlableObject, XmlComponent } from "file/xml-components"; +import { DocumentWrapper } from "../document-wrapper"; import { ShadingType } from "../table/shading"; import { Alignment, AlignmentType } from "./formatting/alignment"; import { Bidirectional } from "./formatting/bidirectional"; @@ -44,6 +45,7 @@ export interface IParagraphPropertiesOptions extends IParagraphStylePropertiesOp readonly numbering?: { readonly reference: string; readonly level: number; + readonly instance?: number; readonly custom?: boolean; }; readonly shading?: { @@ -54,6 +56,8 @@ export interface IParagraphPropertiesOptions extends IParagraphStylePropertiesOp } export class ParagraphProperties extends IgnoreIfEmptyXmlComponent { + private readonly numberingReferences: { readonly reference: string; readonly instance: number }[] = []; + constructor(options?: IParagraphPropertiesOptions) { super("w:pPr"); @@ -128,7 +132,12 @@ export class ParagraphProperties extends IgnoreIfEmptyXmlComponent { if (!options.numbering.custom) { this.push(new Style("ListParagraph")); } - this.push(new NumberProperties(options.numbering.reference, options.numbering.level)); + this.numberingReferences.push({ + reference: options.numbering.reference, + instance: options.numbering.instance ?? 0, + }); + + this.push(new NumberProperties(`${options.numbering.reference}-${options.numbering.instance ?? 0}`, options.numbering.level)); } if (options.rightTabStop) { @@ -147,4 +156,14 @@ export class ParagraphProperties extends IgnoreIfEmptyXmlComponent { public push(item: XmlComponent): void { this.root.push(item); } + + public prepForXml(context: IContext): IXmlableObject | undefined { + if (context.viewWrapper instanceof DocumentWrapper) { + for (const reference of this.numberingReferences) { + context.file.Numbering.createConcreteNumberingInstance(reference.reference, reference.instance); + } + } + + return super.prepForXml(context); + } } diff --git a/src/index.spec.ts b/src/index.spec.ts new file mode 100644 index 0000000000..47633f9d22 --- /dev/null +++ b/src/index.spec.ts @@ -0,0 +1,12 @@ +import { expect } from "chai"; + +import { Document } from "./index"; + +describe("Index", () => { + describe("Document", () => { + it("should instantiate the Document", () => { + // tslint:disable-next-line: no-unused-expression + expect(new Document()).to.be.ok; + }); + }); +});