Compare commits

..

7 Commits
9.0.3 ... 9.1.0

Author SHA1 Message Date
17eb4fe8c4 Version bump 2024-11-28 10:38:29 +00:00
3997ce538d fix(replacer): errors suppressed by catch statement (#2856) 2024-11-28 10:35:11 +00:00
3654eb0800 New Textbox Component (#2718)
* Adding textbox with no props

* Fixing namings and adding shape style

* simplify usage

* Fix linting issues

* Re-name demo

* Use new shape authoring style

* Add tests

Simplify API

* Add better types for styles

* Add more options to TextBox and add documentation

---------

Co-authored-by: zohar11 <zohar@sumit-ai.com>
Co-authored-by: Dolan <dolan_miu@hotmail.com>
2024-11-28 10:33:19 +00:00
c6bb255641 Add hyphenation support (#2678)
* Add hyphenation support

* Remove unneeded linebreaks

* Add documentation and fix eslint

* Add tests

---------

Co-authored-by: Dolan Miu <dolan_miu@hotmail.com>
2024-11-21 10:41:31 +00:00
05a3cf5b43 fix: add rel to fontTable (#2800)
* fix: add rel to fontTable

* Fix prettier

---------

Co-authored-by: Dolan <dolan_miu@hotmail.com>
2024-11-20 11:19:43 +00:00
dece0f58e1 Fix duplicated br tags (#2717)
* Adjust test to demo duplicated br tags

* Fix patchDocument duplicating br tags

* Only include w:Pr

* Fix tag condition

---------

Co-authored-by: Dolan <dolan_miu@hotmail.com>
2024-11-20 11:19:25 +00:00
7f3c5615c9 Version bump 2024-11-07 23:46:27 +00:00
30 changed files with 744 additions and 77 deletions

43
demo/94-texbox.ts Normal file
View File

@ -0,0 +1,43 @@
// Simple example to add textbox to a document
import { Document, Packer, Paragraph, Textbox, TextRun } from "docx";
import * as fs from "fs";
const doc = new Document({
sections: [
{
properties: {},
children: [
new Textbox({
alignment: "center",
children: [
new Paragraph({
children: [new TextRun("Hi i'm a textbox!")],
}),
],
style: {
width: "200pt",
height: "auto",
},
}),
new Textbox({
alignment: "center",
children: [
new Paragraph({
children: [new TextRun("Hi i'm a textbox with a hidden box!")],
}),
],
style: {
width: "300pt",
height: 400,
visibility: "hidden",
zIndex: "auto",
},
}),
],
},
],
});
Packer.toBuffer(doc).then((buffer) => {
fs.writeFileSync("My Document.docx", buffer);
});

View File

@ -22,19 +22,30 @@ const doc = new docx.Document({
### Full list of options: ### Full list of options:
- creator | Property | Type | Notes |
- description | -------------------------- | -------------------------------------------------------- | -------- |
- title | sections | `ISectionOptions[]` | Optional |
- subject | title | `string` | Optional |
- keywords | subject | `string` | Optional |
- lastModifiedBy | creator | `string` | Optional |
- revision | keywords | `string` | Optional |
- externalStyles | description | `string` | Optional |
- styles | lastModifiedBy | `string` | Optional |
- numbering | revision | `number` | Optional |
- footnotes | externalStyles | `string` | Optional |
- hyperlinks | styles | `IStylesOptions` | Optional |
- background | numbering | `INumberingOptions` | Optional |
| comments | `ICommentsOptions` | Optional |
| footnotes | `Record<string, { children: Paragraph[] }>` | Optional |
| background | `IDocumentBackgroundOptions` | Optional |
| features | `{ trackRevisions?: boolean; updateFields?: boolean; }` | Optional |
| compatabilityModeVersion | `number` | Optional |
| compatibility | `ICompatibilityOptions` | Optional |
| customProperties | ` ICustomPropertyOptions`[] | Optional |
| evenAndOddHeaderAndFooters | `boolean` | Optional |
| defaultTabStop | `number` | Optional |
| fonts | ` FontOptions[]` | Optional |
| hyphenation | `IHyphenationOptions` | Optional |
### Change background color of Document ### Change background color of Document

26
docs/usage/text-box.md Normal file
View File

@ -0,0 +1,26 @@
# Text Box
Similar `Text Frames`, but the difference being that it is `VML` `Shape` based.
!> `Text Boxes` requires an understanding of [Paragraphs](usage/paragraph.md).
> `Text boxes` are paragraphs of text in a document which are positioned in a separate region or frame in the document, and can be positioned with a specific size and position relative to non-frame paragraphs in the current document.
## Intro
To make a `Text Box`, simply create a `Textbox` object inside the `Document`:
```ts
new Textbox({
alignment: "center",
children: [
new Paragraph({
children: [new TextRun("Hi i'm a textbox!")],
}),
],
style: {
width: "200pt",
height: "auto",
},
});
```

View File

@ -1,6 +1,6 @@
# Text Frames # Text Frames
Also known as `Text Boxes` > Similar to `Text Boxes`!
!> Text Frames requires an understanding of [Paragraphs](usage/paragraph.md). !> Text Frames requires an understanding of [Paragraphs](usage/paragraph.md).

4
package-lock.json generated
View File

@ -1,12 +1,12 @@
{ {
"name": "docx", "name": "docx",
"version": "9.0.2", "version": "9.1.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "docx", "name": "docx",
"version": "9.0.2", "version": "9.1.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/node": "^22.7.5", "@types/node": "^22.7.5",

View File

@ -1,6 +1,6 @@
{ {
"name": "docx", "name": "docx",
"version": "9.0.2", "version": "9.1.0",
"description": "Easily generate .docx files with JS/TS with a nice declarative API. Works for Node and on the Browser.", "description": "Easily generate .docx files with JS/TS with a nice declarative API. Works for Node and on the Browser.",
"type": "module", "type": "module",
"main": "build/index.umd.js", "main": "build/index.umd.js",

View File

@ -109,6 +109,12 @@ export class Compiler {
); );
}); });
file.Document.Relationships.createRelationship(
file.Document.Relationships.RelationshipCount + 1,
"http://schemas.openxmlformats.org/officeDocument/2006/relationships/fontTable",
"fontTable.xml",
);
return xml( return xml(
this.formatter.format(file.Document.Relationships, { this.formatter.format(file.Document.Relationships, {
viewWrapper: file.Document, viewWrapper: file.Document,

View File

@ -1,5 +1,6 @@
import { FontOptions } from "@file/fonts/font-table"; import { FontOptions } from "@file/fonts/font-table";
import { ICommentsOptions } from "@file/paragraph/run/comment-run"; import { ICommentsOptions } from "@file/paragraph/run/comment-run";
import { IHyphenationOptions } from "@file/settings";
import { ICompatibilityOptions } from "@file/settings/compatibility"; import { ICompatibilityOptions } from "@file/settings/compatibility";
import { StringContainer, XmlComponent } from "@file/xml-components"; import { StringContainer, XmlComponent } from "@file/xml-components";
import { dateTimeValue } from "@util/values"; import { dateTimeValue } from "@util/values";
@ -44,6 +45,7 @@ export type IPropertiesOptions = {
readonly evenAndOddHeaderAndFooters?: boolean; readonly evenAndOddHeaderAndFooters?: boolean;
readonly defaultTabStop?: number; readonly defaultTabStop?: number;
readonly fonts?: readonly FontOptions[]; readonly fonts?: readonly FontOptions[];
readonly hyphenation?: IHyphenationOptions;
}; };
// <xs:element name="coreProperties" type="CT_CoreProperties"/> // <xs:element name="coreProperties" type="CT_CoreProperties"/>

View File

@ -0,0 +1,34 @@
import { describe, expect, it } from "vitest";
import { Formatter } from "@export/formatter";
import { createLineNumberType } from "./line-number";
describe("createLineNumberType", () => {
it("should work", () => {
const textDirection = createLineNumberType({ countBy: 0, start: 0, restart: "newPage", distance: 10 });
const tree = new Formatter().format(textDirection);
expect(tree).to.deep.equal({
"w:lnNumType": { _attr: { "w:countBy": 0, "w:start": 0, "w:restart": "newPage", "w:distance": 10 } },
});
});
it("should work with string measures for distance", () => {
const textDirection = createLineNumberType({ countBy: 0, start: 0, restart: "newPage", distance: "10mm" });
const tree = new Formatter().format(textDirection);
expect(tree).to.deep.equal({
"w:lnNumType": { _attr: { "w:countBy": 0, "w:start": 0, "w:restart": "newPage", "w:distance": "10mm" } },
});
});
it("should work with blank entries", () => {
const textDirection = createLineNumberType({});
const tree = new Formatter().format(textDirection);
expect(tree).to.deep.equal({
"w:lnNumType": { _attr: {} },
});
});
});

View File

@ -479,4 +479,41 @@ describe("File", () => {
expect(doc.Styles).to.not.be.undefined; expect(doc.Styles).to.not.be.undefined;
}); });
}); });
describe("#features", () => {
it("should work with updateFields", () => {
const doc = new File({
sections: [],
features: {
updateFields: true,
},
});
expect(doc.Styles).to.not.be.undefined;
});
it("should work with trackRevisions", () => {
const doc = new File({
sections: [],
features: {
trackRevisions: true,
},
});
expect(doc.Styles).to.not.be.undefined;
});
});
describe("#hyphenation", () => {
it("should work with autoHyphenation", () => {
const doc = new File({
sections: [],
hyphenation: {
autoHyphenation: true,
},
});
expect(doc.Styles).to.not.be.undefined;
});
});
}); });

View File

@ -80,6 +80,12 @@ export class File {
trackRevisions: options.features?.trackRevisions, trackRevisions: options.features?.trackRevisions,
updateFields: options.features?.updateFields, updateFields: options.features?.updateFields,
defaultTabStop: options.defaultTabStop, defaultTabStop: options.defaultTabStop,
hyphenation: {
autoHyphenation: options.hyphenation?.autoHyphenation,
hyphenationZone: options.hyphenation?.hyphenationZone,
consecutiveHyphenLimit: options.hyphenation?.consecutiveHyphenLimit,
doNotHyphenateCaps: options.hyphenation?.doNotHyphenateCaps,
},
}); });
this.media = new Media(); this.media = new Media();

