Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 19 additions & 5 deletions lib/Docx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
Expand Down Expand Up @@ -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;
}
Expand All @@ -163,7 +168,7 @@ export class Docx<
}

if (relationships !== null) {
await component.ensureRelationship(relationships);
component.ensureRelationship(relationships);
}

await Promise.all(
Expand Down Expand Up @@ -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<PropsGeneric>(contentTypes, relationships);
return new Docx<PropsGeneric>(
contentTypes,
relationships,
null,
bookmarks
);
}

/**
Expand Down
9 changes: 7 additions & 2 deletions lib/classes/src/Bookmarks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)) {
Expand All @@ -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);
}
}
10 changes: 7 additions & 3 deletions lib/classes/src/Component.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -53,6 +54,9 @@ export type ComponentContext = {

/** Relationships that the nodes in this context can reference. */
relationships: RelationshipsXml | null;

/** The bookmarks. */
bookmarks?: Bookmarks;
};

/**
Expand All @@ -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<C>, ...children: ComponentChild<C>[]): C;
children: string[];
Expand All @@ -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;
Expand All @@ -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;
Expand Down
4 changes: 3 additions & 1 deletion lib/classes/src/XmlFile.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -104,7 +105,8 @@ export class XmlFileWithContentTypes extends XmlFileBase {
public static fromArchive(
_archive: Archive,
_contentTypes: ContentTypesXml,
location: string
location: string,
_context?: ArchiveContext
): Promise<XmlFile> {
return Promise.resolve(new XmlFile(location));
}
Expand Down
58 changes: 58 additions & 0 deletions lib/classes/test/Bookmarks.test.ts
Original file line number Diff line number Diff line change
@@ -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.'
);
});
});
});
25 changes: 15 additions & 10 deletions lib/components/document/src/BookmarkRangeStart.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<BookmarkRangeStartProps>(
`map {
"id": ./@${QNS.w}id/number(),
"name": ./@${QNS.w}name/string()
static override fromNode(
node: Node,
context: ComponentContext
): BookmarkRangeStart {
const props = evaluateXPathToMap<BookmarkRangeStartProps>(
`map {
"id": ./@${QNS.w}id/number(),
"name": ./@${QNS.w}name/string()
}`,
node
)
node
);

context.bookmarks?.registerIdentifier(props.id!, props.name);
return new BookmarkRangeStart(props);
}
}

Expand Down
102 changes: 102 additions & 0 deletions lib/components/document/test/BookmarkRangeStart.test.ts
Original file line number Diff line number Diff line change
@@ -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(`
<w:bookmarkStart xmlns:w="${NamespaceUri.w}" w:id="3" w:name="my_bookmark" />
`);
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(`
<w:bookmarkStart xmlns:w="${NamespaceUri.w}" w:id="7" w:name="chapter1" />
`);
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(
`<w:bookmarkStart xmlns:w="${NamespaceUri.w}" w:id="0" w:name="intro" />`
),
context
);
BookmarkRangeStart.fromNode(
create(
`<w:bookmarkStart xmlns:w="${NamespaceUri.w}" w:id="1" w:name="chapter1" />`
),
context
);
BookmarkRangeStart.fromNode(
create(
`<w:bookmarkStart xmlns:w="${NamespaceUri.w}" w:id="2" w:name="chapter2" />`
),
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(`
<w:bookmarkStart xmlns:w="${NamespaceUri.w}" w:id="5" w:name="duplicate" />
`);
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(`
<w:bookmarkStart xmlns:w="${NamespaceUri.w}" w:id="1" w:name="test_bm" />
`);
const component = BookmarkRangeStart.fromNode(node, context);
const output = serialize(component.toNode([]));
expect(output).toContain('id="1"');
expect(output).toContain('name="test_bm"');
});
});
2 changes: 2 additions & 0 deletions lib/components/document/test/Cell.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,6 +16,7 @@ import { Table } from '../src/Table.ts';
const emptyContext: ComponentContext = {
archive: new Archive(),
relationships: null,
bookmarks: new Bookmarks(),
};

describe('Cell', () => {
Expand Down
Loading
Loading