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,