View File

@ -20,3 +20,4 @@ export * from "./border";
export * from "./vertical-align"; export * from "./vertical-align";
export * from "./checkbox"; export * from "./checkbox";
export * from "./fonts"; export * from "./fonts";
export * from "./textbox";

View File

@ -129,6 +129,74 @@ describe("Settings", () => {
}); });
}); });
it("should add autoHyphenation setting", () => {
const options = {
hyphenation: {
autoHyphenation: true,
},
};
const tree = new Formatter().format(new Settings(options));
expect(Object.keys(tree)).has.length(1);
expect(tree["w:settings"]).to.be.an("array");
expect(tree["w:settings"]).to.deep.include({
"w:autoHyphenation": {},
});
});
it("should add doNotHyphenateCaps setting", () => {
const options = {
hyphenation: {
doNotHyphenateCaps: true,
},
};
const tree = new Formatter().format(new Settings(options));
expect(Object.keys(tree)).has.length(1);
expect(tree["w:settings"]).to.be.an("array");
expect(tree["w:settings"]).to.deep.include({
"w:doNotHyphenateCaps": {},
});
});
it("should add hyphenationZone setting", () => {
const options = {
hyphenation: {
hyphenationZone: 200,
},
};
const tree = new Formatter().format(new Settings(options));
expect(Object.keys(tree)).has.length(1);
expect(tree["w:settings"]).to.be.an("array");
expect(tree["w:settings"]).to.deep.include({
"w:hyphenationZone": {
_attr: {
"w:val": 200,
},
},
});
});
it("should add consecutiveHyphenLimit setting", () => {
const options = {
hyphenation: {
consecutiveHyphenLimit: 3,
},
};
const tree = new Formatter().format(new Settings(options));
expect(Object.keys(tree)).has.length(1);
expect(tree["w:settings"]).to.be.an("array");
expect(tree["w:settings"]).to.deep.include({
"w:consecutiveHyphenLimit": {
_attr: {
"w:val": 3,
},
},
});
});
// TODO: Remove when deprecating compatibilityModeVersion // TODO: Remove when deprecating compatibilityModeVersion
it("should add compatibility setting with legacy version", () => { it("should add compatibility setting with legacy version", () => {
const settings = new Settings({ const settings = new Settings({

View File

@ -153,6 +153,18 @@ export type ISettingsOptions = {
readonly updateFields?: boolean; readonly updateFields?: boolean;
readonly compatibility?: ICompatibilityOptions; readonly compatibility?: ICompatibilityOptions;
readonly defaultTabStop?: number; readonly defaultTabStop?: number;
readonly hyphenation?: IHyphenationOptions;
};
export type IHyphenationOptions = {
/** Specifies whether the application automatically hyphenates words as they are typed in the document. */
readonly autoHyphenation?: boolean;
/** Specifies the minimum number of characters at the beginning of a word before a hyphen can be inserted. */
readonly hyphenationZone?: number;
/** Specifies the maximum number of consecutive lines that can end with a hyphenated word. */
readonly consecutiveHyphenLimit?: number;
/** Specifies whether to hyphenate words in all capital letters. */
readonly doNotHyphenateCaps?: boolean;
}; };
export class Settings extends XmlComponent { export class Settings extends XmlComponent {
@ -204,6 +216,26 @@ export class Settings extends XmlComponent {
this.root.push(new NumberValueElement("w:defaultTabStop", options.defaultTabStop)); this.root.push(new NumberValueElement("w:defaultTabStop", options.defaultTabStop));
} }
// https://c-rex.net/samples/ooxml/e1/Part4/OOXML_P4_DOCX_autoHyphenation_topic_ID0EFUMX.html
if (options.hyphenation?.autoHyphenation !== undefined) {
this.root.push(new OnOffElement("w:autoHyphenation", options.hyphenation.autoHyphenation));
}
// https://c-rex.net/samples/ooxml/e1/Part4/OOXML_P4_DOCX_hyphenationZone_topic_ID0ERI3X.html
if (options.hyphenation?.hyphenationZone !== undefined) {
this.root.push(new NumberValueElement("w:hyphenationZone", options.hyphenation.hyphenationZone));
}
// https://c-rex.net/samples/ooxml/e1/Part4/OOXML_P4_DOCX_consecutiveHyphenLim_topic_ID0EQ6RX.html
if (options.hyphenation?.consecutiveHyphenLimit !== undefined) {
this.root.push(new NumberValueElement("w:consecutiveHyphenLimit", options.hyphenation.consecutiveHyphenLimit));
}
// https://c-rex.net/samples/ooxml/e1/Part4/OOXML_P4_DOCX_doNotHyphenateCaps_topic_ID0EW4XX.html
if (options.hyphenation?.doNotHyphenateCaps !== undefined) {
this.root.push(new OnOffElement("w:doNotHyphenateCaps", options.hyphenation.doNotHyphenateCaps));
}
this.root.push( this.root.push(
new Compatibility({ new Compatibility({
...(options.compatibility ?? {}), ...(options.compatibility ?? {}),

View File

@ -0,0 +1 @@
export * from "./textbox";

View File

@ -0,0 +1,11 @@
import { BuilderElement, XmlComponent } from "@file/xml-components";
export type IPictElement = {
readonly shape: XmlComponent;
};
export const createPictElement = ({ shape }: IPictElement): XmlComponent =>
new BuilderElement<{ readonly style?: string }>({
name: "w:pict",
children: [shape],
});

View File

@ -0,0 +1,58 @@
import { describe, expect, it } from "vitest";
import { Formatter } from "@export/formatter";
import { Paragraph } from "@file/paragraph";
import { createShape } from "./shape";
describe("createShape", () => {
it("should work", () => {
const tree = new Formatter().format(
createShape({
id: "test-id",
style: {
width: "10pt",
},
children: [new Paragraph("test-content")],
}),
);
expect(tree).toStrictEqual({
"v:shape": [
{ _attr: { id: "test-id", type: "#_x0000_t202", style: "width:10pt" } },
{
"v:textbox": [
{ _attr: { insetmode: "auto", style: "mso-fit-shape-to-text:t;" } },
{
"w:txbxContent": [
{ "w:p": [{ "w:r": [{ "w:t": [{ _attr: { "xml:space": "preserve" } }, "test-content"] }] }] },
],
},
],
},
],
});
});
it("should create default styles", () => {
const tree = new Formatter().format(
createShape({
id: "test-id",
}),
);
expect(tree).toStrictEqual({
"v:shape": [
{ _attr: { id: "test-id", type: "#_x0000_t202" } },
{
"v:textbox": [
{ _attr: { insetmode: "auto", style: "mso-fit-shape-to-text:t;" } },
{
"w:txbxContent": {},
},
],
},
],
});
});
});

View File

@ -0,0 +1,125 @@
// https://c-rex.net/samples/ooxml/e1/Part3/OOXML_P3_Primer_OfficeArt_topic_ID0ELU5O.html
// http://webapp.docx4java.org/OnlineDemo/ecma376/VML/shape.html
import { ParagraphChild } from "@file/paragraph";
import { BuilderElement, XmlComponent } from "@file/xml-components";
import { LengthUnit } from "../types";
import { createVmlTextbox } from "../vml-textbox/vml-texbox";
const SHAPE_TYPE = "#_x0000_t202";
const styleToKeyMap: Record<keyof VmlShapeStyle, string> = {
flip: "flip",
height: "height",
left: "left",
marginBottom: "margin-bottom",
marginLeft: "margin-left",
marginRight: "margin-right",
marginTop: "margin-top",
positionHorizontal: "mso-position-horizontal",
positionHorizontalRelative: "mso-position-horizontal-relative",
positionVertical: "mso-position-vertical",
positionVerticalRelative: "mso-position-vertical-relative",
wrapDistanceBottom: "mso-wrap-distance-bottom",
wrapDistanceLeft: "mso-wrap-distance-left",
wrapDistanceRight: "mso-wrap-distance-right",
wrapDistanceTop: "mso-wrap-distance-top",
wrapEdited: "mso-wrap-edited",
wrapStyle: "mso-wrap-style",
position: "position",
rotation: "rotation",
top: "top",
visibility: "visibility",
width: "width",
zIndex: "z-index",
};
export type VmlShapeStyle = {
/** Specifies that the orientation of a shape is flipped. Default is no value. */
readonly flip?: "x" | "y" | "xy" | "yx";
/** Specifies the height of the containing block of the shape. Default is 0. It is specified in CSS units or, for elements in a group, in the coordinate system of the parent element. */
readonly height?: LengthUnit;
/** Specifies the position of the left of the containing block of the shape relative to the element left of it in the flow of the page. Default is 0. It is specified in CSS units or, for elements in a group, in the coordinate system of the parent element. This property shall not be used for shapes anchored inline. */
readonly left?: LengthUnit;
/** Specifies the position of the bottom of the containing block of the shape relative to the shape anchor. Default is 0. It is specified in CSS units or, for elements in a group, in the coordinate system of the parent element. */
readonly marginBottom?: LengthUnit;
/** Specifies the position of the left of the containing block of the shape relative to the shape anchor. Default is 0. It is specified in CSS units or, for elements in a group, in the coordinate system of the parent element. */
readonly marginLeft?: LengthUnit;
/** Specifies the position of the right of the containing block of the shape relative to the shape anchor. Default is 0. It is specified in CSS units or, for elements in a group, in the coordinate system of the parent element. */
readonly marginRight?: LengthUnit;
/** Specifies the position of the top of the containing block of the shape relative to the shape anchor. Default is 0. It is specified in CSS units or, for elements in a group, in the coordinate system of the parent element. */
readonly marginTop?: LengthUnit;
/** Specifies the horizontal positioning data for objects in WordprocessingML documents. Default is absolute. */
readonly positionHorizontal?: "absolute" | "left" | "center" | "right" | "inside" | "outside";
/** Specifies relative horizontal position data for objects in WordprocessingML documents. This modifies the mso-position-horizontal property. Default is text. */
readonly positionHorizontalRelative?: "margin" | "page" | "text" | "char";
/** Specifies the vertical positioning data for objects in WordprocessingML documents. Default is absolute. */
readonly positionVertical?: "absolute" | "left" | "center" | "right" | "inside" | "outside";
/** Specifies relative vertical position data for objects in WordprocessingML documents. This modifies the mso-position-vertical property. Default is text. */
readonly positionVerticalRelative?: "margin" | "page" | "text" | "char";
/** Specifies the distance from the bottom of the shape to the text that wraps around it. Default is 0 pt. Note that this property is different from the CSS margin property, which changes the origin of the shape to include the margin areas. This property does not change the origin. */
readonly wrapDistanceBottom?: number;
/** Specifies the distance from the left side of the shape to the text that wraps around it. Default is 0 pt. Note that this property is different from the CSS margin property, which changes the origin of the shape to include the margin areas. This property does not change the origin. */
readonly wrapDistanceLeft?: number;
/** Specifies the distance from the right side of the shape to the text that wraps around it. Default is 0 pt. Note that this property is different from the CSS margin property, which changes the origin of the shape to include the margin areas. This property does not change the origin. */
readonly wrapDistanceRight?: number;
/** Specifies the distance from the top of the shape to the text that wraps around it. Default is 0 pt. Note that this property is different from the CSS margin property, which changes the origin of the shape to include the margin areas. This property does not change the origin. */
readonly wrapDistanceTop?: number;
/** Specifies whether the wrap coordinates were customized by the user. If the wrap coordinates are generated by an editor, this property is true; otherwise they were customized by a user. Default is false. */
readonly wrapEdited?: boolean;
/** Specifies the wrapping mode for text in shapes in WordprocessingML documents. Default is square. */
readonly wrapStyle?: "square" | "none";
/** Specifies the type of positioning used to place an element. Default is static. When the element is contained inside a group, this property must be absolute. */
readonly position?: "static" | "absolute" | "relative";
/** Specifies the angle that a shape is rotated, in degrees. Default is 0. Positive angles are clockwise. */
readonly rotation?: number;
/** Specifies the position of the top of the containing block of the shape relative to the element above it in the flow of the page. Default is 0. It is specified in CSS units or, for elements in a group, in the coordinate system of the parent element. This property shall not be used for shapes anchored inline. */
readonly top?: LengthUnit;
/** Specifies whether a shape is displayed. Only inherit and hidden are used; any other values are mapped to inherit. Default is inherit. */
readonly visibility?: "hidden" | "inherit";
/** Specifies the width of the containing block of the shape. Default is 0. It is specified in CSS units or, for elements in a group, in the coordinate system of the parent element. */
readonly width: LengthUnit;
/** Specifies the display order of overlapping shapes. Default is 0. This property shall not be used for shapes anchored inline. */
readonly zIndex?: "auto" | number;
};
const formatShapeStyle = (style?: VmlShapeStyle): string | undefined =>
style
? Object.entries(style)
.map(([key, value]) => `${styleToKeyMap[key as keyof VmlShapeStyle]}:${value}`)
.join(";")
: undefined;
export const createShape = ({
id,
children,
type = SHAPE_TYPE,
style,
}: {
readonly id: string;
readonly children?: readonly ParagraphChild[];
readonly type?: string;
readonly style?: VmlShapeStyle;
}): XmlComponent =>
new BuilderElement<{
readonly id: string;
readonly type?: string;
readonly style?: string;
}>({
name: "v:shape",
attributes: {
id: {
key: "id",
value: id,
},
type: {
key: "type",
value: type,
},
style: {
key: "style",
value: formatShapeStyle(style),
},
},
children: [createVmlTextbox({ style: "mso-fit-shape-to-text:t;", children })],
});

View File

@ -0,0 +1,8 @@
import { ParagraphChild } from "@file/paragraph";
import { BuilderElement, XmlComponent } from "@file/xml-components";
export const createTextboxContent = ({ children = [] }: { readonly children?: readonly ParagraphChild[] }): XmlComponent =>
new BuilderElement<{ readonly style?: string }>({
name: "w:txbxContent",
children: children as readonly XmlComponent[],
});

View File

@ -0,0 +1,47 @@
import { describe, expect, it } from "vitest";
import { Formatter } from "@export/formatter";
import { Paragraph } from "@file/paragraph";
import { Textbox } from "./textbox";
describe("VmlTextbox", () => {
it("should work", () => {
const tree = new Formatter().format(
new Textbox({
style: {
width: "10pt",
},
children: [new Paragraph("test-content")],
}),
);
expect(tree).toStrictEqual({
"w:p": [
{
"w:pict": [
{
"v:shape": [
{ _attr: { id: expect.any(String), type: "#_x0000_t202", style: "width:10pt" } },
{
"v:textbox": [
{ _attr: { insetmode: "auto", style: "mso-fit-shape-to-text:t;" } },
{
"w:txbxContent": [
{
"w:p": [
{ "w:r": [{ "w:t": [{ _attr: { "xml:space": "preserve" } }, "test-content"] }] },
],
},
],
},
],
},
],
},
],
},
],
});
});
});

View File

@ -0,0 +1,27 @@
import { FileChild } from "@file/file-child";
import { IParagraphOptions, ParagraphProperties } from "@file/paragraph";
import { uniqueId } from "@util/convenience-functions";
import { createPictElement } from "./pict-element/pict-element";
import { VmlShapeStyle, createShape } from "./shape/shape";
type ITextboxOptions = Omit<IParagraphOptions, "style"> & {
readonly style?: VmlShapeStyle;
};
export class Textbox extends FileChild {
public constructor({ style, children, ...rest }: ITextboxOptions) {
super("w:p");
this.root.push(new ParagraphProperties(rest));
this.root.push(
createPictElement({
shape: createShape({
children: children,
id: uniqueId(),
style: style,
}),
}),
);
}
}

View File

@ -0,0 +1,3 @@
import { Percentage, RelativeMeasure, UniversalMeasure } from "@util/values";
export type LengthUnit = "auto" | number | Percentage | UniversalMeasure | RelativeMeasure;

View File

@ -0,0 +1,42 @@
// http://webapp.docx4java.org/OnlineDemo/ecma376/VML/textbox.html
import { ParagraphChild } from "@file/paragraph";
import { BuilderElement, XmlComponent } from "@file/xml-components";
import { InsetMode } from "@util/types";
import { createTextboxContent } from "../texbox-content/textbox-content";
import { LengthUnit } from "../types";
// type VMLTextboxStyle = {
// readonly fontWeight?: "normal" | "lighter" | 100 | 200 | 300 | 400 | "bold" | "bolder" | 500 | 600 | 700 | 800 | 900;
// }
export type IVTextboxOptions = {
readonly style?: string;
readonly children?: readonly ParagraphChild[];
readonly inset?: {
readonly top: LengthUnit;
readonly left: LengthUnit;
readonly bottom: LengthUnit;
readonly right: LengthUnit;
};
};
export const createVmlTextbox = ({ style, children, inset }: IVTextboxOptions): XmlComponent =>
new BuilderElement<{ readonly style?: string; readonly inset?: string; readonly insetMode?: InsetMode }>({
name: "v:textbox",
attributes: {
style: {
key: "style",
value: style,
},
insetMode: {
key: "insetmode",
value: inset ? "custom" : "auto",
},
inset: {
key: "inset",
value: inset ? `${inset.left}, ${inset.top}, ${inset.right}, ${inset.bottom}` : undefined,
},
},
children: [createTextboxContent({ children })],
});

View File

@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import { Formatter } from "@export/formatter";
import { Paragraph } from "@file/paragraph";
import { createVmlTextbox } from "./vml-texbox";
describe("VmlTextbox", () => {
it("should work", () => {
const tree = new Formatter().format(
createVmlTextbox({
style: "test-style",
children: [new Paragraph("test-content")],
}),
);
expect(tree).toStrictEqual({
"v:textbox": [
{ _attr: { insetmode: "auto", style: "test-style" } },
{ "w:txbxContent": [{ "w:p": [{ "w:r": [{ "w:t": [{ _attr: { "xml:space": "preserve" } }, "test-content"] }] }] }] },
],
});
});
it("should work with inset", () => {
const tree = new Formatter().format(
createVmlTextbox({
style: "test-style",
children: [new Paragraph("test-content")],
inset: {
top: 0,
left: 0,
bottom: 0,
right: 0,
},
}),
);
expect(tree).toStrictEqual({
"v:textbox": [
{ _attr: { insetmode: "custom", style: "test-style", inset: "0, 0, 0, 0" } },
{ "w:txbxContent": [{ "w:p": [{ "w:r": [{ "w:t": [{ _attr: { "xml:space": "preserve" } }, "test-content"] }] }] }] },
],
});
});
});

View File

@ -132,38 +132,37 @@ export const patchDocument = async <T extends PatchDocumentOutputType = PatchDoc
// We need to loop through to catch every occurrence of the patch text // We need to loop through to catch every occurrence of the patch text
// It is possible that the patch text is in the same run // It is possible that the patch text is in the same run
// This algorithm is limited to one patch per text run // This algorithm is limited to one patch per text run
// Once it cannot find any more occurrences, it will throw an error, and then we break out of the loop // We break out of the loop once it cannot find any more occurrences
// https://github.com/dolanmiu/docx/issues/2267 // https://github.com/dolanmiu/docx/issues/2267
while (true) { while (true) {
try { const { didFindOccurrence } = replacer({
replacer({ json,
json, patch: {
patch: { ...patchValue,
...patchValue, children: patchValue.children.map((element) => {
children: patchValue.children.map((element) => { // We need to replace external hyperlinks with concrete hyperlinks
// We need to replace external hyperlinks with concrete hyperlinks if (element instanceof ExternalHyperlink) {
if (element instanceof ExternalHyperlink) { const concreteHyperlink = new ConcreteHyperlink(element.options.children, uniqueId());
const concreteHyperlink = new ConcreteHyperlink(element.options.children, uniqueId()); // eslint-disable-next-line functional/immutable-data
// eslint-disable-next-line functional/immutable-data hyperlinkRelationshipAdditions.push({
hyperlinkRelationshipAdditions.push({ key,
key, hyperlink: {
hyperlink: { id: concreteHyperlink.linkId,
id: concreteHyperlink.linkId, link: element.options.link,
link: element.options.link, },
}, });
}); return concreteHyperlink;
return concreteHyperlink; } else {
} else { return element;
return element; }
} }),
}), // eslint-disable-next-line @typescript-eslint/no-explicit-any
// eslint-disable-next-line @typescript-eslint/no-explicit-any } as any,
} as any, patchText,
patchText, context,
context, keepOriginalStyles,
keepOriginalStyles, });
}); if (!didFindOccurrence) {
} catch {
break; break;
} }
} }

View File

@ -62,6 +62,10 @@ export const MOCK_JSON = {
name: "w:t", name: "w:t",
elements: [{ type: "text", text: "What a {{bold}} text!" }], elements: [{ type: "text", text: "What a {{bold}} text!" }],
}, },
{
type: "element",
name: "w:br",
},
], ],
}, },
], ],
@ -74,24 +78,23 @@ export const MOCK_JSON = {
describe("replacer", () => { describe("replacer", () => {
describe("replacer", () => { describe("replacer", () => {
it("should throw an error if nothing is added", () => { it("should throw an error if nothing is added", () => {
expect(() => const { didFindOccurrence } = replacer({
replacer({ json: {
json: { elements: [],
elements: [], },
}, patch: {
patch: { type: PatchType.PARAGRAPH,
type: PatchType.PARAGRAPH, children: [],
children: [], },
}, patchText: "hello",
patchText: "hello", // eslint-disable-next-line functional/prefer-readonly-type
// eslint-disable-next-line functional/prefer-readonly-type context: vi.fn<[], IContext>()(),
context: vi.fn<[], IContext>()(), });
}), expect(didFindOccurrence).toBe(false);
).toThrow();
}); });
it("should replace paragraph type", () => { it("should replace paragraph type", () => {
const output = replacer({ const { element, didFindOccurrence } = replacer({
json: JSON.parse(JSON.stringify(MOCK_JSON)), json: JSON.parse(JSON.stringify(MOCK_JSON)),
patch: { patch: {
type: PatchType.PARAGRAPH, type: PatchType.PARAGRAPH,
@ -107,11 +110,12 @@ describe("replacer", () => {
}, },
}); });
expect(JSON.stringify(output)).to.contain("Delightful Header"); expect(JSON.stringify(element)).to.contain("Delightful Header");
expect(didFindOccurrence).toBe(true);
}); });
it("should replace paragraph type keeping original styling if keepOriginalStyles is true", () => { it("should replace paragraph type keeping original styling if keepOriginalStyles is true", () => {
const output = replacer({ const { element, didFindOccurrence } = replacer({
json: JSON.parse(JSON.stringify(MOCK_JSON)), json: JSON.parse(JSON.stringify(MOCK_JSON)),
patch: { patch: {
type: PatchType.PARAGRAPH, type: PatchType.PARAGRAPH,
@ -128,8 +132,8 @@ describe("replacer", () => {
keepOriginalStyles: true, keepOriginalStyles: true,
}); });
expect(JSON.stringify(output)).to.contain("sweet"); expect(JSON.stringify(element)).to.contain("sweet");
expect(output.elements![0].elements![1].elements).toMatchObject([ expect(element.elements![0].elements![1].elements).toMatchObject([
{ {
type: "element", type: "element",
name: "w:r", name: "w:r",
@ -176,13 +180,18 @@ describe("replacer", () => {
name: "w:t", name: "w:t",
elements: [{ type: "text", text: " text!" }], elements: [{ type: "text", text: " text!" }],
}, },
{
name: "w:br",
type: "element",
},
], ],
}, },
]); ]);
expect(didFindOccurrence).toBe(true);
}); });
it("should replace document type", () => { it("should replace document type", () => {
const output = replacer({ const { element, didFindOccurrence } = replacer({
json: JSON.parse(JSON.stringify(MOCK_JSON)), json: JSON.parse(JSON.stringify(MOCK_JSON)),
patch: { patch: {
type: PatchType.DOCUMENT, type: PatchType.DOCUMENT,
@ -198,12 +207,13 @@ describe("replacer", () => {
}, },
}); });
expect(JSON.stringify(output)).to.contain("Lorem ipsum paragraph"); expect(JSON.stringify(element)).to.contain("Lorem ipsum paragraph");
expect(didFindOccurrence).toBe(true);
}); });
it("should replace", () => { it("should replace", () => {
// cspell:disable // cspell:disable
const output = replacer({ const { element, didFindOccurrence } = replacer({
json: { json: {
elements: [ elements: [
{ {
@ -647,7 +657,8 @@ describe("replacer", () => {
}, },
}); });
expect(JSON.stringify(output)).to.contain("Lorem ipsum paragraph"); expect(JSON.stringify(element)).to.contain("Lorem ipsum paragraph");
expect(didFindOccurrence).toBe(true);
}); });
}); });
}); });

View File

@ -14,6 +14,11 @@ const formatter = new Formatter();
const SPLIT_TOKEN = "ɵ"; const SPLIT_TOKEN = "ɵ";
type IReplacerResult = {
readonly element: Element;
readonly didFindOccurrence: boolean;
};
export const replacer = ({ export const replacer = ({
json, json,
patch, patch,
@ -26,11 +31,11 @@ export const replacer = ({
readonly patchText: string; readonly patchText: string;
readonly context: IContext; readonly context: IContext;
readonly keepOriginalStyles?: boolean; readonly keepOriginalStyles?: boolean;
}): Element => { }): IReplacerResult => {
const renderedParagraphs = findLocationOfText(json, patchText); const renderedParagraphs = findLocationOfText(json, patchText);
if (renderedParagraphs.length === 0) { if (renderedParagraphs.length === 0) {
throw new Error(`Could not find text ${patchText}`); return { element: json, didFindOccurrence: false };
} }
for (const renderedParagraph of renderedParagraphs) { for (const renderedParagraph of renderedParagraphs) {
@ -64,7 +69,7 @@ export const replacer = ({
if (keepOriginalStyles) { if (keepOriginalStyles) {
const runElementNonTextualElements = runElementToBeReplaced.elements!.filter( const runElementNonTextualElements = runElementToBeReplaced.elements!.filter(
(e) => e.type === "element" && e.name !== "w:t" && e.name !== "w:br" && e.name !== "w:tab", (e) => e.type === "element" && e.name === "w:rPr",
); );
newRunElements = textJson.map((e) => ({ newRunElements = textJson.map((e) => ({
@ -85,7 +90,7 @@ export const replacer = ({
} }
} }
return json; return { element: json, didFindOccurrence: true };
}; };
const goToElementFromPath = (json: Element, path: readonly number[]): Element => { const goToElementFromPath = (json: Element, path: readonly number[]): Element => {

7
src/util/types.ts Normal file
View File

@ -0,0 +1,7 @@
// <xsd:simpleType name="ST_InsetMode">
// <xsd:restriction base="xsd:string">
// <xsd:enumeration value="auto"/>
// <xsd:enumeration value="custom"/>
// </xsd:restriction>
// </xsd:simpleType>
export type InsetMode = "auto" | "custom"; // VML only type

View File

@ -21,6 +21,16 @@ export type PositiveUniversalMeasure = `${number}${"mm" | "cm" | "in" | "pt" | "
// </xsd:simpleType> // </xsd:simpleType>
export type Percentage = `${"-" | ""}${number}%`; export type Percentage = `${"-" | ""}${number}%`;
// <xsd:simpleType name="ST_PositivePercentage">
// <xsd:restriction base="ST_Percentage">
// <xsd:pattern value="[0-9]+(\.[0-9]+)?%"/>
// </xsd:restriction>
// </xsd:simpleType>
export type PositivePercentage = `${number}%`;
// Only applies to VmlTextbox so far
export type RelativeMeasure = `${"-" | ""}${number}${"em" | "ex"}`;
// <xsd:simpleType name="ST_DecimalNumber"> // <xsd:simpleType name="ST_DecimalNumber">
// <xsd:restriction base="xsd:integer"/> // <xsd:restriction base="xsd:integer"/>
// </xsd:simpleType> // </xsd:simpleType>

View File

@ -1,8 +1,8 @@
import { configDefaults, defineConfig } from "vitest/config";
import { resolve } from "path"; import { resolve } from "path";
import tsconfigPaths from "vite-tsconfig-paths";
import dts from "vite-plugin-dts"; import dts from "vite-plugin-dts";
import { nodePolyfills } from "vite-plugin-node-polyfills"; import { nodePolyfills } from "vite-plugin-node-polyfills";
import tsconfigPaths from "vite-tsconfig-paths";
import { configDefaults, defineConfig } from "vitest/config";
export default defineConfig({ export default defineConfig({
plugins: [ plugins: [
@ -65,7 +65,7 @@ export default defineConfig({
reporter: ["text", "json", "html"], reporter: ["text", "json", "html"],
thresholds: { thresholds: {
statements: 100, statements: 100,
branches: 99.35, branches: 99.68,
functions: 100, functions: 100,
lines: 100, lines: 100,
}, },
@ -76,6 +76,7 @@ export default defineConfig({
'**/docs/**', '**/docs/**',
'**/scripts/**', '**/scripts/**',
'**/src/**/index.ts', '**/src/**/index.ts',
'**/src/**/types.ts',
], ],
}, },
include: [ include: [