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. + + + + Description 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';