diff --git a/lib/Docx.ts b/lib/Docx.ts index 40c330a0..1d36c281 100644 --- a/lib/Docx.ts +++ b/lib/Docx.ts @@ -68,11 +68,16 @@ export class Docx< rules: GenericRenderer< RuleResult, { document: DocumentXml } & PropsGeneric - > | null = null + > | null = null, + bookmarks: Bookmarks | null = null ) { this.contentTypes = contentTypes; this.relationships = relationships; + if (bookmarks) { + this.bookmarks = bookmarks; + } + if (rules) { this.#renderer.merge(rules); } @@ -145,7 +150,7 @@ export class Docx< // Loop over all content to ensure styles are registered, relationships created etc. await Promise.all( (await children).map(async function walk(componentPromise) { - const component = await componentPromise; + const component = componentPromise; if (typeof component === 'string') { return; } @@ -163,7 +168,7 @@ export class Docx< } if (relationships !== null) { - await component.ensureRelationship(relationships); + component.ensureRelationship(relationships); } await Promise.all( @@ -257,13 +262,22 @@ export class Docx< archive, FileLocation.contentTypes ); + const bookmarks = new Bookmarks(); const relationships = await RelationshipsXml.fromArchive( archive, contentTypes, - FileLocation.relationships + FileLocation.relationships, + { + bookmarks, + } ); - return new Docx(contentTypes, relationships); + return new Docx( + contentTypes, + relationships, + null, + bookmarks + ); } /** diff --git a/lib/classes/src/Bookmarks.ts b/lib/classes/src/Bookmarks.ts index dc0924a9..44ad0dc8 100644 --- a/lib/classes/src/Bookmarks.ts +++ b/lib/classes/src/Bookmarks.ts @@ -9,8 +9,6 @@ export class Bookmarks { /** * Marks a unique identifier as taken. - * - * @todo When loading an existing document, bookmarks are not registered from it yet. */ public registerIdentifier(id: number, name?: string) { if (this.#bookmarks.has(id)) { @@ -32,4 +30,11 @@ export class Bookmarks { this.registerIdentifier(id); return { id, name }; } + + /** + * Get a bookmark name by its identifier. + */ + public get(id: number): string | null | undefined { + return this.#bookmarks.get(id); + } } diff --git a/lib/classes/src/Component.ts b/lib/classes/src/Component.ts index 7bc70bee..45f06162 100644 --- a/lib/classes/src/Component.ts +++ b/lib/classes/src/Component.ts @@ -2,6 +2,7 @@ import type { DocumentXml } from '../../files/src/DocumentXml.ts'; import type { FooterXml, HeaderXml } from '../../files/src/HeaderFooterXml.ts'; import type { RelationshipsXml } from '../../files/src/RelationshipsXml.ts'; import type { Archive } from './Archive.ts'; +import type { Bookmarks } from './Bookmarks.ts'; /** * An ancestor of a component at serialization time, or the {@link DocumentXml} instance that is the @@ -53,6 +54,9 @@ export type ComponentContext = { /** Relationships that the nodes in this context can reference. */ relationships: RelationshipsXml | null; + + /** The bookmarks. */ + bookmarks?: Bookmarks; }; /** @@ -66,7 +70,7 @@ const IS_COMPONENT: unique symbol = Symbol(); * it must have a `children` and `mixed` static properties. */ export interface ComponentDefinition< - C extends AnyComponent | unknown = AnyComponent + C extends AnyComponent | unknown = AnyComponent, > { new (props: ComponentProps, ...children: ComponentChild[]): C; children: string[]; @@ -82,7 +86,7 @@ export interface ComponentDefinition< */ export type ComponentFunction< PropsGeneric extends { [key: string]: unknown } = { [key: string]: never }, - ChildGeneric extends AnyComponent | string = never + ChildGeneric extends AnyComponent | string = never, > = ( props: PropsGeneric & { children?: ChildGeneric | ChildGeneric[] } ) => AnyComponent; @@ -106,7 +110,7 @@ export function isComponentDefinition( export abstract class Component< PropsGeneric extends { [key: string]: unknown } = { [key: string]: never }, - ChildGeneric extends AnyComponent | string = never + ChildGeneric extends AnyComponent | string = never, > { // eslint-disable-next-line @typescript-eslint/prefer-as-const public static [IS_COMPONENT]: true = true; diff --git a/lib/classes/src/XmlFile.ts b/lib/classes/src/XmlFile.ts index 7107fbff..4f47cd1e 100644 --- a/lib/classes/src/XmlFile.ts +++ b/lib/classes/src/XmlFile.ts @@ -1,5 +1,6 @@ import { FileMime } from '../../enums.ts'; import type { ContentTypesXml } from '../../files/src/ContentTypesXml.ts'; +import type { ArchiveContext } from '../../files/src/index.ts'; import { parse } from '../../utilities/src/dom.ts'; import type { Archive } from './Archive.ts'; import type { BinaryFile } from './BinaryFile.ts'; @@ -104,7 +105,8 @@ export class XmlFileWithContentTypes extends XmlFileBase { public static fromArchive( _archive: Archive, _contentTypes: ContentTypesXml, - location: string + location: string, + _context?: ArchiveContext ): Promise { return Promise.resolve(new XmlFile(location)); } diff --git a/lib/classes/test/Bookmarks.test.ts b/lib/classes/test/Bookmarks.test.ts new file mode 100644 index 00000000..b5a6b853 --- /dev/null +++ b/lib/classes/test/Bookmarks.test.ts @@ -0,0 +1,58 @@ +import { expect } from 'std/expect'; +import { describe, it } from 'std/testing/bdd'; + +import { Bookmarks } from '../src/Bookmarks.ts'; + +describe('Bookmarks', () => { + describe('get', () => { + it('returns the bookmark name by its identifier', () => { + const bookmarks = new Bookmarks(); + bookmarks.registerIdentifier(1, 'chapter1'); + expect(bookmarks.get(1)).toBe('chapter1'); + }); + + it('returns null when registered without a name', () => { + const bookmarks = new Bookmarks(); + bookmarks.registerIdentifier(5); + expect(bookmarks.get(5)).toBeNull(); + }); + + it('returns undefined for an unregistered identifier', () => { + const bookmarks = new Bookmarks(); + expect(bookmarks.get(999)).toBeUndefined(); + }); + }); + + describe('create', () => { + it('creates a bookmark with an auto-generated name', () => { + const bookmarks = new Bookmarks(); + const bookmark = bookmarks.create(); + expect(bookmark.id).toBe(0); + expect(bookmark.name).toBe('__docxml_bookmark_0'); + }); + + it('skips identifiers that are already taken', () => { + const bookmarks = new Bookmarks(); + bookmarks.registerIdentifier(0, 'existing'); + const bookmark = bookmarks.create(); + expect(bookmark.id).toBe(1); + }); + + it('makes created bookmarks retrievable via get (name not stored)', () => { + const bookmarks = new Bookmarks(); + const bookmark = bookmarks.create(); + // create() calls registerIdentifier(id) without a name, so get() returns null + expect(bookmarks.get(bookmark.id)).toBeNull(); + }); + }); + + describe('registerIdentifier', () => { + it('throws when registering a duplicate identifier', () => { + const bookmarks = new Bookmarks(); + bookmarks.registerIdentifier(1, 'first'); + expect(() => bookmarks.registerIdentifier(1, 'second')).toThrow( + 'Bookmark with identifier "1" already exists.' + ); + }); + }); +}); diff --git a/lib/components/document/src/BookmarkRangeStart.ts b/lib/components/document/src/BookmarkRangeStart.ts index fb842328..14aaae77 100644 --- a/lib/components/document/src/BookmarkRangeStart.ts +++ b/lib/components/document/src/BookmarkRangeStart.ts @@ -1,7 +1,8 @@ import type { Bookmark } from '../../../classes/src/Bookmarks.ts'; import { - type ComponentAncestor, Component, + type ComponentAncestor, + type ComponentContext, } from '../../../classes/src/Component.ts'; import { registerComponent } from '../../../utilities/src/components.ts'; import { create } from '../../../utilities/src/dom.ts'; @@ -30,7 +31,7 @@ export type BookmarkRangeStartProps = }; /** - * The start of a range associated with a comment. + * The start of a range associated with a bookmark. */ export class BookmarkRangeStart extends Component< BookmarkRangeStartProps, @@ -67,16 +68,20 @@ export class BookmarkRangeStart extends Component< /** * Instantiate this component from the XML in an existing DOCX file. */ - static override fromNode(node: Node): BookmarkRangeStart { - return new BookmarkRangeStart( - evaluateXPathToMap( - `map { - "id": ./@${QNS.w}id/number(), - "name": ./@${QNS.w}name/string() + static override fromNode( + node: Node, + context: ComponentContext + ): BookmarkRangeStart { + const props = evaluateXPathToMap( + `map { + "id": ./@${QNS.w}id/number(), + "name": ./@${QNS.w}name/string() }`, - node - ) + node ); + + context.bookmarks?.registerIdentifier(props.id!, props.name); + return new BookmarkRangeStart(props); } } diff --git a/lib/components/document/test/BookmarkRangeStart.test.ts b/lib/components/document/test/BookmarkRangeStart.test.ts new file mode 100644 index 00000000..b21a364a --- /dev/null +++ b/lib/components/document/test/BookmarkRangeStart.test.ts @@ -0,0 +1,102 @@ +import { expect } from 'std/expect'; +import { describe, it } from 'std/testing/bdd'; + +import { Archive } from '../../../classes/src/Archive.ts'; +import { Bookmarks } from '../../../classes/src/Bookmarks.ts'; +import type { ComponentContext } from '../../../classes/src/Component.ts'; +import { create, serialize } from '../../../utilities/src/dom.ts'; +import { NamespaceUri } from '../../../utilities/src/namespaces.ts'; +import { BookmarkRangeStart } from '../src/BookmarkRangeStart.ts'; + +describe('BookmarkRangeStart', () => { + it('parses id and name from XML', () => { + const bookmarks = new Bookmarks(); + const context: ComponentContext = { + archive: new Archive(), + relationships: null, + bookmarks, + }; + const node = create(` + + `); + const component = BookmarkRangeStart.fromNode(node, context); + expect(component.props.id).toBe(3); + expect(component.props.name).toBe('my_bookmark'); + }); + + it('registers the bookmark in the context during parsing', () => { + const bookmarks = new Bookmarks(); + const context: ComponentContext = { + archive: new Archive(), + relationships: null, + bookmarks, + }; + const node = create(` + + `); + BookmarkRangeStart.fromNode(node, context); + expect(bookmarks.get(7)).toBe('chapter1'); + }); + + it('registers multiple bookmarks in the same context', () => { + const bookmarks = new Bookmarks(); + const context: ComponentContext = { + archive: new Archive(), + relationships: null, + bookmarks, + }; + BookmarkRangeStart.fromNode( + create( + `` + ), + context + ); + BookmarkRangeStart.fromNode( + create( + `` + ), + context + ); + BookmarkRangeStart.fromNode( + create( + `` + ), + context + ); + expect(bookmarks.get(0)).toBe('intro'); + expect(bookmarks.get(1)).toBe('chapter1'); + expect(bookmarks.get(2)).toBe('chapter2'); + }); + + it('throws when parsing a duplicate bookmark id', () => { + const bookmarks = new Bookmarks(); + bookmarks.registerIdentifier(5, 'existing'); + const context: ComponentContext = { + archive: new Archive(), + relationships: null, + bookmarks, + }; + const node = create(` + + `); + expect(() => BookmarkRangeStart.fromNode(node, context)).toThrow( + 'Bookmark with identifier "5" already exists.' + ); + }); + + it('serializes back to XML correctly', () => { + const bookmarks = new Bookmarks(); + const context: ComponentContext = { + archive: new Archive(), + relationships: null, + bookmarks, + }; + const node = create(` + + `); + const component = BookmarkRangeStart.fromNode(node, context); + const output = serialize(component.toNode([])); + expect(output).toContain('id="1"'); + expect(output).toContain('name="test_bm"'); + }); +}); diff --git a/lib/components/document/test/Cell.test.ts b/lib/components/document/test/Cell.test.ts index 09004222..132920d2 100644 --- a/lib/components/document/test/Cell.test.ts +++ b/lib/components/document/test/Cell.test.ts @@ -2,6 +2,7 @@ import { expect } from 'std/expect'; import { describe, it } from 'std/testing/bdd'; import { Archive } from '../../../classes/src/Archive.ts'; +import { Bookmarks } from '../../../classes/src/Bookmarks.ts'; import type { ComponentContext } from '../../../classes/src/Component.ts'; import { create } from '../../../utilities/src/dom.ts'; import { NamespaceUri } from '../../../utilities/src/namespaces.ts'; @@ -15,6 +16,7 @@ import { Table } from '../src/Table.ts'; const emptyContext: ComponentContext = { archive: new Archive(), relationships: null, + bookmarks: new Bookmarks(), }; describe('Cell', () => { diff --git a/lib/components/document/test/Hyperlink.test.ts b/lib/components/document/test/Hyperlink.test.ts index df44547a..754c3460 100644 --- a/lib/components/document/test/Hyperlink.test.ts +++ b/lib/components/document/test/Hyperlink.test.ts @@ -2,7 +2,10 @@ import { expect } from 'std/expect'; import { describe, it } from 'std/testing/bdd'; import { Archive } from '../../../classes/src/Archive.ts'; +import { Bookmarks } from '../../../classes/src/Bookmarks.ts'; import type { ComponentContext } from '../../../classes/src/Component.ts'; +import { RelationshipType } from '../../../enums.ts'; +import { RelationshipsXml } from '../../../files/src/RelationshipsXml.ts'; import { create, serialize } from '../../../utilities/src/dom.ts'; import { NamespaceUri } from '../../../utilities/src/namespaces.ts'; import { Hyperlink } from '../src/Hyperlink.ts'; @@ -10,6 +13,7 @@ import { Hyperlink } from '../src/Hyperlink.ts'; const emptyContext: ComponentContext = { archive: new Archive(), relationships: null, + bookmarks: new Bookmarks(), }; describe('Hyperlink', () => { @@ -60,4 +64,44 @@ describe('Hyperlink', () => { `.replace(/\t|\n/g, '') ); }); + + it('resolves url from relationshipId via relationships', () => { + const relationships = new RelationshipsXml('_rels/.rels', [ + { + id: 'rId1', + type: RelationshipType.hyperlink, + target: 'https://example.com', + isExternal: true, + isBinary: false, + }, + ]); + const context: ComponentContext = { + archive: new Archive(), + relationships, + bookmarks: new Bookmarks(), + }; + const link = Hyperlink.fromNode( + create(` + + Click + + `), + context + ); + expect(link.props.url).toBe('https://example.com'); + expect(link.props.relationshipId).toBe('rId1'); + }); + + it('does not set url when there is no relationshipId', () => { + const link = Hyperlink.fromNode( + create(` + + Click + + `), + emptyContext + ); + expect(link.props.url).toBeFalsy(); + expect(link.props.anchor).toBe('bookmark1'); + }); }); diff --git a/lib/files/src/DocumentXml.ts b/lib/files/src/DocumentXml.ts index 563c4996..7b8f5287 100644 --- a/lib/files/src/DocumentXml.ts +++ b/lib/files/src/DocumentXml.ts @@ -32,6 +32,7 @@ import { type File, RelationshipsXml } from './RelationshipsXml.ts'; import { SettingsXml } from './SettingsXml.ts'; import { StylesXml } from './StylesXml.ts'; import { ThemeXml } from './ThemeXml.ts'; +import type { ArchiveContext } from './index.ts'; export type DocumentChild = SectionChild | Section; @@ -291,12 +292,14 @@ export class DocumentXml extends XmlFileWithContentTypes { public static override async fromArchive( archive: Archive, contentTypes: ContentTypesXml, - location: string + location: string, + context?: ArchiveContext ): Promise { const relationships = await RelationshipsXml.fromArchive( archive, contentTypes, - `${dirname(location)}/_rels/${basename(location)}.rels` + `${dirname(location)}/_rels/${basename(location)}.rels`, + context ); const doc = new DocumentXml(location, relationships); const dom = await archive.readXml(location); @@ -305,17 +308,20 @@ export class DocumentXml extends XmlFileWithContentTypes { `/*/${QNS.w}body/(${QNS.w}p/${QNS.w}pPr/${QNS.w}sectPr | ${QNS.w}sectPr)`, dom ); - const context: ComponentContext = { + const componentContext: ComponentContext = { archive, relationships, + bookmarks: context?.bookmarks, }; doc.set( sections.length - ? sections.map((node) => Section.fromNode(node, context)) + ? sections.map((node) => + Section.fromNode(node, componentContext) + ) : createChildComponentsFromNodes( sectionChildComponentNames, evaluateXPathToNodes(`/*/${QNS.w}body/*`, dom), - context + componentContext ) ); return doc; diff --git a/lib/files/src/RelationshipsXml.ts b/lib/files/src/RelationshipsXml.ts index da296de0..fbe46a23 100644 --- a/lib/files/src/RelationshipsXml.ts +++ b/lib/files/src/RelationshipsXml.ts @@ -12,7 +12,7 @@ import { create } from '../../utilities/src/dom.ts'; import { createRandomId } from '../../utilities/src/identifiers.ts'; import { QNS } from '../../utilities/src/namespaces.ts'; import { evaluateXPathToArray } from '../../utilities/src/xquery.ts'; -import { castRelationshipToClass } from './index.ts'; +import { type ArchiveContext, castRelationshipToClass } from './index.ts'; export type RelationshipMeta = { id: string; @@ -167,7 +167,7 @@ export class RelationshipsXml extends XmlFileWithContentTypes { : relative( dirname(dirname(this.location)), meta.target - ), + ), } ) ), @@ -204,7 +204,8 @@ export class RelationshipsXml extends XmlFileWithContentTypes { public static override async fromArchive( archive: Archive, contentTypes: ContentTypesXml, - location: string + location: string, + context?: ArchiveContext ): Promise { const meta = evaluateXPathToArray( ` @@ -235,15 +236,16 @@ export class RelationshipsXml extends XmlFileWithContentTypes { archive, contentTypes, meta.target - ) + ) : await castRelationshipToClass( archive, contentTypes, { type: meta.type, target: meta.target, - } - ), + }, + context + ), })) ) ).reduce((map, { id, instance }) => { diff --git a/lib/files/src/index.ts b/lib/files/src/index.ts index 5c4bcd29..1aa5cbb6 100644 --- a/lib/files/src/index.ts +++ b/lib/files/src/index.ts @@ -1,5 +1,6 @@ import type { ContentTypesXml } from '../../../mod.ts'; import type { Archive } from '../../classes/src/Archive.ts'; +import type { Bookmarks } from '../../classes/src/Bookmarks.ts'; import { UnhandledXmlFile } from '../../classes/src/XmlFile.ts'; import { RelationshipType } from '../../enums.ts'; import { CommentsXml } from './CommentsXml.ts'; @@ -18,6 +19,8 @@ import { ExtendedPropertiesXml } from './wip/ExtendedPropertiesXml.ts'; import { FontTableXml } from './wip/FontTableXml.ts'; import { WebSettingsXml } from './wip/WebSettingsXml.ts'; +export type ArchiveContext = { bookmarks: Bookmarks }; + /** * @deprecated This is probably not the best way to instantiate new classes. Should be looking at * the content type instead. @@ -25,7 +28,8 @@ import { WebSettingsXml } from './wip/WebSettingsXml.ts'; export function castRelationshipToClass( archive: Archive, contentTypes: ContentTypesXml, - meta: Pick + meta: Pick, + context?: ArchiveContext ) { switch (meta.type) { case RelationshipType.customProperties: @@ -46,7 +50,12 @@ export function castRelationshipToClass( case RelationshipType.header: return HeaderXml.fromArchive(archive, contentTypes, meta.target); case RelationshipType.officeDocument: - return DocumentXml.fromArchive(archive, contentTypes, meta.target); + return DocumentXml.fromArchive( + archive, + contentTypes, + meta.target, + context + ); case RelationshipType.settings: return SettingsXml.fromArchive(archive, contentTypes, meta.target); case RelationshipType.styles: