diff --git a/.cspell.json b/.cspell.json
index 1e8efa7a1e..93ca72762a 100644
--- a/.cspell.json
+++ b/.cspell.json
@@ -21,6 +21,7 @@
"iife",
"Initializable",
"iroha",
+ "JOHAB",
"jsonify",
"jszip",
"NUMPAGES",
diff --git a/demo/91-custom-fonts.ts b/demo/91-custom-fonts.ts
new file mode 100644
index 0000000000..e19c2e2750
--- /dev/null
+++ b/demo/91-custom-fonts.ts
@@ -0,0 +1,40 @@
+// Simple example to add text to a document
+
+import * as fs from "fs";
+import { CharacterSet, Document, Packer, Paragraph, Tab, TextRun } from "docx";
+
+const font = fs.readFileSync("./demo/assets/Pacifico.ttf");
+
+const doc = new Document({
+ sections: [
+ {
+ properties: {},
+ children: [
+ new Paragraph({
+ run: {
+ font: "Pacifico",
+ },
+ children: [
+ new TextRun("Hello World"),
+ new TextRun({
+ text: "Foo Bar",
+ bold: true,
+ size: 40,
+ font: "Pacifico",
+ }),
+ new TextRun({
+ children: [new Tab(), "Github is the best"],
+ bold: true,
+ font: "Pacifico",
+ }),
+ ],
+ }),
+ ],
+ },
+ ],
+ fonts: [{ name: "Pacifico", data: font, characterSet: CharacterSet.ANSI }],
+});
+
+Packer.toBuffer(doc).then((buffer) => {
+ fs.writeFileSync("My Document.docx", buffer);
+});
diff --git a/demo/92-declarative-custom-fonts.ts b/demo/92-declarative-custom-fonts.ts
new file mode 100644
index 0000000000..524d242a12
--- /dev/null
+++ b/demo/92-declarative-custom-fonts.ts
@@ -0,0 +1,44 @@
+// Simple example to add text to a document
+
+import * as fs from "fs";
+import { Document, Packer, Paragraph, Tab, TextRun } from "docx";
+
+const font = fs.readFileSync("./demo/assets/Pacifico.ttf");
+
+const doc = new Document({
+ styles: {
+ default: {
+ document: {
+ run: {
+ font: "Pacifico",
+ },
+ },
+ },
+ },
+ sections: [
+ {
+ properties: {},
+ children: [
+ new Paragraph({
+ children: [
+ new TextRun("Hello World"),
+ new TextRun({
+ text: "Foo Bar",
+ bold: true,
+ size: 40,
+ }),
+ new TextRun({
+ children: [new Tab(), "Github is the best"],
+ bold: true,
+ }),
+ ],
+ }),
+ ],
+ },
+ ],
+ fonts: [{ name: "Pacifico", data: font, characterSet: "00" }],
+});
+
+Packer.toBuffer(doc).then((buffer) => {
+ fs.writeFileSync("My Document.docx", buffer);
+});
diff --git a/demo/assets/Pacifico.ttf b/demo/assets/Pacifico.ttf
new file mode 100644
index 0000000000..6d47cdc9ac
Binary files /dev/null and b/demo/assets/Pacifico.ttf differ
diff --git a/demo/tsconfig.json b/demo/tsconfig.json
new file mode 100644
index 0000000000..70206942be
--- /dev/null
+++ b/demo/tsconfig.json
@@ -0,0 +1,10 @@
+{
+ "extends": "../tsconfig.json",
+ "compilerOptions": {
+ "rootDir": "./",
+ "paths": {
+ "docx": ["../build"]
+ }
+ },
+ "include": ["../demo"]
+}
\ No newline at end of file
diff --git a/src/export/packer/next-compiler.spec.ts b/src/export/packer/next-compiler.spec.ts
index 3ddfe34fb6..6713ee2734 100644
--- a/src/export/packer/next-compiler.spec.ts
+++ b/src/export/packer/next-compiler.spec.ts
@@ -36,7 +36,7 @@ describe("Compiler", () => {
const fileNames = Object.keys(zipFile.files).map((f) => zipFile.files[f].name);
expect(fileNames).is.an.instanceof(Array);
- expect(fileNames).has.length(17);
+ expect(fileNames).has.length(19);
expect(fileNames).to.include("word/document.xml");
expect(fileNames).to.include("word/styles.xml");
expect(fileNames).to.include("docProps/core.xml");
@@ -47,7 +47,9 @@ describe("Compiler", () => {
expect(fileNames).to.include("word/_rels/footnotes.xml.rels");
expect(fileNames).to.include("word/settings.xml");
expect(fileNames).to.include("word/comments.xml");
+ expect(fileNames).to.include("word/fontTable.xml");
expect(fileNames).to.include("word/_rels/document.xml.rels");
+ expect(fileNames).to.include("word/_rels/fontTable.xml.rels");
expect(fileNames).to.include("[Content_Types].xml");
expect(fileNames).to.include("_rels/.rels");
},
@@ -94,7 +96,7 @@ describe("Compiler", () => {
const fileNames = Object.keys(zipFile.files).map((f) => zipFile.files[f].name);
expect(fileNames).is.an.instanceof(Array);
- expect(fileNames).has.length(25);
+ expect(fileNames).has.length(27);
expect(fileNames).to.include("word/header1.xml");
expect(fileNames).to.include("word/_rels/header1.xml.rels");
@@ -127,12 +129,10 @@ describe("Compiler", () => {
const spy = vi.spyOn(compiler["formatter"], "format");
compiler.compile(file);
- expect(spy).toBeCalledTimes(13);
+ expect(spy).toBeCalledTimes(15);
});
it("should work with media datas", () => {
- // This test is required because before, there was a case where Document was formatted twice, which was inefficient
- // This also caused issues such as running prepForXml multiple times as format() was ran multiple times.
const file = new File({
sections: [
{
@@ -182,5 +182,14 @@ describe("Compiler", () => {
compiler.compile(file);
});
+
+ it("should work with fonts", () => {
+ const file = new File({
+ sections: [],
+ fonts: [{ name: "Pacifico", data: Buffer.from("") }],
+ });
+
+ compiler.compile(file);
+ });
});
});
diff --git a/src/export/packer/next-compiler.ts b/src/export/packer/next-compiler.ts
index e1ec950af2..0b0434a0b2 100644
--- a/src/export/packer/next-compiler.ts
+++ b/src/export/packer/next-compiler.ts
@@ -2,6 +2,7 @@ import JSZip from "jszip";
import xml from "xml";
import { File } from "@file/file";
+import { obfuscate } from "@file/fonts/obfuscate-ttf-to-odttf";
import { Formatter } from "../formatter";
import { ImageReplacer } from "./image-replacer";
@@ -31,6 +32,8 @@ interface IXmlifyedFileMapping {
readonly FootNotesRelationships: IXmlifyedFile;
readonly Settings: IXmlifyedFile;
readonly Comments?: IXmlifyedFile;
+ readonly FontTable?: IXmlifyedFile;
+ readonly FontTableRelationships?: IXmlifyedFile;
}
export class Compiler {
@@ -63,6 +66,11 @@ export class Compiler {
zip.file(`word/media/${fileName}`, stream);
}
+ for (const { data: buffer, name, fontKey } of file.FontTable.fontOptionsWithKey) {
+ const [nameWithoutExtension] = name.split(".");
+ zip.file(`word/fonts/${nameWithoutExtension}.odttf`, obfuscate(buffer, fontKey));
+ }
+
return zip;
}
@@ -439,6 +447,40 @@ export class Compiler {
),
path: "word/comments.xml",
},
+ FontTable: {
+ data: xml(
+ this.formatter.format(file.FontTable.View, {
+ viewWrapper: file.Document,
+ file,
+ stack: [],
+ }),
+ {
+ indent: prettify,
+ declaration: {
+ standalone: "yes",
+ encoding: "UTF-8",
+ },
+ },
+ ),
+ path: "word/fontTable.xml",
+ },
+ FontTableRelationships: {
+ data: (() =>
+ xml(
+ this.formatter.format(file.FontTable.Relationships, {
+ viewWrapper: file.Document,
+ file,
+ stack: [],
+ }),
+ {
+ indent: prettify,
+ declaration: {
+ encoding: "UTF-8",
+ },
+ },
+ ))(),
+ path: "word/_rels/fontTable.xml.rels",
+ },
};
}
}
diff --git a/src/file/content-types/content-types.spec.ts b/src/file/content-types/content-types.spec.ts
index 54e70889b2..a0203d0dae 100644
--- a/src/file/content-types/content-types.spec.ts
+++ b/src/file/content-types/content-types.spec.ts
@@ -29,7 +29,16 @@ describe("ContentTypes", () => {
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/vnd.openxmlformats-officedocument.obfuscatedFont",
+ Extension: "odttf",
+ },
+ },
+ });
+ expect(tree["Types"][9]).to.deep.equal({
Override: {
_attr: {
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml",
@@ -37,7 +46,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.styles+xml",
@@ -45,7 +54,7 @@ describe("ContentTypes", () => {
},
},
});
- expect(tree["Types"][10]).to.deep.equal({
+ expect(tree["Types"][11]).to.deep.equal({
Override: {
_attr: {
ContentType: "application/vnd.openxmlformats-package.core-properties+xml",
@@ -53,7 +62,7 @@ describe("ContentTypes", () => {
},
},
});
- expect(tree["Types"][11]).to.deep.equal({
+ expect(tree["Types"][12]).to.deep.equal({
Override: {
_attr: {
ContentType: "application/vnd.openxmlformats-officedocument.custom-properties+xml",
@@ -61,7 +70,7 @@ describe("ContentTypes", () => {
},
},
});
- expect(tree["Types"][12]).to.deep.equal({
+ expect(tree["Types"][13]).to.deep.equal({
Override: {
_attr: {
ContentType: "application/vnd.openxmlformats-officedocument.extended-properties+xml",
@@ -69,7 +78,7 @@ describe("ContentTypes", () => {
},
},
});
- expect(tree["Types"][13]).to.deep.equal({
+ expect(tree["Types"][14]).to.deep.equal({
Override: {
_attr: {
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml",
@@ -77,7 +86,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.footnotes+xml",
@@ -85,7 +94,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.settings+xml",
@@ -102,7 +111,7 @@ describe("ContentTypes", () => {
contentTypes.addFooter(102);
const tree = new Formatter().format(contentTypes);
- expect(tree["Types"][17]).to.deep.equal({
+ expect(tree["Types"][19]).to.deep.equal({
Override: {
_attr: {
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
@@ -111,7 +120,7 @@ describe("ContentTypes", () => {
},
});
- expect(tree["Types"][18]).to.deep.equal({
+ expect(tree["Types"][20]).to.deep.equal({
Override: {
_attr: {
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml",
@@ -128,7 +137,7 @@ describe("ContentTypes", () => {
contentTypes.addHeader(202);
const tree = new Formatter().format(contentTypes);
- expect(tree["Types"][17]).to.deep.equal({
+ expect(tree["Types"][19]).to.deep.equal({
Override: {
_attr: {
ContentType: "application/vnd.openxmlformats-officedocument.wordprocessingml.header+xml",
@@ -137,7 +146,7 @@ describe("ContentTypes", () => {
},
});
- expect(tree["Types"][18]).to.deep.equal({
+ expect(tree["Types"][20]).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 76cd7e819b..399581f7d6 100644
--- a/src/file/content-types/content-types.ts
+++ b/src/file/content-types/content-types.ts
@@ -20,6 +20,7 @@ export class ContentTypes extends XmlComponent {
this.root.push(new Default("image/gif", "gif"));
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"));
this.root.push(
new Override("application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml", "/word/document.xml"),
@@ -33,6 +34,7 @@ export class ContentTypes extends XmlComponent {
this.root.push(new Override("application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml", "/word/footnotes.xml"));
this.root.push(new Override("application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml", "/word/settings.xml"));
this.root.push(new Override("application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml", "/word/comments.xml"));
+ this.root.push(new Override("application/vnd.openxmlformats-officedocument.wordprocessingml.fontTable+xml", "/word/fontTable.xml"));
}
public addFooter(index: number): void {
diff --git a/src/file/core-properties/properties.ts b/src/file/core-properties/properties.ts
index d262802142..d51b1f23b7 100644
--- a/src/file/core-properties/properties.ts
+++ b/src/file/core-properties/properties.ts
@@ -1,5 +1,6 @@
import { ICommentsOptions } from "@file/paragraph/run/comment-run";
import { ICompatibilityOptions } from "@file/settings/compatibility";
+import { FontOptions } from "@file/fonts/font-table";
import { StringContainer, XmlComponent } from "@file/xml-components";
import { dateTimeValue } from "@util/values";
@@ -40,6 +41,7 @@ export interface IPropertiesOptions {
readonly customProperties?: readonly ICustomPropertyOptions[];
readonly evenAndOddHeaderAndFooters?: boolean;
readonly defaultTabStop?: number;
+ readonly fonts?: readonly FontOptions[];
}
//
diff --git a/src/file/document-wrapper.ts b/src/file/document-wrapper.ts
index 578e8c8f58..e60b2a1927 100644
--- a/src/file/document-wrapper.ts
+++ b/src/file/document-wrapper.ts
@@ -1,3 +1,4 @@
+import { XmlComponent } from "./xml-components";
import { Document, IDocumentOptions } from "./document";
import { Footer } from "./footer/footer";
import { FootNotes } from "./footnotes";
@@ -5,7 +6,7 @@ import { Header } from "./header/header";
import { Relationships } from "./relationships";
export interface IViewWrapper {
- readonly View: Document | Footer | Header | FootNotes;
+ readonly View: Document | Footer | Header | FootNotes | XmlComponent;
readonly Relationships: Relationships;
}
diff --git a/src/file/file.ts b/src/file/file.ts
index 1046d874a5..5aee17d7d1 100644
--- a/src/file/file.ts
+++ b/src/file/file.ts
@@ -17,6 +17,7 @@ import { Styles } from "./styles";
import { ExternalStylesFactory } from "./styles/external-styles-factory";
import { DefaultStylesFactory } from "./styles/factory";
import { FileChild } from "./file-child";
+import { FontWrapper } from "./fonts/font-wrapper";
export interface ISectionOptions {
readonly headers?: {
@@ -53,6 +54,7 @@ export class File {
private readonly appProperties: AppProperties;
private readonly styles: Styles;
private readonly comments: Comments;
+ private readonly fontWrapper: FontWrapper;
public constructor(options: IPropertiesOptions) {
this.coreProperties = new CoreProperties({
@@ -109,6 +111,8 @@ export class File {
this.footnotesWrapper.View.createFootNote(parseFloat(key), options.footnotes[key].children);
}
}
+
+ this.fontWrapper = new FontWrapper(options.fonts ?? []);
}
private addSection({ headers = {}, footers = {}, children, properties }: ISectionOptions): void {
@@ -292,4 +296,8 @@ export class File {
public get Comments(): Comments {
return this.comments;
}
+
+ public get FontTable(): FontWrapper {
+ return this.fontWrapper;
+ }
}
diff --git a/src/file/fonts/create-regular-font.ts b/src/file/fonts/create-regular-font.ts
new file mode 100644
index 0000000000..e72bd6541c
--- /dev/null
+++ b/src/file/fonts/create-regular-font.ts
@@ -0,0 +1,33 @@
+import { XmlComponent } from "@file/xml-components";
+
+import { CharacterSet, createFont } from "./font";
+
+export const createRegularFont = ({
+ name,
+ index,
+ fontKey,
+ characterSet,
+}: {
+ readonly name: string;
+ readonly index: number;
+ readonly fontKey: string;
+ readonly characterSet?: (typeof CharacterSet)[keyof typeof CharacterSet];
+}): XmlComponent =>
+ createFont({
+ name,
+ sig: {
+ usb0: "E0002AFF",
+ usb1: "C000247B",
+ usb2: "00000009",
+ usb3: "00000000",
+ csb0: "000001FF",
+ csb1: "00000000",
+ },
+ charset: characterSet,
+ family: "auto",
+ pitch: "variable",
+ embedRegular: {
+ fontKey,
+ id: `rId${index}`,
+ },
+ });
diff --git a/src/file/fonts/font-table.ts b/src/file/fonts/font-table.ts
new file mode 100644
index 0000000000..fce632a9e2
--- /dev/null
+++ b/src/file/fonts/font-table.ts
@@ -0,0 +1,44 @@
+import { BuilderElement, XmlComponent } from "@file/xml-components";
+
+import { createRegularFont } from "./create-regular-font";
+import { FontOptionsWithKey } from "./font-wrapper";
+import { CharacterSet } from "./font";
+
+//
+//
+//
+//
+//
+
+export type FontOptions = {
+ readonly name: string;
+ readonly data: Buffer;
+ readonly characterSet?: (typeof CharacterSet)[keyof typeof CharacterSet];
+};
+
+export const createFontTable = (fonts: readonly FontOptionsWithKey[]): XmlComponent =>
+ // https://c-rex.net/projects/samples/ooxml/e1/Part4/OOXML_P4_DOCX_Font_topic_ID0ERNCU.html
+ // http://www.datypic.com/sc/ooxml/e-w_fonts.html
+ new BuilderElement({
+ name: "w:fonts",
+ attributes: {
+ mc: { key: "xmlns:mc", value: "http://schemas.openxmlformats.org/markup-compatibility/2006" },
+ r: { key: "xmlns:r", value: "http://schemas.openxmlformats.org/officeDocument/2006/relationships" },
+ w: { key: "xmlns:w", value: "http://schemas.openxmlformats.org/wordprocessingml/2006/main" },
+ w14: { key: "xmlns:w14", value: "http://schemas.microsoft.com/office/word/2010/wordml" },
+ w15: { key: "xmlns:w15", value: "http://schemas.microsoft.com/office/word/2012/wordml" },
+ w16cex: { key: "xmlns:w16cex", value: "http://schemas.microsoft.com/office/word/2018/wordml/cex" },
+ w16cid: { key: "xmlns:w16cid", value: "http://schemas.microsoft.com/office/word/2016/wordml/cid" },
+ w16: { key: "xmlns:w16", value: "http://schemas.microsoft.com/office/word/2018/wordml" },
+ w16sdtdh: { key: "xmlns:w16sdtdh", value: "http://schemas.microsoft.com/office/word/2020/wordml/sdtdatahash" },
+ w16se: { key: "xmlns:w16se", value: "http://schemas.microsoft.com/office/word/2015/wordml/symex" },
+ Ignorable: { key: "mc:Ignorable", value: "w14 w15 w16se w16cid w16 w16cex w16sdtdh" },
+ },
+ children: fonts.map((font, i) =>
+ createRegularFont({
+ name: font.name,
+ index: i + 1,
+ fontKey: font.fontKey,
+ }),
+ ),
+ });
diff --git a/src/file/fonts/font-wrapper.ts b/src/file/fonts/font-wrapper.ts
new file mode 100644
index 0000000000..5013df398d
--- /dev/null
+++ b/src/file/fonts/font-wrapper.ts
@@ -0,0 +1,36 @@
+import { IViewWrapper } from "@file/document-wrapper";
+import { Relationships } from "@file/relationships";
+import { XmlComponent } from "@file/xml-components";
+import { uniqueUuid } from "@util/convenience-functions";
+
+import { FontOptions, createFontTable } from "./font-table";
+
+export type FontOptionsWithKey = FontOptions & { readonly fontKey: string };
+
+export class FontWrapper implements IViewWrapper {
+ private readonly fontTable: XmlComponent;
+ private readonly relationships: Relationships;
+ public readonly fontOptionsWithKey: readonly FontOptionsWithKey[] = [];
+
+ public constructor(public readonly options: readonly FontOptions[]) {
+ this.fontOptionsWithKey = options.map((o) => ({ ...o, fontKey: uniqueUuid() }));
+ this.fontTable = createFontTable(this.fontOptionsWithKey);
+ this.relationships = new Relationships();
+
+ for (let i = 0; i < options.length; i++) {
+ this.relationships.createRelationship(
+ i + 1,
+ "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font",
+ `fonts/${options[i].name}.odttf`,
+ );
+ }
+ }
+
+ public get View(): XmlComponent {
+ return this.fontTable;
+ }
+
+ public get Relationships(): Relationships {
+ return this.relationships;
+ }
+}
diff --git a/src/file/fonts/font.spec.ts b/src/file/fonts/font.spec.ts
new file mode 100644
index 0000000000..bfb0bc2efc
--- /dev/null
+++ b/src/file/fonts/font.spec.ts
@@ -0,0 +1,223 @@
+import { describe, expect, it } from "vitest";
+
+import { Formatter } from "@export/formatter";
+
+import { createFont } from "./font";
+
+describe("font", () => {
+ it("should work", () => {
+ const tree = new Formatter().format(
+ createFont({
+ name: "Times New Roman",
+ altName: "Times New Roman",
+ family: "roman",
+ charset: "00",
+ panose1: "02020603050405020304",
+ pitch: "variable",
+ embedRegular: {
+ id: "rId0",
+ fontKey: "00000000-0000-0000-0000-000000000000",
+ },
+ }),
+ );
+
+ expect(tree).to.deep.equal({
+ "w:font": [
+ {
+ _attr: {
+ "w:name": "Times New Roman",
+ },
+ },
+ {
+ "w:altName": {
+ _attr: {
+ "w:val": "Times New Roman",
+ },
+ },
+ },
+ {
+ "w:panose1": {
+ _attr: {
+ "w:val": "02020603050405020304",
+ },
+ },
+ },
+ {
+ "w:charset": {
+ _attr: {
+ "w:val": "00",
+ },
+ },
+ },
+ {
+ "w:family": {
+ _attr: {
+ "w:val": "roman",
+ },
+ },
+ },
+ {
+ "w:pitch": {
+ _attr: {
+ "w:val": "variable",
+ },
+ },
+ },
+ {
+ "w:embedRegular": {
+ _attr: {
+ "r:id": "rId0",
+ "w:fontKey": "{00000000-0000-0000-0000-000000000000}",
+ },
+ },
+ },
+ ],
+ });
+ });
+
+ it("should work for embedBold", () => {
+ const tree = new Formatter().format(
+ createFont({
+ name: "Times New Roman",
+ embedBold: {
+ id: "rId0",
+ fontKey: "00000000-0000-0000-0000-000000000000",
+ },
+ }),
+ );
+
+ expect(tree).toStrictEqual({
+ "w:font": expect.arrayContaining([
+ {
+ "w:embedBold": {
+ _attr: {
+ "r:id": "rId0",
+ "w:fontKey": "{00000000-0000-0000-0000-000000000000}",
+ },
+ },
+ },
+ ]),
+ });
+ });
+
+ it("should work for embedBoldItalic", () => {
+ const tree = new Formatter().format(
+ createFont({
+ name: "Times New Roman",
+ embedBoldItalic: {
+ id: "rId0",
+ fontKey: "00000000-0000-0000-0000-000000000000",
+ },
+ }),
+ );
+
+ expect(tree).toStrictEqual({
+ "w:font": expect.arrayContaining([
+ {
+ "w:embedBoldItalic": {
+ _attr: {
+ "r:id": "rId0",
+ "w:fontKey": "{00000000-0000-0000-0000-000000000000}",
+ },
+ },
+ },
+ ]),
+ });
+ });
+
+ it("should work for embedItalic", () => {
+ const tree = new Formatter().format(
+ createFont({
+ name: "Times New Roman",
+ embedItalic: {
+ id: "rId0",
+ fontKey: "00000000-0000-0000-0000-000000000000",
+ },
+ }),
+ );
+
+ expect(tree).toStrictEqual({
+ "w:font": expect.arrayContaining([
+ {
+ "w:embedItalic": {
+ _attr: {
+ "r:id": "rId0",
+ "w:fontKey": "{00000000-0000-0000-0000-000000000000}",
+ },
+ },
+ },
+ ]),
+ });
+ });
+
+ it("should work for notTrueType", () => {
+ const tree = new Formatter().format(
+ createFont({
+ name: "Times New Roman",
+ embedRegular: {
+ id: "rId0",
+ fontKey: "00000000-0000-0000-0000-000000000000",
+ subsetted: true,
+ },
+ }),
+ );
+
+ expect(tree).toStrictEqual({
+ "w:font": expect.arrayContaining([
+ {
+ "w:embedRegular": [
+ {
+ _attr: {
+ "r:id": "rId0",
+ "w:fontKey": "{00000000-0000-0000-0000-000000000000}",
+ },
+ },
+ {
+ "w:subsetted": {},
+ },
+ ],
+ },
+ ]),
+ });
+ });
+
+ it("should work for subsetted", () => {
+ const tree = new Formatter().format(
+ createFont({
+ name: "Times New Roman",
+ notTrueType: true,
+ }),
+ );
+
+ expect(tree).toStrictEqual({
+ "w:font": expect.arrayContaining([
+ {
+ "w:notTrueType": {},
+ },
+ ]),
+ });
+ });
+
+ it("should work without fontKey", () => {
+ const tree = new Formatter().format(
+ createFont({
+ name: "Times New Roman",
+ embedItalic: {
+ id: "rId0",
+ },
+ }),
+ );
+
+ expect(tree).toStrictEqual({
+ "w:font": expect.arrayContaining([
+ {
+ "w:embedItalic": {
+ _attr: {
+ "r:id": "rId0",
+ },
+ },
+ },
+ ]),
+ });
+ });
+});
diff --git a/src/file/fonts/font.ts b/src/file/fonts/font.ts
new file mode 100644
index 0000000000..618bcd6293
--- /dev/null
+++ b/src/file/fonts/font.ts
@@ -0,0 +1,156 @@
+import { BuilderElement, createStringElement, OnOffElement, XmlComponent } from "@file/xml-components";
+
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+//
+
+//
+//
+//
+//
+//
+//
+//
+//
+
+// http://www.datypic.com/sc/ooxml/e-w_embedRegular-1.html
+export interface IFontRelationshipOptions {
+ /**
+ * Relationship to Part
+ */
+ readonly id: string;
+ /**
+ * Embedded Font Obfuscation Key
+ */
+ readonly fontKey?: string;
+ /**
+ * Embedded Font Is Subsetted
+ */
+ readonly subsetted?: boolean;
+}
+
+export const CharacterSet = {
+ ANSI: "00",
+ DEFAULT: "01",
+ SYMBOL: "02",
+ MAC: "4D",
+ JIS: "80",
+ HANGUL: "81",
+ JOHAB: "82",
+ GB_2312: "86",
+ CHINESEBIG5: "88",
+ GREEK: "A1",
+ TURKISH: "A2",
+ VIETNAMESE: "A3",
+ HEBREW: "B1",
+ ARABIC: "B2",
+ BALTIC: "BA",
+ RUSSIAN: "CC",
+ THAI: "DE",
+ EASTEUROPE: "EE",
+ OEM: "FF",
+} as const;
+
+export type FontOptions = {
+ readonly name: string;
+ readonly altName?: string;
+ readonly panose1?: string;
+ readonly charset?: (typeof CharacterSet)[keyof typeof CharacterSet];
+ readonly family?: string;
+ readonly notTrueType?: boolean;
+ readonly pitch?: string;
+ readonly sig?: {
+ readonly usb0: string;
+ readonly usb1: string;
+ readonly usb2: string;
+ readonly usb3: string;
+ readonly csb0: string;
+ readonly csb1: string;
+ };
+ readonly embedRegular?: IFontRelationshipOptions;
+ readonly embedBold?: IFontRelationshipOptions;
+ readonly embedItalic?: IFontRelationshipOptions;
+ readonly embedBoldItalic?: IFontRelationshipOptions;
+};
+
+const createFontRelationship = ({ id, fontKey, subsetted }: IFontRelationshipOptions, name: string): XmlComponent =>
+ new BuilderElement({
+ name,
+ attributes: {
+ id: { key: "r:id", value: id },
+ ...(fontKey ? { fontKey: { key: "w:fontKey", value: `{${fontKey}}` } } : {}),
+ },
+ children: [...(subsetted ? [new OnOffElement("w:subsetted", subsetted)] : [])],
+ });
+
+export const createFont = ({
+ name,
+ altName,
+ panose1,
+ charset,
+ family,
+ notTrueType,
+ pitch,
+ sig,
+ embedRegular,
+ embedBold,
+ embedItalic,
+ embedBoldItalic,
+}: FontOptions): XmlComponent =>
+ // http://www.datypic.com/sc/ooxml/e-w_font-1.html
+ new BuilderElement({
+ name: "w:font",
+ attributes: {
+ name: { key: "w:name", value: name },
+ },
+ children: [
+ // http://www.datypic.com/sc/ooxml/e-w_altName-1.html
+ ...(altName ? [createStringElement("w:altName", altName)] : []),
+ // http://www.datypic.com/sc/ooxml/e-w_panose1-1.html
+ ...(panose1 ? [createStringElement("w:panose1", panose1)] : []),
+ // http://www.datypic.com/sc/ooxml/e-w_charset-1.html
+ ...(charset ? [createStringElement("w:charset", charset)] : []),
+ // http://www.datypic.com/sc/ooxml/e-w_family-1.html
+ ...(family ? [createStringElement("w:family", family)] : []),
+ // http://www.datypic.com/sc/ooxml/e-w_notTrueType-1.html
+ ...(notTrueType ? [new OnOffElement("w:notTrueType", notTrueType)] : []),
+ ...(pitch ? [createStringElement("w:pitch", pitch)] : []),
+ // http://www.datypic.com/sc/ooxml/e-w_sig-1.html
+ ...(sig
+ ? [
+ new BuilderElement({
+ name: "w:sig",
+ attributes: {
+ usb0: { key: "w:usb0", value: sig.usb0 },
+ usb1: { key: "w:usb1", value: sig.usb1 },
+ usb2: { key: "w:usb2", value: sig.usb2 },
+ usb3: { key: "w:usb3", value: sig.usb3 },
+ csb0: { key: "w:csb0", value: sig.csb0 },
+ csb1: { key: "w:csb1", value: sig.csb1 },
+ },
+ }),
+ ]
+ : []),
+ // http://www.datypic.com/sc/ooxml/e-w_embedRegular-1.html
+ ...(embedRegular ? [createFontRelationship(embedRegular, "w:embedRegular")] : []),
+ // http://www.datypic.com/sc/ooxml/e-w_embedBold-1.html
+ ...(embedBold ? [createFontRelationship(embedBold, "w:embedBold")] : []),
+ // http://www.datypic.com/sc/ooxml/e-w_embedItalic-1.html
+ ...(embedItalic ? [createFontRelationship(embedItalic, "w:embedItalic")] : []),
+ // http://www.datypic.com/sc/ooxml/e-w_embedBoldItalic-1.html
+ ...(embedBoldItalic ? [createFontRelationship(embedBoldItalic, "w:embedBoldItalic")] : []),
+ ],
+ });
diff --git a/src/file/fonts/index.ts b/src/file/fonts/index.ts
new file mode 100644
index 0000000000..4476a0a9e1
--- /dev/null
+++ b/src/file/fonts/index.ts
@@ -0,0 +1 @@
+export { CharacterSet } from "./font";
diff --git a/src/file/fonts/obfuscate-ttf-to-odttf.ts b/src/file/fonts/obfuscate-ttf-to-odttf.ts
new file mode 100644
index 0000000000..cf11ad0b89
--- /dev/null
+++ b/src/file/fonts/obfuscate-ttf-to-odttf.ts
@@ -0,0 +1,22 @@
+const obfuscatedStartOffset = 0;
+const obfuscatedEndOffset = 32;
+const guidSize = 32;
+
+export const obfuscate = (buf: Buffer, fontKey: string): Buffer => {
+ const guid = fontKey.replace(/-/g, "");
+ if (guid.length !== guidSize) {
+ throw new Error(`Error: Cannot extract GUID from font filename: ${fontKey}`);
+ }
+
+ const hexStrings = guid.replace(/(..)/g, "$1 ").trim().split(" ");
+ const hexNumbers = hexStrings.map((hexString) => parseInt(hexString, 16));
+ // eslint-disable-next-line functional/immutable-data
+ hexNumbers.reverse();
+
+ const bytesToObfuscate = buf.slice(obfuscatedStartOffset, obfuscatedEndOffset);
+ // eslint-disable-next-line no-bitwise
+ const obfuscatedBytes = bytesToObfuscate.map((byte, i) => byte ^ hexNumbers[i % hexNumbers.length]);
+
+ const out = Buffer.concat([buf.slice(0, obfuscatedStartOffset), obfuscatedBytes, buf.slice(obfuscatedEndOffset)]);
+ return out;
+};
diff --git a/src/file/fonts/obsfuscate-ttf-to-odtts.spec.ts b/src/file/fonts/obsfuscate-ttf-to-odtts.spec.ts
new file mode 100644
index 0000000000..54412bbcd9
--- /dev/null
+++ b/src/file/fonts/obsfuscate-ttf-to-odtts.spec.ts
@@ -0,0 +1,14 @@
+import { describe, expect, it } from "vitest";
+
+import { obfuscate } from "./obfuscate-ttf-to-odttf";
+
+describe("obfuscate", () => {
+ it("should work", () => {
+ const buffer = obfuscate(Buffer.from(""), "00000000-0000-0000-0000-000000000000");
+ expect(buffer).toBeDefined();
+ });
+
+ it("should throw error if uuid is not correct", () => {
+ expect(() => obfuscate(Buffer.from(""), "bad-uuid")).toThrowError();
+ });
+});
diff --git a/src/file/index.ts b/src/file/index.ts
index 6cf75e4f24..db03f4ec7b 100644
--- a/src/file/index.ts
+++ b/src/file/index.ts
@@ -18,3 +18,4 @@ export * from "./shared";
export * from "./border";
export * from "./vertical-align";
export * from "./checkbox";
+export * from "./fonts";
diff --git a/src/file/paragraph/run/image-run.ts b/src/file/paragraph/run/image-run.ts
index 6945ff8e7e..7a674a13c8 100644
--- a/src/file/paragraph/run/image-run.ts
+++ b/src/file/paragraph/run/image-run.ts
@@ -70,8 +70,8 @@ export class ImageRun extends Run {
.split("")
.map((c) => c.charCodeAt(0)),
);
+ /* c8 ignore next 6 */
} else {
- /* c8 ignore next 4 */
// 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");
diff --git a/src/file/relationships/relationship/relationship.ts b/src/file/relationships/relationship/relationship.ts
index 099c7c11f1..4734d61d58 100644
--- a/src/file/relationships/relationship/relationship.ts
+++ b/src/file/relationships/relationship/relationship.ts
@@ -17,7 +17,8 @@ export type RelationshipType =
| "http://schemas.openxmlformats.org/officeDocument/2006/relationships/extended-properties"
| "http://schemas.openxmlformats.org/officeDocument/2006/relationships/hyperlink"
| "http://schemas.openxmlformats.org/officeDocument/2006/relationships/footnotes"
- | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments";
+ | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments"
+ | "http://schemas.openxmlformats.org/officeDocument/2006/relationships/font";
export const TargetModeType = {
EXTERNAL: "External",
diff --git a/src/file/xml-components/imported-xml-component.ts b/src/file/xml-components/imported-xml-component.ts
index 202ae2d339..e90c169dd9 100644
--- a/src/file/xml-components/imported-xml-component.ts
+++ b/src/file/xml-components/imported-xml-component.ts
@@ -30,6 +30,7 @@ export const convertToXmlComponent = (element: XmlElement): ImportedXmlComponent
return element.text as string;
default:
return undefined;
+ /* c8 ignore next 2 */
}
};
diff --git a/src/file/xml-components/simple-elements.ts b/src/file/xml-components/simple-elements.ts
index 898c65e567..3a4651e4c9 100644
--- a/src/file/xml-components/simple-elements.ts
+++ b/src/file/xml-components/simple-elements.ts
@@ -55,6 +55,14 @@ export class StringValueElement extends XmlComponent {
}
}
+export const createStringElement = (name: string, value: string): XmlComponent =>
+ new BuilderElement({
+ name,
+ attributes: {
+ value: { key: "w:val", value },
+ },
+ });
+
// This represents various number element types.
export class NumberValueElement extends XmlComponent {
public constructor(name: string, val: number) {
@@ -82,19 +90,23 @@ export class StringContainer extends XmlComponent {
}
export class BuilderElement extends XmlComponent {
- public constructor(options: {
+ public constructor({
+ name,
+ attributes,
+ children,
+ }: {
readonly name: string;
readonly attributes?: AttributePayload;
readonly children?: readonly XmlComponent[];
}) {
- super(options.name);
+ super(name);
- if (options.attributes) {
- this.root.push(new NextAttributeComponent(options.attributes));
+ if (attributes) {
+ this.root.push(new NextAttributeComponent(attributes));
}
- for (const child of options.children ?? []) {
- this.root.push(child);
+ if (children) {
+ this.root.push(...children);
}
}
}
diff --git a/src/util/convenience-functions.spec.ts b/src/util/convenience-functions.spec.ts
index 533bae4f09..26b52e7851 100644
--- a/src/util/convenience-functions.spec.ts
+++ b/src/util/convenience-functions.spec.ts
@@ -1,6 +1,16 @@
import { describe, expect, it } from "vitest";
-import { convertInchesToTwip, convertMillimetersToTwip, uniqueId, uniqueNumericIdCreator } from "./convenience-functions";
+import {
+ abstractNumUniqueNumericIdGen,
+ bookmarkUniqueNumericIdGen,
+ concreteNumUniqueNumericIdGen,
+ convertInchesToTwip,
+ convertMillimetersToTwip,
+ docPropertiesUniqueNumericIdGen,
+ uniqueId,
+ uniqueNumericIdCreator,
+ uniqueUuid,
+} from "./convenience-functions";
describe("Utility", () => {
describe("#convertMillimetersToTwip", () => {
@@ -24,9 +34,47 @@ describe("Utility", () => {
});
});
+ describe("#abstractNumUniqueNumericIdGen", () => {
+ it("should generate a unique incrementing ID", () => {
+ const uniqueNumericId = abstractNumUniqueNumericIdGen();
+ expect(uniqueNumericId()).to.equal(1);
+ expect(uniqueNumericId()).to.equal(2);
+ });
+ });
+
+ describe("#concreteNumUniqueNumericIdGen", () => {
+ it("should generate a unique incrementing ID", () => {
+ const uniqueNumericId = concreteNumUniqueNumericIdGen();
+ expect(uniqueNumericId()).to.equal(2);
+ expect(uniqueNumericId()).to.equal(3);
+ });
+ });
+
+ describe("#docPropertiesUniqueNumericIdGen", () => {
+ it("should generate a unique incrementing ID", () => {
+ const uniqueNumericId = docPropertiesUniqueNumericIdGen();
+ expect(uniqueNumericId()).to.equal(1);
+ expect(uniqueNumericId()).to.equal(2);
+ });
+ });
+
+ describe("#bookmarkUniqueNumericIdGen", () => {
+ it("should generate a unique incrementing ID", () => {
+ const uniqueNumericId = bookmarkUniqueNumericIdGen();
+ expect(uniqueNumericId()).to.equal(1);
+ expect(uniqueNumericId()).to.equal(2);
+ });
+ });
+
describe("#uniqueId", () => {
it("should generate a unique pseudorandom ID", () => {
expect(uniqueId()).to.not.be.empty;
});
});
+
+ describe("#uniqueUuid", () => {
+ it("should generate a unique pseudorandom ID", () => {
+ expect(uniqueUuid()).to.not.be.empty;
+ });
+ });
});
diff --git a/src/util/convenience-functions.ts b/src/util/convenience-functions.ts
index b6683b0d15..bf3c5774a3 100644
--- a/src/util/convenience-functions.ts
+++ b/src/util/convenience-functions.ts
@@ -1,4 +1,4 @@
-import { nanoid } from "nanoid/non-secure";
+import { nanoid, customAlphabet } from "nanoid/non-secure";
// Twip - twentieths of a point
export const convertMillimetersToTwip = (millimeters: number): number => Math.floor((millimeters / 25.4) * 72 * 20);
@@ -23,3 +23,7 @@ export const docPropertiesUniqueNumericIdGen = (): UniqueNumericIdCreator => uni
export const bookmarkUniqueNumericIdGen = (): UniqueNumericIdCreator => uniqueNumericIdCreator();
export const uniqueId = (): string => nanoid().toLowerCase();
+
+const generateUuidPart = (count: number): string => customAlphabet("1234567890abcdef", count)();
+export const uniqueUuid = (): string =>
+ `${generateUuidPart(8)}-${generateUuidPart(4)}-${generateUuidPart(4)}-${generateUuidPart(4)}-${generateUuidPart(12)}`;
diff --git a/vite.config.ts b/vite.config.ts
index f53e3f356b..d2fdcd127e 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.96,
- branches: 98.98,
+ statements: 99.98,
+ branches: 99.15,
functions: 100,
- lines: 99.96,
+ lines: 99.98,
},
exclude: [
...configDefaults.exclude,