diff --git a/docs/examples/images.md b/docs/examples/images.md
index 091d55e5..630a38fe 100644
--- a/docs/examples/images.md
+++ b/docs/examples/images.md
@@ -16,10 +16,36 @@ await Docx.fromJsx(
data={Deno.readFile('test/spacekees.jpeg')}
width={cm(16)}
height={cm(16)}
- title="Title"
- alt="Description"
+ title='Title'
+ alt='Description'
/>
- ,
+
+).toFile('images.docx');
+```
+
+## Borders
+
+Pass a `border` prop to draw a line around the image. Every option is optional; `width` defaults to
+0.75pt and `color` to black, so `border={{}}` already gives you a thin black line. The `color` is a
+hexadecimal code without leading hash, and `type` is one of the DrawingML dash styles (`solid`,
+`dot`, `dash`, `lgDash`, `dashDot`, `lgDashDot`, `lgDashDotDot`, `sysDash`, `sysDot`, `sysDashDot`,
+`sysDashDotDot`).
+
+```tsx
+/** @jsx Docx.jsx */
+import Docx, { cm, Image, Paragraph, pt, Text } from 'docxml';
+
+await Docx.fromJsx(
+
+
+
+
+
).toFile('images.docx');
```
diff --git a/examples/images.tsx b/examples/images.tsx
index a760925f..1ff12427 100644
--- a/examples/images.tsx
+++ b/examples/images.tsx
@@ -1,5 +1,5 @@
/** @jsx Docx.jsx */
-import Docx, { cm, Image, Paragraph, Section, Text } from '../mod.ts';
+import Docx, { cm, Image, Paragraph, pt, Section, Text } from '../mod.ts';
await Docx.fromJsx(
@@ -9,8 +9,8 @@ await Docx.fromJsx(
data={Deno.readFile('assets/spacekees.jpeg')}
width={cm(16)}
height={cm(16)}
- title="Title"
- alt="Description"
+ title='Title'
+ alt='Description'
/>
@@ -29,8 +29,27 @@ await Docx.fromJsx(
}}
width={cm(16)}
height={cm(16)}
- title="Title"
- alt="Description"
+ title='Title'
+ alt='Description'
+ />
+
+
+
+ This image has a configurable border.
+
+
+
+
diff --git a/lib/Docx.ts b/lib/Docx.ts
index 1d36c281..abf019dd 100644
--- a/lib/Docx.ts
+++ b/lib/Docx.ts
@@ -168,7 +168,7 @@ export class Docx<
}
if (relationships !== null) {
- component.ensureRelationship(relationships);
+ await component.ensureRelationship(relationships);
}
await Promise.all(
diff --git a/lib/classes/src/Component.ts b/lib/classes/src/Component.ts
index 45f06162..a238fb7b 100644
--- a/lib/classes/src/Component.ts
+++ b/lib/classes/src/Component.ts
@@ -156,7 +156,9 @@ export abstract class Component<
*
* this.#relationshipId = relationships.add(RelationshipType.hyperlink, this.props.url);
*/
- public ensureRelationship(_relationships: RelationshipsXml) {
+ public ensureRelationship(
+ _relationships: RelationshipsXml
+ ): void | Promise {
// no-op
}
diff --git a/lib/components/document/src/Image.ts b/lib/components/document/src/Image.ts
index fe9ae24c..c94b069f 100644
--- a/lib/components/document/src/Image.ts
+++ b/lib/components/document/src/Image.ts
@@ -29,10 +29,54 @@ import {
*/
export type ImageChild = never;
+/**
+ * The line width that word processors use when a border does not specify one.
+ */
+const DEFAULT_BORDER_WIDTH_EMU = 9525;
+
+/**
+ * The line color used when a border does not specify one.
+ */
+const DEFAULT_BORDER_COLOR = '000000';
+
export type DataExtensions = {
svg?: Promise;
};
+/**
+ * The dash style of an image border, as defined by DrawingML's `ST_PresetLineDashVal`.
+ */
+export type ImageBorderType =
+ | 'solid'
+ | 'dot'
+ | 'dash'
+ | 'lgDash'
+ | 'dashDot'
+ | 'lgDashDot'
+ | 'lgDashDotDot'
+ | 'sysDash'
+ | 'sysDot'
+ | 'sysDashDot'
+ | 'sysDashDotDot';
+
+/**
+ * A type describing the border drawn around an {@link Image}.
+ */
+export type ImageBorder = {
+ /**
+ * The thickness of the border line.
+ */
+ width?: null | Length;
+ /**
+ * The color of the border line, as a hexadecimal code without leading hash (`"ff0000"`).
+ */
+ color?: null | string;
+ /**
+ * The dash style of the border line.
+ */
+ type?: null | ImageBorderType;
+};
+
/**
* A type describing the props accepted by {@link Image}.
*/
@@ -44,6 +88,11 @@ export type ImageProps = {
alt?: null | string;
width: Length;
height: Length;
+ /**
+ * The border drawn around this image. Omitting this prop, or any of its options, means that
+ * the word processor default is used.
+ */
+ border?: null | ImageBorder;
/**
* RelationshipId when the image is imported from an existing DOCX file.
* This is used to preserve the relationship when re-serializing the file,
@@ -219,6 +268,37 @@ export class Image extends Component {
);
}
+ let borderNode: Node | null = null;
+ const { border } = this.props;
+ if (border) {
+ borderNode = create(
+ `
+ element ${QNS.a}ln {
+ attribute w { $borderWidth },
+ attribute cap { "flat" },
+ attribute cmpd { "sng" },
+ attribute algn { "ctr" },
+ element ${QNS.a}solidFill {
+ element ${QNS.a}srgbClr {
+ attribute val { $borderColor }
+ }
+ },
+ if (exists($borderType)) then element ${QNS.a}prstDash {
+ attribute val { $borderType }
+ } else ()
+ }
+ `,
+ {
+ borderWidth: Math.round(
+ border.width?.emu ?? DEFAULT_BORDER_WIDTH_EMU
+ ),
+ // Without an explicit fill a word processor draws no line at all.
+ borderColor: border.color || DEFAULT_BORDER_COLOR,
+ borderType: border.type ?? null,
+ }
+ );
+ }
+
return create(
`
element ${QNS.w}drawing {
@@ -227,6 +307,12 @@ export class Image extends Component {
attribute cx { $width },
attribute cy { $height }
},
+ element ${QNS.wp}effectExtent {
+ attribute l { $effectExtent },
+ attribute t { $effectExtent },
+ attribute r { $effectExtent },
+ attribute b { $effectExtent }
+ },
element ${QNS.wp}docPr {
attribute id { $identifier },
attribute name { $name },
@@ -275,7 +361,8 @@ export class Image extends Component {
element ${QNS.a}prstGeom {
attribute prst { "rect" },
element ${QNS.a}avLst {}
- }
+ },
+ $borderNode
}
}
}
@@ -290,7 +377,13 @@ export class Image extends Component {
height: Math.round(this.props.height.emu),
name: this.props.title || '',
desc: this.props.alt || '',
+ // A border line is drawn on the edge of the image, so it needs room outside the
+ // extent or word processors will clip it.
+ effectExtent: border
+ ? Math.round(border.width?.emu ?? DEFAULT_BORDER_WIDTH_EMU)
+ : 0,
extensionList,
+ borderNode,
}
);
}
@@ -361,6 +454,7 @@ export class Image extends Component {
title,
width,
height,
+ border: extractBorderFromPicNode(picNode),
relationshipId: main.relationshipId,
});
image.#meta.location = main.location;
@@ -377,6 +471,33 @@ export class Image extends Component {
registerComponent(Image);
+function extractBorderFromPicNode(picNode: Node | null): ImageBorder | null {
+ if (picNode === null) {
+ return null;
+ }
+ const lineNode = evaluateXPathToFirstNode(
+ `./${QNS.pic}spPr/${QNS.a}ln`,
+ picNode
+ );
+ if (lineNode === null) {
+ return null;
+ }
+ const width = evaluateXPathToString(`./@w/string()`, lineNode);
+ const color = evaluateXPathToString(
+ `./${QNS.a}solidFill/${QNS.a}srgbClr/@val/string()`,
+ lineNode
+ );
+ const type = evaluateXPathToString(
+ `./${QNS.a}prstDash/@val/string()`,
+ lineNode
+ );
+ return {
+ width: width ? emu(Number(width)) : null,
+ color: color || null,
+ type: (type as ImageBorderType) || null,
+ };
+}
+
type ExtractedBlipNodeData = {
main: {
data: Promise;
diff --git a/lib/components/document/test/Image.test.ts b/lib/components/document/test/Image.test.ts
new file mode 100644
index 00000000..4c334a0b
--- /dev/null
+++ b/lib/components/document/test/Image.test.ts
@@ -0,0 +1,188 @@
+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 } from '../../../utilities/src/dom.ts';
+import { cm, pt } from '../../../utilities/src/length.ts';
+import { NamespaceUri, QNS } from '../../../utilities/src/namespaces.ts';
+import {
+ evaluateXPathToBoolean,
+ evaluateXPathToNumber,
+ evaluateXPathToString,
+} from '../../../utilities/src/xquery.ts';
+import { Image } from '../src/Image.ts';
+
+function createContext(): ComponentContext {
+ return {
+ archive: new Archive().addTextFile('word/media/image1.png', 'x'),
+ relationships: new RelationshipsXml('word/_rels/document.xml.rels', [
+ {
+ id: 'rId1',
+ type: RelationshipType.image,
+ target: 'word/media/image1.png',
+ isExternal: false,
+ isBinary: true,
+ },
+ ]),
+ bookmarks: new Bookmarks(),
+ };
+}
+
+describe('Image borders', () => {
+ it('serializes all border options', () => {
+ const node = new Image({
+ data: Promise.resolve(new Uint8Array()),
+ width: cm(1),
+ height: cm(1),
+ relationshipId: 'rId1',
+ border: { width: pt(1), color: 'ff0000', type: 'dash' },
+ }).toNode([]);
+
+ expect(
+ evaluateXPathToNumber(`descendant::${QNS.a}ln/@w/number()`, node)
+ ).toBe(12700);
+ expect(
+ evaluateXPathToString(
+ `descendant::${QNS.a}ln/${QNS.a}solidFill/${QNS.a}srgbClr/@val/string()`,
+ node
+ )
+ ).toBe('ff0000');
+ expect(
+ evaluateXPathToString(
+ `descendant::${QNS.a}ln/${QNS.a}prstDash/@val/string()`,
+ node
+ )
+ ).toBe('dash');
+ });
+
+ it('falls back to a visible line for border options that are not set', () => {
+ const node = new Image({
+ data: Promise.resolve(new Uint8Array()),
+ width: cm(1),
+ height: cm(1),
+ relationshipId: 'rId1',
+ border: { color: '00ff00' },
+ }).toNode([]);
+
+ expect(
+ evaluateXPathToNumber(`descendant::${QNS.a}ln/@w/number()`, node)
+ ).toBe(9525);
+ expect(
+ evaluateXPathToBoolean(`exists(descendant::${QNS.a}prstDash)`, node)
+ ).toBe(false);
+ expect(
+ evaluateXPathToString(
+ `descendant::${QNS.a}ln/${QNS.a}solidFill/${QNS.a}srgbClr/@val/string()`,
+ node
+ )
+ ).toBe('00ff00');
+ });
+
+ it('draws a black line when only a dash type is given', () => {
+ const node = new Image({
+ data: Promise.resolve(new Uint8Array()),
+ width: cm(1),
+ height: cm(1),
+ relationshipId: 'rId1',
+ border: { type: 'sysDot' },
+ }).toNode([]);
+
+ expect(
+ evaluateXPathToString(
+ `descendant::${QNS.a}ln/${QNS.a}solidFill/${QNS.a}srgbClr/@val/string()`,
+ node
+ )
+ ).toBe('000000');
+ });
+
+ it('reserves room for the border line so that it is not clipped', () => {
+ const node = new Image({
+ data: Promise.resolve(new Uint8Array()),
+ width: cm(1),
+ height: cm(1),
+ relationshipId: 'rId1',
+ border: { width: pt(3) },
+ }).toNode([]);
+
+ expect(
+ evaluateXPathToNumber(
+ `descendant::${QNS.wp}effectExtent/@t/number()`,
+ node
+ )
+ ).toBe(38100);
+ });
+
+ it('does not serialize a line when no border is given', () => {
+ const node = new Image({
+ data: Promise.resolve(new Uint8Array()),
+ width: cm(1),
+ height: cm(1),
+ relationshipId: 'rId1',
+ }).toNode([]);
+
+ expect(
+ evaluateXPathToBoolean(`exists(descendant::${QNS.a}ln)`, node)
+ ).toBe(false);
+ });
+
+ it('parses a border from an existing document', () => {
+ const image = Image.fromNode(
+ create(`
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `),
+ createContext()
+ );
+
+ expect(image.props.border?.width?.pt).toBe(1.5);
+ expect(image.props.border?.color).toBe('0000ff');
+ expect(image.props.border?.type).toBe('sysDot');
+ });
+
+ it('parses no border when the image has none', () => {
+ const image = Image.fromNode(
+ create(`
+
+
+
+
+
+
+
+
+
+
+
+
+
+ `),
+ createContext()
+ );
+
+ expect(image.props.border).toBe(null);
+ });
+});
diff --git a/mod.ts b/mod.ts
index e84fa408..6bbee5b9 100644
--- a/mod.ts
+++ b/mod.ts
@@ -112,6 +112,8 @@ export {
} from './lib/components/document/src/Hyperlink.ts';
export {
Image,
+ type ImageBorder,
+ type ImageBorderType,
type ImageChild,
type ImageProps,
} from './lib/components/document/src/Image.ts';