diff --git a/demo/5-images.ts b/demo/5-images.ts index 6039d6c832..7953b939cd 100644 --- a/demo/5-images.ts +++ b/demo/5-images.ts @@ -21,6 +21,7 @@ const doc = new Document({ new Paragraph({ children: [ new ImageRun({ + type: "jpg", data: fs.readFileSync("./demo/images/image1.jpeg"), transformation: { width: 100, @@ -37,6 +38,7 @@ const doc = new Document({ new Paragraph({ children: [ new ImageRun({ + type: "png", data: fs.readFileSync("./demo/images/dog.png").toString("base64"), transformation: { width: 100, @@ -53,6 +55,7 @@ const doc = new Document({ new Paragraph({ children: [ new ImageRun({ + type: "jpg", data: fs.readFileSync("./demo/images/cat.jpg"), transformation: { width: 100, @@ -73,6 +76,7 @@ const doc = new Document({ new Paragraph({ children: [ new ImageRun({ + type: "bmp", data: fs.readFileSync("./demo/images/parrots.bmp"), transformation: { width: 150, @@ -88,6 +92,7 @@ const doc = new Document({ new Paragraph({ children: [ new ImageRun({ + type: "gif", data: fs.readFileSync("./demo/images/pizza.gif"), transformation: { width: 200, @@ -103,6 +108,7 @@ const doc = new Document({ new Paragraph({ children: [ new ImageRun({ + type: "gif", data: fs.readFileSync("./demo/images/pizza.gif"), transformation: { width: 200, @@ -124,6 +130,7 @@ const doc = new Document({ new Paragraph({ children: [ new ImageRun({ + type: "jpg", data: fs.readFileSync("./demo/images/cat.jpg"), transformation: { width: 200, @@ -143,6 +150,22 @@ const doc = new Document({ }), ], }), + new Paragraph({ + children: [ + new ImageRun({ + type: "svg", + data: fs.readFileSync("./demo/images/linux-svg.svg"), + transformation: { + width: 200, + height: 200, + }, + fallback: { + type: "png", + data: fs.readFileSync("./demo/images/linux-png.png"), + }, + }), + ], + }), ], }, ], diff --git a/demo/images/linux-png.png b/demo/images/linux-png.png new file mode 100644 index 0000000000..f4e2d5ad27 Binary files /dev/null and b/demo/images/linux-png.png differ diff --git a/demo/images/linux-svg.svg b/demo/images/linux-svg.svg new file mode 100644 index 0000000000..da9140f12d --- /dev/null +++ b/demo/images/linux-svg.svg @@ -0,0 +1,183 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docs/_coverpage.md b/docs/_coverpage.md index b8a546f3a4..665583fc68 100644 --- a/docs/_coverpage.md +++ b/docs/_coverpage.md @@ -4,7 +4,7 @@ - Simple, declarative API - 80+ usage examples -- Battle tested, mature, 99.9%+ coverage +- Battle tested, mature, 100% coverage (yes, every line is tested) [GitHub](https://github.com/dolanmiu/docx) [Get Started](#Welcome) diff --git a/docs/usage/images.md b/docs/usage/images.md index 1326a780b2..8fa763b3bf 100644 --- a/docs/usage/images.md +++ b/docs/usage/images.md @@ -6,6 +6,7 @@ To create a `floating` image on top of text: ```ts const image = new ImageRun({ + type: 'gif', data: fs.readFileSync("./demo/images/pizza.gif"), transformation: { width: 200, @@ -26,6 +27,7 @@ By default with no arguments, its an `inline` image: ```ts const image = new ImageRun({ + type: 'gif', data: fs.readFileSync("./demo/images/pizza.gif"), transformation: { width: 100, @@ -59,6 +61,7 @@ const doc = new Document({ new Paragraph({ children: [ new ImageRun({ + type: [IMAGE_TYPE], data: [IMAGE_BUFFER], transformation: { width: [IMAGE_SIZE], @@ -97,6 +100,7 @@ To change the position the image to be on top of the text, simply add the `float ```ts const image = new ImageRun({ + type: 'png', data: buffer, transformation: { width: 903, @@ -115,6 +119,7 @@ const image = new ImageRun({ ```ts const image = new ImageRun({ + type: 'png', data: buffer, transformation: { width: 903, @@ -180,6 +185,7 @@ For example: ```ts const image = new ImageRun({ + type: 'gif', data: fs.readFileSync("./demo/images/pizza.gif"), transformation: { width: 200, @@ -228,6 +234,7 @@ For example: ```ts const image = new ImageRun({ + type: 'gif', data: fs.readFileSync("./demo/images/pizza.gif"), transformation: { width: 200, @@ -258,6 +265,7 @@ Specifies common non-visual DrawingML properties. A name, title and description ```ts const image = new ImageRun({ + type: 'gif', data: fs.readFileSync("./demo/images/pizza.gif"), altText: { title: "This is an ultimate title", diff --git a/docs/usage/patcher.md b/docs/usage/patcher.md index 6f76b1d180..fecc709dcf 100644 --- a/docs/usage/patcher.md +++ b/docs/usage/patcher.md @@ -76,7 +76,7 @@ patchDocument(fs.readFileSync("My Document.docx"), { ], link: "https://www.google.co.uk", }), - new ImageRun({ data: fs.readFileSync("./demo/images/dog.png"), transformation: { width: 100, height: 100 } }), + new ImageRun({ type: 'png', data: fs.readFileSync("./demo/images/dog.png"), transformation: { width: 100, height: 100 } }), ], }), ], diff --git a/src/export/packer/image-replacer.spec.ts b/src/export/packer/image-replacer.spec.ts index f9f5f33e47..33ff33f4cf 100644 --- a/src/export/packer/image-replacer.spec.ts +++ b/src/export/packer/image-replacer.spec.ts @@ -12,7 +12,8 @@ describe("ImageReplacer", () => { "test {test-image.png} test", [ { - stream: Buffer.from(""), + type: "png", + data: Buffer.from(""), fileName: "test-image.png", transformation: { pixels: { diff --git a/src/export/packer/next-compiler.spec.ts b/src/export/packer/next-compiler.spec.ts index 6713ee2734..753389de44 100644 --- a/src/export/packer/next-compiler.spec.ts +++ b/src/export/packer/next-compiler.spec.ts @@ -150,12 +150,25 @@ describe("Compiler", () => { new Paragraph({ children: [ new ImageRun({ + type: "png", data: Buffer.from("", "base64"), transformation: { width: 100, height: 100, }, }), + new ImageRun({ + type: "svg", + data: Buffer.from("", "base64"), + transformation: { + width: 100, + height: 100, + }, + fallback: { + type: "png", + data: Buffer.from("", "base64"), + }, + }), ], }), ], @@ -165,7 +178,8 @@ describe("Compiler", () => { vi.spyOn(compiler["imageReplacer"], "getMediaData").mockReturnValue([ { - stream: Buffer.from(""), + type: "png", + data: Buffer.from(""), fileName: "test", transformation: { pixels: { @@ -178,6 +192,36 @@ describe("Compiler", () => { }, }, }, + { + type: "svg", + data: Buffer.from(""), + fileName: "test", + transformation: { + pixels: { + x: 100, + y: 100, + }, + emus: { + x: 100, + y: 100, + }, + }, + fallback: { + type: "png", + data: Buffer.from(""), + fileName: "test", + transformation: { + pixels: { + x: 100, + y: 100, + }, + emus: { + x: 100, + y: 100, + }, + }, + }, + }, ]); compiler.compile(file); diff --git a/src/export/packer/next-compiler.ts b/src/export/packer/next-compiler.ts index 0b0434a0b2..b7df84c899 100644 --- a/src/export/packer/next-compiler.ts +++ b/src/export/packer/next-compiler.ts @@ -62,8 +62,13 @@ export class Compiler { } } - for (const { stream, fileName } of file.Media.Array) { - zip.file(`word/media/${fileName}`, stream); + for (const data of file.Media.Array) { + if (data.type !== "svg") { + zip.file(`word/media/${data.fileName}`, data.data); + } else { + zip.file(`word/media/${data.fileName}`, data.data); + zip.file(`word/media/${data.fallback.fileName}`, data.fallback.data); + } } for (const { data: buffer, name, fontKey } of file.FontTable.fontOptionsWithKey) { diff --git a/src/file/content-types/content-types.spec.ts b/src/file/content-types/content-types.spec.ts index a0203d0dae..1fac77993e 100644 --- a/src/file/content-types/content-types.spec.ts +++ b/src/file/content-types/content-types.spec.ts @@ -25,12 +25,13 @@ describe("ContentTypes", () => { expect(tree["Types"][3]).to.deep.equal({ Default: { _attr: { ContentType: "image/jpeg", Extension: "jpg" } } }); expect(tree["Types"][4]).to.deep.equal({ Default: { _attr: { ContentType: "image/bmp", Extension: "bmp" } } }); expect(tree["Types"][5]).to.deep.equal({ Default: { _attr: { ContentType: "image/gif", Extension: "gif" } } }); - expect(tree["Types"][6]).to.deep.equal({ + expect(tree["Types"][6]).to.deep.equal({ Default: { _attr: { ContentType: "image/svg+xml", Extension: "svg" } } }); + expect(tree["Types"][7]).to.deep.equal({ Default: { _attr: { ContentType: "application/vnd.openxmlformats-package.relationships+xml", Extension: "rels" } }, }); - expect(tree["Types"][7]).to.deep.equal({ Default: { _attr: { ContentType: "application/xml", Extension: "xml" } } }); + expect(tree["Types"][8]).to.deep.equal({ Default: { _attr: { ContentType: "application/xml", Extension: "xml" } } }); - expect(tree["Types"][8]).to.deep.equal({ + expect(tree["Types"][9]).to.deep.equal({ Default: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.obfuscatedFont", @@ -38,7 +39,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][9]).to.deep.equal({ + expect(tree["Types"][10]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", @@ -46,7 +47,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][10]).to.deep.equal({ + expect(tree["Types"][11]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml", @@ -54,7 +55,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][11]).to.deep.equal({ + expect(tree["Types"][12]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-package.core-properties+xml", @@ -62,7 +63,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][12]).to.deep.equal({ + expect(tree["Types"][13]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.custom-properties+xml", @@ -70,7 +71,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][13]).to.deep.equal({ + expect(tree["Types"][14]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.extended-properties+xml", @@ -78,7 +79,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][14]).to.deep.equal({ + expect(tree["Types"][15]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml", @@ -86,7 +87,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][15]).to.deep.equal({ + expect(tree["Types"][16]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", @@ -94,7 +95,7 @@ describe("ContentTypes", () => { }, }, }); - expect(tree["Types"][16]).to.deep.equal({ + expect(tree["Types"][17]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml", @@ -111,7 +112,7 @@ describe("ContentTypes", () => { contentTypes.addFooter(102); const tree = new Formatter().format(contentTypes); - expect(tree["Types"][19]).to.deep.equal({ + expect(tree["Types"][20]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml", @@ -120,7 +121,7 @@ describe("ContentTypes", () => { }, }); - expect(tree["Types"][20]).to.deep.equal({ + expect(tree["Types"][21]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml", @@ -137,7 +138,7 @@ describe("ContentTypes", () => { contentTypes.addHeader(202); const tree = new Formatter().format(contentTypes); - expect(tree["Types"][19]).to.deep.equal({ + expect(tree["Types"][20]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml", @@ -146,7 +147,7 @@ describe("ContentTypes", () => { }, }); - expect(tree["Types"][20]).to.deep.equal({ + expect(tree["Types"][21]).to.deep.equal({ Override: { _attr: { ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml", diff --git a/src/file/content-types/content-types.ts b/src/file/content-types/content-types.ts index 399581f7d6..fc7cc8a8b0 100644 --- a/src/file/content-types/content-types.ts +++ b/src/file/content-types/content-types.ts @@ -18,6 +18,7 @@ export class ContentTypes extends XmlComponent { this.root.push(new Default("image/jpeg", "jpg")); this.root.push(new Default("image/bmp", "bmp")); this.root.push(new Default("image/gif", "gif")); + this.root.push(new Default("image/svg+xml", "svg")); this.root.push(new Default("application/vnd.openxmlformats-package.relationships+xml", "rels")); this.root.push(new Default("application/xml", "xml")); this.root.push(new Default("application/vnd.openxmlformats-officedocument.obfuscatedFont", "odttf")); diff --git a/src/file/drawing/anchor/anchor.spec.ts b/src/file/drawing/anchor/anchor.spec.ts index 215aa087db..ab0c9cb401 100644 --- a/src/file/drawing/anchor/anchor.spec.ts +++ b/src/file/drawing/anchor/anchor.spec.ts @@ -11,8 +11,9 @@ import { Anchor } from "./anchor"; const createAnchor = (drawingOptions: IDrawingOptions): Anchor => new Anchor({ mediaData: { + type: "png", fileName: "test.png", - stream: Buffer.from(""), + data: Buffer.from(""), transformation: { pixels: { x: 0, diff --git a/src/file/drawing/drawing.spec.ts b/src/file/drawing/drawing.spec.ts index 60fe335792..73bb1fcd15 100644 --- a/src/file/drawing/drawing.spec.ts +++ b/src/file/drawing/drawing.spec.ts @@ -11,8 +11,9 @@ const imageBase64Data = `iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAMAAAD04JH5AAACzVBMVEU const createDrawing = (drawingOptions?: IDrawingOptions): Drawing => new Drawing( { + type: "jpg", fileName: "test.jpg", - stream: Buffer.from(imageBase64Data, "base64"), + data: Buffer.from(imageBase64Data, "base64"), transformation: { pixels: { x: 100, diff --git a/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip-extentions.ts b/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip-extentions.ts new file mode 100644 index 0000000000..909d87f80b --- /dev/null +++ b/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip-extentions.ts @@ -0,0 +1,35 @@ +import { BuilderElement, XmlComponent } from "@file/xml-components"; +import { IMediaData } from "@file/media"; + +const createSvgBlip = (mediaData: IMediaData): XmlComponent => + new BuilderElement({ + name: "asvg:svgBlip", + attributes: { + asvg: { + key: "xmlns:asvg", + value: "http://schemas.microsoft.com/office/drawing/2016/SVG/main", + }, + embed: { + key: "r:embed", + value: `rId{${mediaData.fileName}}`, + }, + }, + }); + +const createExtention = (mediaData: IMediaData): XmlComponent => + new BuilderElement({ + name: "a:ext", + attributes: { + uri: { + key: "uri", + value: "{96DAC541-7B7A-43D3-8B79-37D633B846F1}", + }, + }, + children: [createSvgBlip(mediaData)], + }); + +export const createExtentionList = (mediaData: IMediaData): XmlComponent => + new BuilderElement({ + name: "a:extLst", + children: [createExtention(mediaData)], + }); diff --git a/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip-fill.ts b/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip-fill.ts index 00b90fcef5..5f9bacebed 100644 --- a/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip-fill.ts +++ b/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip-fill.ts @@ -1,7 +1,7 @@ import { IMediaData } from "@file/media"; import { XmlComponent } from "@file/xml-components"; -import { Blip } from "./blip"; +import { createBlip } from "./blip"; import { SourceRectangle } from "./source-rectangle"; import { Stretch } from "./stretch"; @@ -9,7 +9,7 @@ export class BlipFill extends XmlComponent { public constructor(mediaData: IMediaData) { super("pic:blipFill"); - this.root.push(new Blip(mediaData)); + this.root.push(createBlip(mediaData)); this.root.push(new SourceRectangle()); this.root.push(new Stretch()); } diff --git a/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip.ts b/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip.ts index 8db38d6762..d322272737 100644 --- a/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip.ts +++ b/src/file/drawing/inline/graphic/graphic-data/pic/blip/blip.ts @@ -1,24 +1,24 @@ import { IMediaData } from "@file/media"; -import { XmlAttributeComponent, XmlComponent } from "@file/xml-components"; +import { BuilderElement, XmlComponent } from "@file/xml-components"; +import { createExtentionList } from "./blip-extentions"; -class BlipAttributes extends XmlAttributeComponent<{ +type BlipAttributes = { readonly embed: string; readonly cstate: string; -}> { - protected readonly xmlKeys = { - embed: "r:embed", - cstate: "cstate", - }; -} +}; -export class Blip extends XmlComponent { - public constructor(mediaData: IMediaData) { - super("a:blip"); - this.root.push( - new BlipAttributes({ - embed: `rId{${mediaData.fileName}}`, - cstate: "none", - }), - ); - } -} +export const createBlip = (mediaData: IMediaData): XmlComponent => + new BuilderElement({ + name: "a:blip", + attributes: { + embed: { + key: "r:embed", + value: `rId{${mediaData.type === "svg" ? mediaData.fallback.fileName : mediaData.fileName}}`, + }, + cstate: { + key: "cstate", + value: "none", + }, + }, + children: mediaData.type === "svg" ? [createExtentionList(mediaData)] : [], + }); diff --git a/src/file/drawing/inline/inline.spec.ts b/src/file/drawing/inline/inline.spec.ts index fca5ed61c9..1d2a791507 100644 --- a/src/file/drawing/inline/inline.spec.ts +++ b/src/file/drawing/inline/inline.spec.ts @@ -8,8 +8,9 @@ describe("Inline", () => { const tree = new Formatter().format( createInline({ mediaData: { + type: "png", fileName: "test.png", - stream: Buffer.from(""), + data: Buffer.from(""), transformation: { pixels: { x: 0, diff --git a/src/file/file.spec.ts b/src/file/file.spec.ts index ac0098a379..7f4cca4ae2 100644 --- a/src/file/file.spec.ts +++ b/src/file/file.spec.ts @@ -436,7 +436,44 @@ describe("File", () => { it("should work with external styles", () => { const doc = new File({ sections: [], - externalStyles: "", + externalStyles: ` + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + `, }); expect(doc.Styles).to.not.be.undefined; diff --git a/src/file/file.ts b/src/file/file.ts index 5aee17d7d1..47dd895166 100644 --- a/src/file/file.ts +++ b/src/file/file.ts @@ -84,7 +84,7 @@ export class File { this.media = new Media(); - if (options.externalStyles) { + if (options.externalStyles !== undefined) { const stylesFactory = new ExternalStylesFactory(); this.styles = stylesFactory.newInstance(options.externalStyles); } else if (options.styles) { diff --git a/src/file/media/data.ts b/src/file/media/data.ts index eb5ae4629d..1cdd76f2a7 100644 --- a/src/file/media/data.ts +++ b/src/file/media/data.ts @@ -14,11 +14,25 @@ export interface IMediaDataTransformation { readonly rotation?: number; } -export interface IMediaData { - readonly stream: Buffer | Uint8Array | ArrayBuffer; +type CoreMediaData = { readonly fileName: string; readonly transformation: IMediaDataTransformation; -} + readonly data: Buffer | Uint8Array | ArrayBuffer; +}; + +type RegularMediaData = { + readonly type: "jpg" | "png" | "gif" | "bmp"; +}; + +type SvgMediaData = { + readonly type: "svg"; + /** + * Required in case the Word processor does not support SVG. + */ + readonly fallback: RegularMediaData & CoreMediaData; +}; + +export type IMediaData = (RegularMediaData | SvgMediaData) & CoreMediaData; // Needed because of: https://github.com/s-panferov/awesome-typescript-loader/issues/432 /** diff --git a/src/file/media/media.spec.ts b/src/file/media/media.spec.ts index 1e59502726..8cc0d0f741 100644 --- a/src/file/media/media.spec.ts +++ b/src/file/media/media.spec.ts @@ -19,7 +19,8 @@ describe("Media", () => { const media = new Media(); media.addImage("test2.png", { - stream: Buffer.from(""), + type: "png", + data: Buffer.from(""), fileName: "test.png", transformation: { pixels: { diff --git a/src/file/paragraph/run/image-run.spec.ts b/src/file/paragraph/run/image-run.spec.ts index 77eff04070..f0fb8161f1 100644 --- a/src/file/paragraph/run/image-run.spec.ts +++ b/src/file/paragraph/run/image-run.spec.ts @@ -19,6 +19,7 @@ describe("ImageRun", () => { describe("#constructor()", () => { it("should create with Buffer", () => { const currentImageRun = new ImageRun({ + type: "png", data: Buffer.from(""), transformation: { width: 200, @@ -39,8 +40,7 @@ describe("ImageRun", () => { const tree = new Formatter().format(currentImageRun, { file: { Media: { - // eslint-disable-next-line @typescript-eslint/no-empty-function - addImage: () => {}, + addImage: vi.fn(), }, } as unknown as File, viewWrapper: {} as unknown as IViewWrapper, @@ -271,6 +271,7 @@ describe("ImageRun", () => { it("should create with string", () => { const currentImageRun = new ImageRun({ + type: "png", data: "", transformation: { width: 200, @@ -291,8 +292,7 @@ describe("ImageRun", () => { const tree = new Formatter().format(currentImageRun, { file: { Media: { - // eslint-disable-next-line @typescript-eslint/no-empty-function - addImage: () => {}, + addImage: vi.fn(), }, } as unknown as File, viewWrapper: {} as unknown as IViewWrapper, @@ -522,10 +522,10 @@ describe("ImageRun", () => { }); it("should return UInt8Array if atob is present", () => { - // eslint-disable-next-line functional/immutable-data - global.atob = () => "atob result"; + vi.spyOn(global, "atob").mockReturnValue("atob result"); const currentImageRun = new ImageRun({ + type: "png", data: "", transformation: { width: 200, @@ -546,8 +546,7 @@ describe("ImageRun", () => { const tree = new Formatter().format(currentImageRun, { file: { Media: { - // eslint-disable-next-line @typescript-eslint/no-empty-function - addImage: () => {}, + addImage: vi.fn(), }, } as unknown as File, viewWrapper: {} as unknown as IViewWrapper, @@ -775,16 +774,13 @@ describe("ImageRun", () => { }, ], }); - - // eslint-disable-next-line @typescript-eslint/no-explicit-any, functional/immutable-data - (global as any).atob = undefined; }); it("should use data as is if its not a string", () => { - // eslint-disable-next-line functional/immutable-data - global.atob = () => "atob result"; + vi.spyOn(global, "atob").mockReturnValue("atob result"); const currentImageRun = new ImageRun({ + type: "png", data: "", transformation: { width: 200, @@ -805,8 +801,7 @@ describe("ImageRun", () => { const tree = new Formatter().format(currentImageRun, { file: { Media: { - // eslint-disable-next-line @typescript-eslint/no-empty-function - addImage: () => {}, + addImage: vi.fn(), }, } as unknown as File, viewWrapper: {} as unknown as IViewWrapper, @@ -1034,9 +1029,107 @@ describe("ImageRun", () => { }, ], }); + }); - // eslint-disable-next-line @typescript-eslint/no-explicit-any, functional/immutable-data - (global as any).atob = undefined; + it("should strip base64 marker", () => { + const spy = vi.spyOn(global, "atob").mockReturnValue("atob result"); + + new ImageRun({ + type: "png", + data: ";base64,", + transformation: { + width: 200, + height: 200, + rotation: 45, + }, + }); + + expect(spy).toBeCalledWith(""); + }); + + it("should work with svgs", () => { + const currentImageRun = new ImageRun({ + type: "svg", + data: Buffer.from(""), + transformation: { + width: 200, + height: 200, + }, + fallback: { + type: "png", + data: Buffer.from(""), + }, + }); + + const tree = new Formatter().format(currentImageRun, { + file: { + Media: { + addImage: vi.fn(), + }, + } as unknown as File, + viewWrapper: {} as unknown as IViewWrapper, + stack: [], + }); + + expect(tree).toStrictEqual({ + "w:r": [ + { + "w:drawing": [ + { + "wp:inline": expect.arrayContaining([ + { + "a:graphic": expect.arrayContaining([ + { + "a:graphicData": expect.arrayContaining([ + { + "pic:pic": expect.arrayContaining([ + { + "pic:blipFill": expect.arrayContaining([ + { + "a:blip": [ + { + _attr: { + cstate: "none", + "r:embed": "rId{test-unique-id.png}", + }, + }, + { + "a:extLst": [ + { + "a:ext": [ + { + _attr: { + uri: "{96DAC541-7B7A-43D3-8B79-37D633B846F1}", + }, + }, + { + "asvg:svgBlip": { + _attr: expect.objectContaining({ + "r:embed": + "rId{test-unique-id.svg}", + }), + }, + }, + ], + }, + ], + }, + ], + }, + ]), + }, + ]), + }, + ]), + }, + ]), + }, + ]), + }, + ], + }, + ], + }); }); }); }); diff --git a/src/file/paragraph/run/image-run.ts b/src/file/paragraph/run/image-run.ts index 7a674a13c8..b2dfa1280e 100644 --- a/src/file/paragraph/run/image-run.ts +++ b/src/file/paragraph/run/image-run.ts @@ -9,38 +9,102 @@ import { IMediaTransformation } from "../../media"; import { IMediaData } from "../../media/data"; import { Run } from "../run"; -export interface IImageOptions { - readonly data: Buffer | string | Uint8Array | ArrayBuffer; +type CoreImageOptions = { readonly transformation: IMediaTransformation; readonly floating?: IFloating; readonly altText?: DocPropertiesOptions; readonly outline?: OutlineOptions; -} +}; + +type RegularImageOptions = { + readonly type: "jpg" | "png" | "gif" | "bmp"; + readonly data: Buffer | string | Uint8Array | ArrayBuffer; +}; + +type SvgMediaOptions = { + readonly type: "svg"; + readonly data: Buffer | string | Uint8Array | ArrayBuffer; + /** + * Required in case the Word processor does not support SVG. + */ + readonly fallback: RegularImageOptions; +}; + +export type IImageOptions = (RegularImageOptions | SvgMediaOptions) & CoreImageOptions; + +const convertDataURIToBinary = (dataURI: string): Uint8Array => { + if (typeof atob === "function") { + // https://gist.github.com/borismus/1032746 + // https://github.com/mafintosh/base64-to-uint8array + const BASE64_MARKER = ";base64,"; + const base64Index = dataURI.indexOf(BASE64_MARKER); + + const base64IndexWithOffset = base64Index === -1 ? 0 : base64Index + BASE64_MARKER.length; + + return new Uint8Array( + atob(dataURI.substring(base64IndexWithOffset)) + .split("") + .map((c) => c.charCodeAt(0)), + ); + /* c8 ignore next 6 */ + } else { + // Not possible to test this branch in NodeJS + // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires + const b = require("buf" + "fer"); + return new b.Buffer(dataURI, "base64"); + } +}; + +const standardizeData = (data: string | Buffer | Uint8Array | ArrayBuffer): Buffer | Uint8Array | ArrayBuffer => + typeof data === "string" ? convertDataURIToBinary(data) : data; + +const createImageData = (options: IImageOptions, key: string): Pick => ({ + data: standardizeData(options.data), + fileName: key, + transformation: { + pixels: { + x: Math.round(options.transformation.width), + y: Math.round(options.transformation.height), + }, + emus: { + x: Math.round(options.transformation.width * 9525), + y: Math.round(options.transformation.height * 9525), + }, + flip: options.transformation.flip, + rotation: options.transformation.rotation ? options.transformation.rotation * 60000 : undefined, + }, +}); export class ImageRun extends Run { - private readonly key = `${uniqueId()}.png`; + private readonly key: string; + private readonly fallbackKey = `${uniqueId()}.png`; private readonly imageData: IMediaData; public constructor(options: IImageOptions) { super({}); - const newData = typeof options.data === "string" ? this.convertDataURIToBinary(options.data) : options.data; - this.imageData = { - stream: newData, - fileName: this.key, - transformation: { - pixels: { - x: Math.round(options.transformation.width), - y: Math.round(options.transformation.height), - }, - emus: { - x: Math.round(options.transformation.width * 9525), - y: Math.round(options.transformation.height * 9525), - }, - flip: options.transformation.flip, - rotation: options.transformation.rotation ? options.transformation.rotation * 60000 : undefined, - }, - }; + this.key = `${uniqueId()}.${options.type}`; + + this.imageData = + options.type === "svg" + ? { + type: options.type, + ...createImageData(options, this.key), + fallback: { + type: options.fallback.type, + ...createImageData( + { + ...options.fallback, + transformation: options.transformation, + }, + this.fallbackKey, + ), + }, + } + : { + type: options.type, + ...createImageData(options, this.key), + }; const drawing = new Drawing(this.imageData, { floating: options.floating, docProperties: options.altText, @@ -53,29 +117,10 @@ export class ImageRun extends Run { public prepForXml(context: IContext): IXmlableObject | undefined { context.file.Media.addImage(this.key, this.imageData); + if (this.imageData.type === "svg") { + context.file.Media.addImage(this.fallbackKey, this.imageData.fallback); + } + return super.prepForXml(context); } - - private convertDataURIToBinary(dataURI: string): Uint8Array { - if (typeof atob === "function") { - // https://gist.github.com/borismus/1032746 - // https://github.com/mafintosh/base64-to-uint8array - const BASE64_MARKER = ";base64,"; - const base64Index = dataURI.indexOf(BASE64_MARKER); - - const base64IndexWithOffset = base64Index === -1 ? 0 : base64Index + BASE64_MARKER.length; - - return new Uint8Array( - atob(dataURI.substring(base64IndexWithOffset)) - .split("") - .map((c) => c.charCodeAt(0)), - ); - /* c8 ignore next 6 */ - } else { - // Not possible to test this branch in NodeJS - // eslint-disable-next-line @typescript-eslint/no-require-imports, @typescript-eslint/no-var-requires - const b = require("buf" + "fer"); - return new b.Buffer(dataURI, "base64"); - } - } } diff --git a/src/file/paragraph/run/run-components/text.ts b/src/file/paragraph/run/run-components/text.ts index d6bc671dbb..db6c8d77b0 100644 --- a/src/file/paragraph/run/run-components/text.ts +++ b/src/file/paragraph/run/run-components/text.ts @@ -23,11 +23,9 @@ export class Text extends XmlComponent { if (typeof options === "string") { this.root.push(new TextAttributes({ space: SpaceType.PRESERVE })); this.root.push(options); - return this; } else { this.root.push(new TextAttributes({ space: options.space ?? SpaceType.DEFAULT })); this.root.push(options.text); - return this; } } } diff --git a/src/patcher/from-docx.spec.ts b/src/patcher/from-docx.spec.ts index 088d0b715d..1b939bad17 100644 --- a/src/patcher/from-docx.spec.ts +++ b/src/patcher/from-docx.spec.ts @@ -254,6 +254,7 @@ describe("from-docx", () => { link: "https://www.google.co.uk", }), new ImageRun({ + type: "png", data: Buffer.from(""), transformation: { width: 100, height: 100 }, }), @@ -266,6 +267,7 @@ describe("from-docx", () => { type: PatchType.PARAGRAPH, children: [ new ImageRun({ + type: "png", data: Buffer.from(""), transformation: { width: 100, height: 100 }, }), @@ -310,6 +312,7 @@ describe("from-docx", () => { type: PatchType.PARAGRAPH, children: [ new ImageRun({ + type: "png", data: Buffer.from(""), transformation: { width: 100, height: 100 }, }), @@ -354,6 +357,7 @@ describe("from-docx", () => { type: PatchType.PARAGRAPH, children: [ new ImageRun({ + type: "png", data: Buffer.from(""), transformation: { width: 100, height: 100 }, }), @@ -391,6 +395,7 @@ describe("from-docx", () => { type: PatchType.PARAGRAPH, children: [ new ImageRun({ + type: "png", data: Buffer.from(""), transformation: { width: 100, height: 100 }, }), diff --git a/src/patcher/from-docx.ts b/src/patcher/from-docx.ts index 11c4c9c4ce..1098bf05ea 100644 --- a/src/patcher/from-docx.ts +++ b/src/patcher/from-docx.ts @@ -215,7 +215,7 @@ export const patchDocument = async (data: InputDataType, options: PatchDocumentO zip.file(key, value); } - for (const { stream, fileName } of file.Media.Array) { + for (const { data: stream, fileName } of file.Media.Array) { zip.file(`word/media/${fileName}`, stream); } diff --git a/vite.config.ts b/vite.config.ts index d2fdcd127e..e5d830db50 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -62,10 +62,10 @@ export default defineConfig({ provider: "v8", reporter: ["text", "json", "html"], thresholds: { - statements: 99.98, - branches: 99.15, + statements: 100, + branches: 99.35, functions: 100, - lines: 99.98, + lines: 100, }, exclude: [ ...configDefaults.exclude,