JavaScript (260)
Text boxes in a worksheet carry notices, reminders or a single conclusion, yet their default look is plain: small black text on white, framed by a thin border that blends into the grid. Enlarging and bolding the font and laying down a light fill make the text rise above the table; inner margins and text wrapping keep long sentences off the border and inside the box; removing the border lets a text box read as a quiet annotation. These changes can be applied in code in one pass instead of one box at a time. Spire.XLS for JavaScript runs in the browser on WebAssembly and handles the input and output files through a virtual file system (VFS), with no backend service required.
This article covers two core feature points:
- Adjust the Font, Background and Border of Text Boxes
- Set the Inner Margins and Text Wrapping of Text Boxes
For installation and project setup, see Integrating Spire.XLS for JavaScript in a React Project. The examples below assume Spire.XLS is installed and the WebAssembly module has been initialised.
Adjust the Font, Background and Border of Text Boxes
The default 11-point black text on a white background lacks contrast against the sheet. A larger size, a bold weight, another color and a pale fill lift a notice above the grid; clearing a border that competes with the cell lines makes the text read as if written straight on the page, which suits footnotes and corner notes. The same two kinds of change can be applied to one text box or split across different boxes, and this feature point adjusts the font, background and border of text boxes.
| Syntax | Result |
|---|---|
workbook.CreateFont() |
Creates a font object for the text of a text box |
font.FontName, font.Size, font.IsBold, font.Color |
Set the font name, size, bold weight and text color |
xlsModule.RichTextShape.Convert(textBox.RichText) |
Returns the rich text object of the text box |
richText.SetFont(0, textBox.Text.length - 1, font) |
Applies the font to the whole text of the text box |
textBox.Fill.FillType = xlsModule.ShapeFillType.SolidColor |
Switches the text box background to a solid fill |
textBox.Fill.ForeColor |
Sets the color of the solid fill |
textBox.Line.Weight = 0 |
Sets the border weight to zero, so no outline is drawn |
The steps are:
- Take the first text box with
sheet.TextBoxes.get(0). - Create a font object with
workbook.CreateFont()and set its name, size, bold weight and color throughFontName,Size,IsBoldandColor. - Get the rich text object with
xlsModule.RichTextShape.Convert(textBox.RichText), then callSetFont(0, textBox.Text.length - 1, font)to apply the font to the whole text. - Set
textBox.Fill.FillTypetoxlsModule.ShapeFillType.SolidColor, then name the fill color withtextBox.Fill.ForeColor. - Take the fourth text box with
sheet.TextBoxes.get(3)and settextBox.Line.Weightto0to remove its border. - Save the workbook with
workbook.SaveToFile.
The code below is the complete example of adjusting the font, background and border of a text box:
function App() {
const adjustTextAppearance = async () => {
// get the Spire.XLS WASM module
const xlsModule = window.wasmModule?.spirexls;
// check that the module is ready
if (!xlsModule) {
alert('Spire.Xls is not ready yet');
return;
}
// load the font and the test data file into the VFS
await window.spire.FetchFileToVFS('ARIAL.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
const inputFileName = 'TextBoxSample.xlsx';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);
// load the workbook and take the first worksheet
const workbook = new xlsModule.Workbook();
workbook.LoadFromFile({ fileName: inputFileName });
const sheet = workbook.Worksheets.get(0);
// take the first text box and create a font with its name, size, bold weight and color
const textBox = sheet.TextBoxes.get(0);
const font = workbook.CreateFont();
font.FontName = 'Arial';
font.Size = 14;
font.IsBold = true;
font.Color = xlsModule.Color.FromArgb(255, 28, 68, 118);
// apply the font to all the text in the text box
const richText = xlsModule.RichTextShape.Convert(textBox.RichText);
richText.SetFont(0, textBox.Text.length - 1, font);
// fill the text box background with a solid color
textBox.Fill.FillType = xlsModule.ShapeFillType.SolidColor;
textBox.Fill.ForeColor = xlsModule.Color.FromArgb(255, 232, 240, 250);
// take the fourth text box and set the border weight to 0, which removes the border
const borderBox = sheet.TextBoxes.get(3);
borderBox.Line.Weight = 0;
// save the workbook
const outputFileName = 'TextAppearance.xlsx';
workbook.SaveToFile({ fileName: outputFileName });
// dispose of the workbook to release its resources
workbook.Dispose();
// read the result back out of the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Format Excel Text Boxes</h1>
<button onClick={adjustTextAppearance}>Adjust the Font, Background and Border of a Text Box</button>
</div>
);
}
export default App;
The notice board after adjusting the font, background and border:

Set the Inner Margins and Text Wrapping of Text Boxes
Text pressed against the border is hard to read, especially on a dark fill. Inner margins hold the text away from each edge and can be set separately for the four sides. A long sentence in a narrow box stays on one line by default and runs past the border, while text wrapping folds it onto the next line. The sample leaves about 1 cm on the left and right and 0.4 cm at the top and bottom, and aligns the text to the top left so the shift caused by the margins is visible. This feature point sets the inner margins and text wrapping of text boxes.
| Syntax | Result |
|---|---|
textBox.InnerLeftMargin, InnerRightMargin, InnerTopMargin, InnerBottomMargin |
Set the distance between the text and the left, right, top and bottom border |
xlsModule.CommentHAlignType.Left |
Aligns the text to the left inside the box |
xlsModule.CommentVAlignType.Top |
Aligns the text to the top inside the box |
textBox.IsWrapText = true |
Folds the text onto the next line when it reaches the box width |
The steps are:
- Take the second text box with
sheet.TextBoxes.get(1). - Set
InnerLeftMargin,InnerRightMargin,InnerTopMarginandInnerBottomMarginin turn to leave room on all four sides. - Set
HAlignmenttoxlsModule.CommentHAlignType.LeftandVAlignmenttoxlsModule.CommentVAlignType.Top, so the text starts from the top-left corner. - Take the third text box with
sheet.TextBoxes.get(2)and setIsWrapTexttotrueto turn on text wrapping. - Save the workbook with
workbook.SaveToFile.
The code below is the complete example of setting the inner margins and text wrapping of a text box:
function App() {
const setTextLayout = async () => {
// get the Spire.XLS WASM module
const xlsModule = window.wasmModule?.spirexls;
// check that the module is ready
if (!xlsModule) {
alert('Spire.Xls is not ready yet');
return;
}
// load the font and the test data file into the VFS
await window.spire.FetchFileToVFS('ARIAL.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
const inputFileName = 'TextBoxSample.xlsx';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);
// load the workbook and take the first worksheet
const workbook = new xlsModule.Workbook();
workbook.LoadFromFile({ fileName: inputFileName });
const sheet = workbook.Worksheets.get(0);
// take the second text box and set the space between the text and its four borders
const textBox = sheet.TextBoxes.get(1);
textBox.InnerLeftMargin = 1;
textBox.InnerRightMargin = 1;
textBox.InnerTopMargin = 0.4;
textBox.InnerBottomMargin = 0.4;
// align the text to the top left so the effect of the margins is visible
textBox.HAlignment = xlsModule.CommentHAlignType.Left;
textBox.VAlignment = xlsModule.CommentVAlignType.Top;
// take the third text box and turn on wrapping, so text folds at the box width
const wrapBox = sheet.TextBoxes.get(2);
wrapBox.IsWrapText = true;
// save the workbook
const outputFileName = 'TextLayout.xlsx';
workbook.SaveToFile({ fileName: outputFileName });
// dispose of the workbook to release its resources
workbook.Dispose();
// read the result back out of the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Format Excel Text Boxes</h1>
<button onClick={setTextLayout}>Set the Inner Margins and Text Wrapping of a Text Box</button>
</div>
);
}
export default App;
The notice board after setting the inner margins and text wrapping:

FAQ
How do I get a text box by name?
Cause: Getting text boxes by index means remembering their order in the collection, and inserting or removing a text box changes that order.
Solution: Use sheet.TextBoxes.get("TextBox 2") to get a text box by name.
How do I format only part of the text in a text box?
Cause: The text of a text box is stored as rich text, and SetFont applies to a character range; anything outside the range keeps its original format.
Solution: Get the rich text object with xlsModule.RichTextShape.Convert(textBox.RichText), then call richText.SetFont(startIndex, endIndex, font) with the range you want. For example, SetFont(0, 3, font) applies to the first four characters only.
Can the border of a text box have another color or a dashed style?
Cause: Besides its weight, the Line object also provides color and dash style settings.
Solution: Set the border color with textBox.Line.ForeColor, the weight with textBox.Line.Weight, and the dash style with textBox.Line.DashStyle, whose value is a member of xlsModule.ShapeDashLineStyleType, such as Dashed for a dashed line.
What if the text does not wrap as expected?
Cause: Text only wraps when IsWrapText is true, and when the text box is wide enough for the whole sentence no line break appears either.
Solution: Confirm that textBox.IsWrapText = true has been set, and check that the width of the text box is smaller than the length of the sentence. When several lines are needed, leave enough height in the text box as well.
Get a Free License
Spire.XLS for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.
A cell whose formatting is already right often has to be reused elsewhere: the unit price column carries a money format, a light blue fill and thin borders, while the amount column on the same row is still plain. Copying a cell format hands the look the source cell has already settled on to the target cell -- number format, font, fill, border and alignment in one go -- and leaves the data in the target cell untouched. Spire.XLS for JavaScript performs these operations in the browser through WebAssembly; the input and output files are managed by a virtual file system (VFS), with no backend service required.
This article covers two feature points:
For installation and project setup, see Integrating Spire.XLS for JavaScript in a React Project. The examples below assume Spire.XLS is installed and the WebAssembly module has been initialized.
Copy the whole cell format
Every part of a cell's look is kept in a style object of its own. Handing the source cell's style object straight to the target cell carries the number format, font, fill, border and alignment across in one go, while the data in the target cell stays where it is. The sample workbook's unit price column already carries a money format and a light blue fill, and the amount column is still plain, so copying the former onto the latter row by row shows the difference.
Assigning the source cell's whole Style to the target cell's Style completes the copy:
| Syntax | Result |
|---|---|
sheet.Range.get('C2').Style |
Takes the whole style object of the source cell (the unit price column) |
sheet.Range.get('E2').Style = sheet.Range.get('C2').Style |
Hands that whole style to the target cell (the amount column): number format, font, fill, border and alignment follow |
sheet.Range.get({ row, column: 5 }).Text |
The value already in the target cell stays where it is, unaffected by the style assignment |
The steps are:
- Load the font file and the test data file into the VFS with
window.spire.FetchFileToVFS. - Create a workbook with
new Workbook(), load the input file withworkbook.LoadFromFile, and take the first worksheet withworkbook.Worksheets.get(0). - Take the source column's cell and the target column's cell row by row with
sheet.Range.get. - Assign the source cell's
Styleto the target cell'sStyle; the whole cell format follows, and the target cell's value is left untouched. - Save the workbook with
workbook.SaveToFileand dispose of it withworkbook.Disposeto free resources.
The full code example below shows how to copy the whole cell format in React:
function App() {
const copyCellStyle = async () => {
// get the Spire.XLS WASM module
const xlsModule = window.wasmModule?.spirexls;
// check that the module is ready
if (!xlsModule) {
alert('Spire.Xls is not ready yet');
return;
}
// load the font and the test data file into the VFS
await window.spire.FetchFileToVFS('ARIAL.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
const inputFileName = 'CopyCellStyle.xlsx';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);
// load the workbook and take the first worksheet
const workbook = new xlsModule.Workbook();
workbook.LoadFromFile({ fileName: inputFileName });
const sheet = workbook.Worksheets.get(0);
// row by row, put the unit price column's format onto the amount column; the amounts stay put
for (let row = 2; row <= 7; row++) {
sheet.Range.get(`E${row}`).Style = sheet.Range.get(`C${row}`).Style;
}
// save the workbook
const outputFileName = 'CopyCellStyle-output.xlsx';
workbook.SaveToFile({ fileName: outputFileName, version: xlsModule.ExcelVersion.Version2016 });
// dispose of the workbook to free resources
workbook.Dispose();
// read the result file from the VFS and start the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Copy Cell Format</h1>
<button id="copy-cell-style" onClick={copyCellStyle}>Copy the Whole Cell Format</button>
</div>
);
}
export default App;
The worksheet after copying the whole cell format:

Clone and reuse a named style
When one set of formatting is needed in several places, registering it as a named style lets it be applied to any range by name, and a colour change has to be made in one place only. A named style lives on the workbook: create it with workbook.Styles.Add, set its properties, then apply it by name through Range.CellStyleName. The example builds a style of white text on a steel blue fill for the header row, then clones it and changes only the colours for the total line -- the change lands on the clone alone, and the original style is unaffected.
A named style is registered on the workbook and applied to a range by name:
| Syntax | Result |
|---|---|
workbook.Styles.Add('OrderHeader') |
Registers a named style called OrderHeader on the workbook |
headerStyle.Font.Color, headerStyle.Color, headerStyle.FillPattern |
Sets the text colour, the fill colour and the fill pattern one by one |
headerStyle.clone() |
Copies out a style of its own, with a number appended to the name |
sheet.Range.get('A1:E1').CellStyleName = headerStyle.Name |
Applies the named style to that range by name |
The steps are:
- Load the font file and the test data file into the VFS with
window.spire.FetchFileToVFS. - Create a workbook with
new Workbook(), load the input file withworkbook.LoadFromFile, and take the first worksheet withworkbook.Worksheets.get(0). - Create a named style with
workbook.Styles.Addand set its font name, size, bold, text colour, fill colour, fill pattern and horizontal alignment. - Apply the style to the header range by name with
sheet.Range.get('A1:E1').CellStyleName. - Clone it with the style's
clone(), change only the text colour and the fill colour, and get the variant the total line needs. - Apply the variant to the total line with
sheet.Range.get('A8:E8').CellStyleName. - Save the workbook with
workbook.SaveToFileand dispose of it withworkbook.Disposeto free resources.
The full code example below shows how to clone a named style and reuse it for different ranges in React:
function App() {
const cloneNamedStyle = async () => {
// get the Spire.XLS WASM module
const xlsModule = window.wasmModule?.spirexls;
// check that the module is ready
if (!xlsModule) {
alert('Spire.Xls is not ready yet');
return;
}
// load the font and the test data file into the VFS
await window.spire.FetchFileToVFS('ARIAL.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
const inputFileName = 'CopyCellStyle.xlsx';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);
// load the workbook and take the first worksheet
const workbook = new xlsModule.Workbook();
workbook.LoadFromFile({ fileName: inputFileName });
const sheet = workbook.Worksheets.get(0);
// create a named style: white text on a steel blue fill, centred
const headerStyle = workbook.Styles.Add('OrderHeader');
headerStyle.Font.FontName = 'Arial';
headerStyle.Font.Size = 11;
headerStyle.Font.IsBold = true;
headerStyle.Font.Color = xlsModule.Color.get_White();
headerStyle.Color = xlsModule.Color.get_SteelBlue();
headerStyle.FillPattern = xlsModule.ExcelPatternType.Solid;
headerStyle.HorizontalAlignment = xlsModule.HorizontalAlignType.Center;
// apply the named style to the header row
sheet.Range.get('A1:E1').CellStyleName = headerStyle.Name;
// clone it and change only the colours, giving the variant the total line needs
const totalStyle = headerStyle.clone();
totalStyle.Font.Color = xlsModule.Color.get_DarkBlue();
totalStyle.Color = xlsModule.Color.get_LightYellow();
// apply the cloned style to the total line
sheet.Range.get('A8:E8').CellStyleName = totalStyle.Name;
// save the workbook
const outputFileName = 'CloneNamedStyle.xlsx';
workbook.SaveToFile({ fileName: outputFileName, version: xlsModule.ExcelVersion.Version2016 });
// dispose of the workbook to free resources
workbook.Dispose();
// read the result file from the VFS and start the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Clone and Reuse a Named Style</h1>
<button id="clone-named-style" onClick={cloneNamedStyle}>Clone and Reuse a Named Style</button>
</div>
);
}
export default App;
The worksheet after cloning a named style and reusing it:

FAQ
Copying the number format alone, without the fill or the border
Reason: A whole-cell copy takes the fill, font and border along with the number format, which is more than a change of display calls for.
Solution: Take NumberFormat from the source cell's style and assign that single property to the target cell's style, leaving the rest untouched:
// take the number format of the total line's quantity cell as the format to apply
const sourceStyle = sheet.Range.get('D8').Style;
// put only the number format onto the quantity column; the bold and the top border stay untouched
for (let row = 2; row <= 7; row++) {
sheet.Range.get(`D${row}`).Style.NumberFormat = sourceStyle.NumberFormat;
}
Why a whole font object cannot be assigned to the target cell
Reason: Style.Font is a read-only property; it returns the font object the cell currently carries, so it cannot be replaced wholesale -- only the members hanging off it can be changed.
Solution: Set FontName, Size, Color, IsBold and the like one at a time. To bring the fill and the border across as well, assign the whole Style; to reuse a group of settings, register them as a named style and apply that style by name.
Does re-colouring a clone change the original style as well
Cause: clone() produces a copy that stands on its own, and once saved it takes a separate entry among the workbook's named styles; the name has a number appended to keep the two apart, so OrderHeader clones to OrderHeader1. Changing the text colour and the fill colour on the copy leaves the original where it was, still carrying the colours it had before the clone.
Solution: When several colour variants are needed, clone them all from the same base style and adjust each one separately; the definition of the base style never moves.
A named style or a plain Style assignment -- which one to use
Cause: Assigning a Style copies the source cell's current look onto the target cell in one go, which suits a one-off move; Range.CellStyleName applies a named style registered on the workbook, so a format used over and over is referred to by a single name in the code.
Solution: For a one-off move of the look, assign the whole Style; for a format reused in several places, build a named style with workbook.Styles.Add and apply it by name.
Get a Free License
Spire.XLS for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.
When a workbook has to be handed to a front-end program, JSON is the shape that fits APIs, caches and components alike: one array per worksheet, one object per row, and the column headers as the keys. Once the workbook is converted, the table data joins the data flow of the page without a backend parsing it first. Spire.XLS for JavaScript performs the conversion in the browser on WebAssembly, handling input and output files through a virtual file system (VFS) with no backend service.
This article covers four core feature points:
- Convert the Entire Workbook to JSON
- Convert a Specific Worksheet to JSON
- Convert a Specific Cell Range to JSON
- Customize the JSON Output Format
For installation and project setup, see Integrating Spire.XLS for JavaScript in a React Project. The examples below assume Spire.XLS is installed and the WebAssembly module has been initialised.
Convert the Entire Workbook to JSON
Every worksheet is a set of records that share a shape: the first row names the fields, and each row below it is one record. Handing over the whole workbook writes all of them at once, with the worksheet name as the key and the records as an array under it, so there is no need to call the conversion once per worksheet or to pick a range in advance. The sample workbook's three worksheets, the sales ledger, the customers and the notes, are written together, one top-level key each.
The whole-workbook conversion only has to name the target format when saving:
| Syntax | Result |
|---|---|
fileFormat: xlsModule.FileFormat.Json |
Writes the workbook as JSON, one top-level key per worksheet |
The steps are:
- Load the font and the test data file into the VFS with
window.spire.FetchFileToVFS. - Create a workbook with
new Workbook(), then load the input file withworkbook.LoadFromFile. - Save the workbook with
workbook.SaveToFile, namingxlsModule.FileFormat.JsoninfileFormat. - Dispose of the workbook with
workbook.Disposeto free resources.
The code below is the complete example of converting the entire workbook to JSON:
function App() {
const workbookToJson = async () => {
// get the Spire.XLS WASM module
const xlsModule = window.wasmModule?.spirexls;
// check that the module is ready
if (!xlsModule) {
alert('Spire.Xls is not ready yet');
return;
}
// load the font and the test data file into the VFS
await window.spire.FetchFileToVFS('ARIAL.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
const inputFileName = 'Sample.xlsx';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);
// load the workbook
const workbook = new xlsModule.Workbook();
workbook.LoadFromFile({ fileName: inputFileName });
// save the whole workbook as JSON
const outputFileName = 'WorkbookToJson.json';
workbook.SaveToFile({ fileName: outputFileName, fileFormat: xlsModule.FileFormat.Json });
// dispose of the workbook to release its resources
workbook.Dispose();
// read the result back out of the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Convert Excel to JSON</h1>
<button id="workbook-to-json" onClick={workbookToJson}>Convert the Entire Workbook to JSON</button>
</div>
);
}
export default App;
The JSON file after converting the entire workbook:

Convert a Specific Worksheet to JSON
Most of the time only one worksheet is meant for the front end, while the rest are internal ledgers or instructions. Writing them all out lengthens the response and leaves the caller to pick the right one. The way around it is to copy the worksheet into a new workbook that holds nothing else and save that, so the result carries this one worksheet only. In the example the third worksheet, the notes, is exported, and the sales ledger and customers worksheets stay out of the result.
Isolating one worksheet comes down to three operations on the Worksheets collection:
| Syntax | Result |
|---|---|
workbook.Worksheets.get(2) |
Takes the third worksheet by index, counting from 0 |
workbook.Worksheets.Clear() |
Clears the worksheets the workbook already holds |
workbook.Worksheets.AddCopy({ sheet }) |
Copies the whole source worksheet into the cleared workbook |
The steps are:
- Load the font and the test data file into the VFS with
window.spire.FetchFileToVFS. - Create the source workbook with
new Workbook()and load the input file withworkbook.LoadFromFile, then take the worksheet to export withWorksheets.get(2). - Create a second workbook with
new Workbook()and clear the three blank worksheets it starts with usingWorksheets.Clear(). - Copy the target worksheet in with
Worksheets.AddCopy({ sheet }), so that it is the only one left. - Save the new workbook with
SaveToFile, namingxlsModule.FileFormat.JsoninfileFormat. - Dispose of both workbook objects with
Dispose.
The code below is the complete example of converting a specific worksheet to JSON:
function App() {
const worksheetToJson = async () => {
// get the Spire.XLS WASM module
const xlsModule = window.wasmModule?.spirexls;
// check that the module is ready
if (!xlsModule) {
alert('Spire.Xls is not ready yet');
return;
}
// load the font and the test data file into the VFS
await window.spire.FetchFileToVFS('ARIAL.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
const inputFileName = 'Sample.xlsx';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);
// load the source workbook and take the third worksheet
const sourceWorkbook = new xlsModule.Workbook();
sourceWorkbook.LoadFromFile({ fileName: inputFileName });
const targetSheet = sourceWorkbook.Worksheets.get(2);
// create a new workbook and clear the three worksheets it starts with
const targetWorkbook = new xlsModule.Workbook();
targetWorkbook.Worksheets.Clear();
// copy the target worksheet in, so that it is the only one left
targetWorkbook.Worksheets.AddCopy({ sheet: targetSheet });
// save that worksheet alone
const outputFileName = 'WorksheetToJson.json';
targetWorkbook.SaveToFile({ fileName: outputFileName, fileFormat: xlsModule.FileFormat.Json });
// dispose of the workbook to release its resources
sourceWorkbook.Dispose();
targetWorkbook.Dispose();
// read the result back out of the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Convert Excel to JSON</h1>
<button id="worksheet-to-json" onClick={worksheetToJson}>Convert a Specific Worksheet to JSON</button>
</div>
);
}
export default App;
The JSON file after converting a specific worksheet:

Convert a Specific Cell Range to JSON
A worksheet usually carries more than the records themselves: title rows, totals formulas, helper columns pasted in from somewhere else. Exporting the whole sheet takes all of that along, when the caller only wants the tidy block in the middle. Naming the range first keeps everything outside it out of the result. The example takes A1:D6 of the sales ledger, six rows by four columns, leaving the price, quantity and amount columns behind in the workbook.
A range export follows the same path as a worksheet export, with a block of cells standing in for the whole sheet:
| Syntax | Result |
|---|---|
sourceSheet.Range.get('A1:D6') |
Takes the source cell range by address |
workbook.Worksheets.Add('RangeData') |
Adds a worksheet called RangeData to the cleared workbook |
targetSheet.Range.get('A1:D6') |
Takes a destination range of the same size as the source |
sourceSheet.Copy({ sourceRange, destRange, copyStyle: true }) |
Copies the values and styles into the destination range; copyStyle set to true brings the styles along |
The steps are:
- Load the font and the test data file into the VFS with
window.spire.FetchFileToVFS. - Create the source workbook with
new Workbook()and load the input file withworkbook.LoadFromFile, then take the source worksheet withWorksheets.get(0). - Mark out the cell range to export with
sourceSheet.Range.get('A1:D6'). - Create a target workbook and clear its default worksheets with
Worksheets.Clear(), then add a worksheet to receive the range withWorksheets.Add('RangeData'). - Take a destination range of the same size with
targetSheet.Range.get('A1:D6')and copy the values and styles across withsourceSheet.Copy({ sourceRange, destRange, copyStyle: true }). - Save the new workbook with
SaveToFile, namingxlsModule.FileFormat.JsoninfileFormat, and dispose of both workbook objects.
The code below is the complete example of converting a specific cell range to JSON:
function App() {
const rangeToJson = async () => {
// get the Spire.XLS WASM module
const xlsModule = window.wasmModule?.spirexls;
// check that the module is ready
if (!xlsModule) {
alert('Spire.Xls is not ready yet');
return;
}
// load the font and the test data file into the VFS
await window.spire.FetchFileToVFS('ARIAL.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
const inputFileName = 'Sample.xlsx';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);
// load the source workbook and take the first worksheet
const sourceWorkbook = new xlsModule.Workbook();
sourceWorkbook.LoadFromFile({ fileName: inputFileName });
const sourceSheet = sourceWorkbook.Worksheets.get(0);
// name the cell range to export
const sourceRange = sourceSheet.Range.get('A1:D6');
// create a new workbook, clear its default worksheets and add one to receive the range
const targetWorkbook = new xlsModule.Workbook();
targetWorkbook.Worksheets.Clear();
const targetSheet = targetWorkbook.Worksheets.Add('RangeData');
// copy the values and styles across; the destination range must match the source size
const destRange = targetSheet.Range.get('A1:D6');
sourceSheet.Copy({ sourceRange, destRange, copyStyle: true });
// save the new workbook
const outputFileName = 'RangeToJson.json';
targetWorkbook.SaveToFile({ fileName: outputFileName, fileFormat: xlsModule.FileFormat.Json });
// dispose of the workbook to release its resources
sourceWorkbook.Dispose();
targetWorkbook.Dispose();
// read the result back out of the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Convert Excel to JSON</h1>
<button id="range-to-json" onClick={rangeToJson}>Convert a Specific Cell Range to JSON</button>
</div>
);
}
export default App;
The JSON file after converting a specific cell range:

Customize the JSON Output Format
The three conversions above all produce a fixed shape: the field names are taken from the header row as they stand, every value is written as a string, an amount comes out as "186400", a boolean as "TRUE", a date in the runtime's own format rather than as yyyy-MM-dd, and a blank cell leaves its field out altogether. A result like that still has to be cleaned up before an API or a component binding can use it. Reading the cells one by one instead lets you decide the field names, the type of every value, and how a blank is represented.
Reading the cells yourself calls for the getters that return real types:
| Syntax | Result |
|---|---|
sheet.get(row, column) |
Takes a cell by row and column number, both counting from 1 |
sheet.LastRow / sheet.LastColumn |
The last row and column of the used range |
cell.Text |
The text in the cell; a blank cell returns an empty string |
cell.NumberValue |
The number in a numeric column, as a number; a blank cell returns null |
cell.BooleanValue |
The true or false of a boolean column, as a boolean |
cell.DateTimeValue |
The Date object of a date column, ready to be formatted |
JSON.stringify(records, null, 2) |
Serialises the records into JSON text indented by two spaces |
The steps are:
- Load the font and the test data file into the VFS with
window.spire.FetchFileToVFS, load the workbook withnew Workbook()andworkbook.LoadFromFile, and take the customers worksheet withWorksheets.get(1). - List the fields to keep in an array, each entry naming its column header, its output field name and how its value is read.
- Read the header row with
sheet.get(1, column)and, usingsheet.LastColumn, map each column header to its column number. - Walk the rows from row 2 to
sheet.LastRow, reading each value withcell.Text,cell.NumberValue,cell.BooleanValueorcell.DateTimeValueaccording to its field, and recording a blank cell asnull. - Build a
yyyy-MM-dddate from the year, month and day of theDateobject. - Serialise the records with
JSON.stringifyand a two-space indent, then write the text into the VFS withFS.writeFile. - Dispose of the workbook with
workbook.Disposeto free resources.
The code below is the complete example of customizing the JSON output format:
function App() {
const customJsonFormat = async () => {
// get the Spire.XLS WASM module
const xlsModule = window.wasmModule?.spirexls;
// check that the module is ready
if (!xlsModule) {
alert('Spire.Xls is not ready yet');
return;
}
// load the font and the test data file into the VFS
await window.spire.FetchFileToVFS('ARIAL.TTF', '/Library/Fonts/', `${process.env.PUBLIC_URL}/font/`);
const inputFileName = 'Sample.xlsx';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}data/`);
// load the workbook and take the customers worksheet
const workbook = new xlsModule.Workbook();
workbook.LoadFromFile({ fileName: inputFileName });
const sheet = workbook.Worksheets.get(1);
// the fields to keep: column header, output field name, how to read the value
const fields = [
{ header: 'Customer ID', key: 'customerId', type: 'text' },
{ header: 'Customer Name', key: 'customerName', type: 'text' },
{ header: 'City', key: 'city', type: 'text' },
{ header: 'First Order Date', key: 'firstOrderDate', type: 'date' },
{ header: 'Renewed', key: 'renewed', type: 'boolean' },
{ header: 'Total Amount (CNY)', key: 'totalAmount', type: 'number' },
{ header: 'Remark', key: 'remark', type: 'text' },
];
// read the header row and map each column header to its column number
const columnOf = {};
for (let column = 1; column <= sheet.LastColumn; column++) {
columnOf[sheet.get(1, column).Text] = column;
}
// dates all go out as yyyy-MM-dd
const pad = (value) => String(value).padStart(2, '0');
const formatDate = (date) =>
`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`;
// walk the rows; a blank cell becomes null
const records = [];
for (let row = 2; row <= sheet.LastRow; row++) {
const record = {};
for (const field of fields) {
const cell = sheet.get(row, columnOf[field.header]);
if (field.type === 'number') {
record[field.key] = cell.NumberValue;
} else if (field.type === 'boolean') {
record[field.key] = cell.BooleanValue;
} else if (field.type === 'date') {
record[field.key] = formatDate(cell.DateTimeValue);
} else {
const text = cell.Text;
record[field.key] = text === '' ? null : text;
}
}
records.push(record);
}
// write with a two-space indent so the result stays readable
const json = JSON.stringify(records, null, 2);
// write the JSON string into the VFS
const outputFileName = 'CustomJsonFormat.json';
window.dotnetRuntime.Module.FS.writeFile(outputFileName, new TextEncoder().encode(json));
// dispose of the workbook to release its resources
workbook.Dispose();
// read the result back out of the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Convert Excel to JSON</h1>
<button id="custom-json-format" onClick={customJsonFormat}>Customize the JSON Output Format</button>
</div>
);
}
export default App;
The JSON file after applying the custom output format:

FAQ
Why are numbers, booleans and dates all strings in the exported JSON
Cause: SaveToFile writes the stored value of each cell. It neither applies the cell's number format nor infers a type, so an amount is written as "186400", a boolean as "TRUE", and a date in the runtime's own format instead of yyyy-MM-dd.
Solution: To get real types, read the cells yourself and take the value with cell.NumberValue, cell.BooleanValue or cell.DateTimeValue, then let JSON.stringify serialise the result.
Why do blank cells not appear in the JSON
Cause: Both the workbook and the worksheet conversion use the header row as the field names, and a blank cell produces no field at all, so that object ends up with fewer keys than the others.
Solution: If the caller needs every object to carry the same keys, read the cells yourself and write null whenever cell.Text comes back as an empty string.
Why is there an extra Evaluation Warning key at the top level of the JSON
Cause: The trial build adds an evaluation warning worksheet to the workbook, and in a whole-workbook conversion it becomes one more top-level key alongside the real worksheets.
Solution: Convert a specific worksheet or a specific cell range instead, and only the data you asked for is written; if the whole workbook has to be exported as it stands, contact sales for a temporary license that removes the evaluation message.
How do I export only a few columns
Cause: The workbook and worksheet conversions both write every column in the worksheet, while a range export can only take a consecutive block, so columns such as A, C and E cannot be expressed as a range.
Solution: Read the cells yourself the way the custom output format does and keep the fields you need; when the columns happen to be consecutive, a range export narrows the result just as well.
Get a Free License
Spire.XLS for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.
Mail Merge Word Documents and Format Values by Culture with JavaScript in React
2026-10-16 08:17:26 Written by Amy ZhaoWhen the same document has to be sent to multiple recipients, only a few elements change from one copy to the next, such as the recipient name, fax number and date, while the rest of the layout and wording remains identical. Word's mail merge turns this variable content into merge fields kept in a mail merge template; at run time, a set of data is filled in to generate the corresponding document. Compared with saving each copy separately on the desktop, performing the merge in the browser does not require recipients to install Office, and the data does not have to be uploaded to a server for processing.
Using Spire.Doc for JavaScript as an example, this article explains the structure of the merge fields in a mail merge template, how MailMerge.Execute() is called, and how to prepare a date string for a target culture before the merge. The entire process runs in the browser through WebAssembly, and both the template and the data are read from and written to the local virtual file system (VFS).
This article covers three core features:
- Prepare a Mail Merge Template with Merge Fields in Word
- Execute a Mail Merge
- Format Date Fields by Culture
For installation and project setup, refer to Integrating Spire.Doc for JavaScript in a React Project. The examples below assume Spire.Doc is installed and the WebAssembly module is initialized.
Prepare a Mail Merge Template with Merge Fields in Word
A mail merge template is an ordinary .docx document: the fixed content is laid out as usual, and the positions that change with the data are replaced by merge fields. A merge field is enclosed by a pair of field markers; the field code declares the field name with MERGEFIELD, and the field result area displays placeholder text in the form «field name», which is the text replaced during the mail merge.
The mail merge template used in this article is a "Requirement Confirmation" letter. The letterhead, document number, body text and confirmation table are all fixed content, and three merge fields are defined:
| Merge Field | Location | Occurrences |
|---|---|---|
Contact Name |
Recipient row, salutation, closing contact | 3 |
Fax |
Fax row | 1 |
Date |
Date row | 1 |
Contact Name appears three times in the document. After the merge, all three occurrences take the same value, so it only has to be supplied once in the field value array.
The specific steps are as follows:
- Create a Word document and lay out the fixed content.
- Place the cursor where the variable content should go and press Ctrl + F9 to insert a pair of field markers.
- Enter the field code inside the markers, for example
MERGEFIELD "Contact Name" \* MERGEFORMAT; enclose the field name in double quotes when it contains spaces. - Press F9 to update the field. The position then displays placeholder text in the form
«Contact Name». - Insert the remaining merge fields at the other variable positions in the same way, save the document as .docx, and place it in the project's public directory, for example
public/static/data/MailMerge.docx.
It is important to note that the placeholder text «field name» belongs to the field's result area, which lies between the separate and end field markers. If the angle brackets are typed manually outside the field, they do not belong to that field and are not replaced during the merge. Pressing Alt + F9 switches between the field code and the field result, which helps verify where the field markers begin and end.
Execute a Mail Merge
MailMerge.Execute(fieldNames, fieldValues) takes two arrays of equal length, whose elements correspond by index:
| Parameter | Description |
|---|---|
fieldNames |
Array of field names, corresponding to the field names declared by MERGEFIELD in the template |
fieldValues |
Array of field values, corresponding to fieldNames by index |
Field name matching is case-insensitive. A field that exists in the template but is not listed in fieldNames is left blank in its result area after the merge.
The specific steps are as follows:
- Get the Spire.Doc WASM module and confirm that it is ready.
- Load the mail merge template and the font into the virtual file system (VFS).
- Create a
Documentobject and load the template. - Prepare the field name array and the field value array so that their order corresponds one to one.
- Call the
Document.MailMerge.Execute()method to execute the mail merge. - Save the result file with the
Document.SaveToFile()method, read it back from the VFS, and trigger a browser download.
The code for executing a mail merge is as follows:
function App() {
const MailMerge = async () => {
// Get the Spire.Doc WASM module
const docModule = window.wasmModule?.spiredoc;
// Check if the module is ready
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Load the mail merge template and the font
let inputFileName = 'MailMerge.docx';
await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);
await window.spire.FetchFileToVFS('ARIALUNI.TTF', "/Library/Fonts/", `${process.env.PUBLIC_URL}static/font/`);
// Create a Document instance and load the template
const doc = new docModule.Document();
doc.LoadFromFile(inputFileName);
// Field names and field values correspond by index
let fieldNames = ["Contact Name", "Fax", "Date"];
let fieldValues = ["John Smith", "+1 (69) 123456", docModule.DateTime.get_Now().Date.ToString()];
// Execute the mail merge
doc.MailMerge.Execute(fieldNames, fieldValues);
// Save the result file
const outputFileName = 'MailMerge-result.docx';
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
doc.Dispose();
// Read the generated file from the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Execute a Mail Merge</h1>
<button onClick={MailMerge}>Generate</button>
</div>
);
}
export default App;
The merge logic itself is a single line, doc.MailMerge.Execute(fieldNames, fieldValues); the rest of the code delivers the template to the VFS and retrieves the result for download. DateTime.get_Now().Date.ToString() returns a string representation of the current date, which can be passed directly as the field value.
After the merge, all five merge field occurrences in the template are replaced with the corresponding field values, and the rest of the layout remains unchanged:
| Merge Field | Before Merge | After Merge |
|---|---|---|
Contact Name |
«Contact Name» | John Smith |
Fax |
«Fax» | +1 (69) 123456 |
Date |
«Date» | 10/22/2026 12:00:00 AM |
The three occurrences of Contact Name (recipient row, salutation, closing contact) all become John Smith, which shows that fields with the same name share one value.
Word document after a mail merge has been executed with JavaScript in React

Format Date Fields by Culture
The output format of DateTime.get_Now().Date.ToString() depends on the culture settings of the runtime environment: a Chinese environment produces 2026/10/22 上午12:00:00, while switching to an English environment may produce 10/22/2026 12:00:00 AM. For a recipient in Germany, the conventional form is 22.10.2026, 17:19:45 — day first, month in the middle, separated by dots.
The mail merge itself only places a piece of text at the field position and does not rearrange the date. The way to control the date format is therefore to format the value as a string for the target culture before it reaches MailMerge.Execute(). The browser's built-in Intl.DateTimeFormat performs this step; its first argument is the locale code and its second argument is the format options.
The specific steps are as follows:
- Get the Spire.Doc WASM module and confirm that it is ready.
- Load the mail merge template and the font into the virtual file system (VFS).
- Use
Intl.DateTimeFormat("de-DE", formatOptions)to format the current time as a German-style date string. - Replace the value of the
Datefield with this string, and keep the other field values unchanged. - Call the
Document.MailMerge.Execute()method to execute the mail merge. - Save the result file with the
Document.SaveToFile()method, read it back from the VFS, and trigger a browser download.
The code for formatting the date field by culture is as follows:
function App() {
const ChangeLocale = async () => {
// Get the Spire.Doc WASM module
const docModule = window.wasmModule?.spiredoc;
// Check if the module is ready
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Load the mail merge template and the font
let inputFileName = 'MailMerge.docx';
await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);
await window.spire.FetchFileToVFS('ARIALUNI.TTF', "/Library/Fonts/", `${process.env.PUBLIC_URL}static/font/`);
// Create a Document instance and load the template
const doc = new docModule.Document();
doc.LoadFromFile(inputFileName);
// Prepare the date string for the German culture (day.month.year)
const datestr = new Intl.DateTimeFormat('de-DE', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
}).format(new Date());
// Field names and field values correspond by index
let fieldNames = ["Contact Name", "Fax", "Date"];
let fieldValues = ["John Smith", "+1 (69) 123456", datestr];
// Execute the mail merge
doc.MailMerge.Execute(fieldNames, fieldValues);
// Save the result file
const outputFileName = 'ChangeLocale-result.docx';
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
doc.Dispose();
// Read the generated file from the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Format Date Fields by Culture</h1>
<button onClick={ChangeLocale}>Generate</button>
</div>
);
}
export default App;
The template is exactly the same and the merge call is exactly the same; what changes is the source of the date string in fieldValues:
const datestr = new Intl.DateTimeFormat('de-DE', { /* format options */ }).format(new Date());
Intl.DateTimeFormat is provided natively by the browser. The locale code can be switched among de-DE, en-US, fr-FR and ja-JP, and the date convention changes accordingly.
With the same template and the same set of data, only the format of the Date field differs:
| Source of the date field | Merge result |
|---|---|
DateTime.get_Now().Date.ToString() |
10/22/2026 12:00:00 AM |
Intl.DateTimeFormat('de-DE', …).format(new Date()) |
22.10.2026, 17:19:45 |
Word document after the date field has been formatted by culture with JavaScript in React

Output a Localized Date with toLocaleString
In addition to Intl.DateTimeFormat, the Date object itself provides localized output methods. toLocaleString() accepts the same locale code and format options and produces the same result:
const datestr = new Date().toLocaleString('de-DE', {
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit'
});
When only the date part is needed, use toLocaleDateString('de-DE', { year: 'numeric', month: '2-digit', day: '2-digit' }) instead. In data exchange scenarios that require an ISO-style form, toISOString().slice(0, 10) returns a string such as 2026-10-22 (this method uses the UTC date).
FAQ
Why does the merged document still contain a placeholder such as «Contact Name»
Cause: The placeholder text belongs to the field's result area, which lies between the separate and end markers of the field code. If « and » were typed as text outside the field, they are not part of that field; MailMerge.Execute() only replaces content inside the field, and text outside it remains as it is.
Solution: In Word, press Ctrl + F9 to create the field, enter the field code, and then press F9 so that Word generates the result text. Do not type the angle brackets manually outside the field. When the template is generated from code, « and » must be written between the separate and end markers, not as a separate run outside the field.
How should a field name that contains a space be written in the template and in JavaScript
In a Word field code, a field name containing spaces or other special characters is enclosed in double quotes:
{ MERGEFIELD "Contact Name" \* MERGEFORMAT }
What is passed to MailMerge.Execute() is the field name itself, without quotes:
let fieldNames = ["Contact Name"];
What is the result when a field in the template is not included in fieldNames
The field's result area is merged as an empty string rather than keeping the original placeholder, and the other fields in the template are processed as usual. This behavior can be used to leave a blank automatically when data is missing; if the original placeholder text should be kept, add the field name to fieldNames and place the original value at the corresponding position in fieldValues.
In addition, field name matching is case-insensitive, so "contact name" and "Contact Name" match the same field. The value actually used is determined by the array index, so the field names and the field values must keep the same order.
Why does the date appear differently on different computers after it is merged into the document
DateTime.get_Now().Date.ToString() produces output according to the current culture settings of the runtime environment, so systems or browsers with different regional settings return different strings. For stable output, follow the approach in "Format Date Fields by Culture": specify the locale code explicitly with Intl.DateTimeFormat and then pass the result to Execute.
In addition, in the environment used for the examples in this article, the time portion returned by DateTime.get_Now() is 12:00:00 AM, meaning that only the date is carried over. When a specific time is required, use the browser's Date object together with Intl.DateTimeFormat to generate the date string.
The downloaded docx will not open and is reported as corrupted
Cause: The Blob's MIME type is incomplete, or the downloaded bytes are not the ones read from the VFS after SaveToFile().
Solution: Declare the full MIME type for the docx and read the file only after saving is complete:
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], {
type: 'application/vnd.openxmlformats-officedocument.wordprocessingml.document'
});
Get a Free License
Spire.Doc for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.
The charts, flow diagrams, and logos exported on the web are mostly SVG, yet printing, archiving, or an approval workflow usually asks for PDF. Saving them one by one in office software takes a person sitting through it, and batch-converting on a server means uploading the files first.
This article uses Spire.PDF for JavaScript to bridge SVG and PDF. It is built on WebAssembly, loading and saving documents in the browser, and it reads and writes files through a virtual file system (VFS), with no backend involved. There are two paths: turn an SVG into a PDF file on its own, or draw the SVG as vector graphics onto a chosen page of an existing PDF.
This article covers two core features:
For installation and project configuration, refer to Integrate Spire.PDF for JavaScript in a React Project. The examples below assume Spire.PDF is already installed and the WebAssembly module is initialized.
Convert an SVG to PDF
Spire.PDF for JavaScript provides the PdfDocument.LoadFromSvg method, which loads an SVG as a PDF document. The loaded document has a single page, and the page size is taken from the width/height on the SVG root element.
The steps are as follows:
- Get the Spire.PDF WASM module and make sure it is ready.
- Read the SVG file into the virtual file system (VFS).
- Create a
PdfDocumentobject and load the SVG with theLoadFromSvg()method. - Use the
PdfDocument.SaveToFile()method to save the result file. - Read the result file from the VFS and trigger a browser download.
The code to convert an SVG to PDF is as follows:
function App() {
const convertSvgToPdf = async () => {
// Get the Spire.PDF WASM module
const pdfModule = window.wasmModule?.spirepdf;
// Check if the module is ready
if (!pdfModule) {
alert('Spire.PDF is not ready yet');
return;
}
// Load the SVG to convert into the VFS
const inputFileName = 'VectorChart.svg';
await window.spire.FetchFileToVFS(inputFileName, '', `${process.env.PUBLIC_URL}/data/`);
// Load the SVG: the document has a single page, the size of the SVG
let doc = new pdfModule.PdfDocument();
doc.LoadFromSvg({ fileName: inputFileName });
// Save the result file
const outputFileName = 'VectorChart.pdf';
doc.SaveToFile(outputFileName);
doc.Close();
// Read the generated file from the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Convert SVG to PDF</h1>
<button onClick={convertSvgToPdf}>
Convert to PDF
</button>
</div>
);
}
export default App;
The result is a single-page PDF whose page size matches the SVG:

Draw an SVG onto an Existing PDF
Spire.PDF for JavaScript also provides the PdfPageBase.CreateTemplate method and the PdfCanvas.DrawTemplate method, which overlay an SVG onto an already laid-out PDF. Load the SVG, turn its single page into a template, then draw it onto the target page at a chosen position and size — the original document's page count, page size, and existing content are untouched.
The steps are as follows:
- Get the Spire.PDF WASM module and make sure it is ready.
- Read the existing PDF and the SVG logo into the virtual file system (VFS).
- Create a
PdfDocumentobject for each and load the existing PDF and the SVG. - Use the
CreateTemplate()method to turn the SVG's first page into a template, then draw it onto the target page with theCanvas.DrawTemplate()method, at a given position and size. - Use the
SaveToFile()method to save the PDF that was drawn on, then read the result file from the VFS and trigger a browser download.
The code to draw an SVG onto an existing PDF is as follows:
function App() {
const drawSvgOnPdf = async () => {
// Get the Spire.PDF WASM module
const pdfModule = window.wasmModule?.spirepdf;
// Check if the module is ready
if (!pdfModule) {
alert('Spire.PDF is not ready yet');
return;
}
// Load both the existing PDF and the SVG logo into the VFS
const pdfFileName = 'ProductManual.pdf';
const svgFileName = 'VectorLogo.svg';
await window.spire.FetchFileToVFS(pdfFileName, '', `${process.env.PUBLIC_URL}/data/`);
await window.spire.FetchFileToVFS(svgFileName, '', `${process.env.PUBLIC_URL}/data/`);
// Load the existing PDF
let existingPdf = new pdfModule.PdfDocument();
existingPdf.LoadFromFile(pdfFileName);
// Load the SVG and turn its single page into a template
let svgDoc = new pdfModule.PdfDocument();
svgDoc.LoadFromSvg({ fileName: svgFileName });
const template = svgDoc.Pages.get_Item(0).CreateTemplate();
// Draw the template on page 1: position and size are in points, origin at the top-left of the page
const location = new pdfModule.PointF(390, 50);
const size = new pdfModule.SizeF(130, 60);
existingPdf.Pages.get_Item(0).Canvas.DrawTemplate({ template, location, size });
// Save the PDF that was drawn on
const outputFileName = 'ProductManual_WithLogo.pdf';
existingPdf.SaveToFile(outputFileName);
svgDoc.Close();
existingPdf.Close();
// Read the generated file from the VFS and trigger the download
const fileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([fileArray], { type: 'application/pdf' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Draw SVG on Existing PDF</h1>
<button onClick={drawSvgOnPdf}>
Insert SVG
</button>
</div>
);
}
export default App;
The original manual content is unchanged, with a vector logo added at the top right:

Frequently Asked Questions
Why isn't the output PDF A4-sized
Cause: When LoadFromSvg creates the page it takes the width/height from the SVG root element, converting pixels to points at 75%. In the example, a 760 × 520 SVG becomes a single page of 570 × 390 points — nothing to do with A4.
Solution: To use another size, change the width, height, or viewBox on the SVG root element and convert again.
How do I draw the SVG on another page or at another position
Cause: Which page DrawTemplate draws on is decided by how the page is fetched, and which corner it lands in is decided by location; both are passed in explicitly by the caller.
Solution: Switch pages with Pages.get_Item(index) (0-based), change the position via PointF, and the size via SizeF — all in points, with the origin at the top-left of the page:
// Draw on page 2, bottom left, at a size of 100 × 46 points
existingPdf.Pages.get_Item(1).Canvas.DrawTemplate({
template: template,
location: new pdfModule.PointF(40, 760),
size: new pdfModule.SizeF(100, 46),
});
Is the inserted SVG vector or a bitmap
Cause: What CreateTemplate returns is a PDF template corresponding to the SVG content, and DrawTemplate draws it into the page as vectors, without going through a bitmap.
Solution: Nothing extra to set. Zoom in on the page afterwards and the logo's edges stay crisp; it prints without blurring either.
Get a Free License
If you would like to remove the evaluation message from the result document or lift feature restrictions, please contact sales for a 30-day temporary license.
Set Word Merge Field Switches and Conditional Fields with JavaScript in React
2026-10-16 08:16:41 Written by Amy ZhaoThe same Word template often has to look different on different occasions: in a letter to a customer the name should carry a courtesy title, while in an internal filing table it should be upper case throughout. Editing the template field by field means maintaining a new file for every variation; assembling the judgement in code means the template loses its value for reuse.
This article handles both requirements in the browser with Spire.Doc for JavaScript. One is handled by field switches: instructions such as \b, \f and \* Upper are written into the template's field codes, so the same field value can land in the document in different shapes. The other is handled by a conditional field: the document hard-codes no sentence at all, but carries an IF field that lets Word decide, when the document is saved, which sentence the current data calls for; this kind of field is assembled dynamically in code.
The whole process runs in the browser on WebAssembly and manages the template document through a virtual file system (VFS); it needs neither an Office installation nor a backend service.
This article covers two core features:
For installation and project setup, refer to Integrating Spire.Doc for JavaScript in a React Project. The examples below assume Spire.Doc is installed and the WebAssembly module is initialized.
Use Field Switches in a Merge Field
A field switch is a short instruction written inside the MERGEFIELD field code, after the field name. It does not change the value passed in; it changes only the shape in which the value is written into the document: a prefix, a suffix, a change of case. Because the switch belongs to the template itself, hanging different switches on the same field in different places produces different results within one document.
The merge field switches used most often are:
| Switch | What it does |
|---|---|
| (none) | Outputs the field value as it is |
\b "Mr " |
Adds a prefix; the argument includes a trailing space |
\b Mr |
Adds a prefix; the argument has no space and sits flush against the field value |
\f " (Author)" |
Adds a suffix; the argument includes a leading space |
\* Upper |
Converts everything to upper case |
\* Lower |
Converts everything to lower case |
\* FirstCap |
Capitalises the first character only |
\* Caps |
Capitalises the first letter of every word |
\* MERGEFORMAT |
Keeps the field formatting; the text is unchanged |
Prepare the Template
To lay the output of every switch side by side for comparison, the sample uses a Customer Contact Card as its template: the same merge field XX_Name appears ten times in the “Name Format Comparison” table, each occurrence carrying a different switch, so the merge only has to supply the value once.
The ten field codes in the template are as follows ({ } are Word's field braces, which you type with Ctrl + F9):
{ MERGEFIELD XX_Name }
{ MERGEFIELD XX_Name \b "Mr " }
{ MERGEFIELD XX_Name \b Mr }
{ MERGEFIELD XX_Name \f " (Author)" }
{ MERGEFIELD XX_Name \* Upper }
{ MERGEFIELD XX_Name \* Lower }
{ MERGEFIELD XX_Name \* FirstCap }
{ MERGEFIELD XX_Name \* Caps }
{ MERGEFIELD XX_Name \* MERGEFORMAT }
{ MERGEFIELD XX_Name \b "Mr " \f " (Author)" }
Spaces separate the field name from the switches and the switches from one another. If the text argument of a switch itself contains a space, wrap it in double quotes (as in \b "Mr "); in that case the space inside the quotes is part of the argument.
The Code
The steps are as follows:
- Get the Spire.Doc WASM module and confirm that it is ready.
- Load the sample template into the virtual file system (VFS), create a
Documentobject and load the file. - Prepare the field name and field value arrays: the field name is
XX_Nameand the field value isjason tang. - Call
Document.MailMerge.Execute()to run the mail merge. - Save the result with
Document.SaveToFile(). - Read the result file back from the VFS and trigger the browser download.
The code for using field switches in a Word merge field is as follows:
function App() {
const MailMergeSwitches = async () => {
// Get the Spire.Doc WASM module
const docModule = window.wasmModule?.spiredoc;
// Check if the module is ready
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Load the sample template into the virtual file system (VFS)
let inputFileName = "MailMergeSwitches.docx";
await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);
// Create a document and load the template
const doc = new docModule.Document();
doc.LoadFromFile(inputFileName);
// Provide a value for one field only; all ten occurrences in the template use it
let fieldNames = ["XX_Name"];
let fieldValues = ["jason tang"];
// Execute the mail merge
doc.MailMerge.Execute(fieldNames, fieldValues);
// Define the output file name
const outputFileName = "MailMergeSwitches-result.docx";
// Save the document
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
doc.Dispose()
// Read the saved file and convert it to a Blob
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Merge data into a Word template that carries field switches</h1>
<button onClick={MailMergeSwitches}>Generate</button>
</div>
);
}
export default App;
Notice that the code contains no switch-related API at all: every switch lives in the template's field codes, and Execute only has to give XX_Name a value. This is exactly how a field switch is meant to be used — the data stays the same, and the template decides the shape. The sample deliberately passes the value in lower case, as jason tang; if Jack Tang were passed, several of the case-related switches would produce output indistinguishable from the default row.
The Result
Merged with jason tang, the result is as follows:
Word output after the customer contact card has been merged, with all ten XX_Name occurrences generated from a single field value

| Field switch | What it does | Merged result (XX_Name) |
|---|---|---|
(no switch) |
Outputs the value as is | jason tang |
\b "Mr " |
Prefix, trailing space | Mr jason tang |
\b Mr |
Prefix, no space | Mrjason tang |
\f " (Author)" |
Suffix, leading space | jason tang (Author) |
\* Upper |
Converts to upper case | JASON TANG |
\* Lower |
Converts to lower case | jason tang |
\* FirstCap |
First character only | Jason tang |
\* Caps |
Every word capitalised | Jason Tang |
\* MERGEFORMAT |
Keeps field formatting | jason tang |
\b "Mr " \f " (Author)" |
Prefix and suffix | Mr jason tang (Author) |
A few points are worth noting:
- The difference between
\b "Mr "and\b Mris that pair of quotes. The argument of the first isMrwith a trailing space, which outputsMr jason tang; the argument of the second isMrwith no space, so it sits flush against the field value and becomesMrjason tang. To leave a gap between the added text and the field value, write the space inside the quotes. \bis a prefix and\fis a suffix, and both can appear in the same field.\* FirstCapcapitalises only the first character (Jason tang), while\* Capscapitalises the first letter of every word (Jason Tang). The two names are similar but the effects differ.\* MERGEFORMATdoes not affect the text. It preserves the field's formatting, Word adds it by default when it creates a merge field.
Combine a Prefix and a Suffix in One Field
When a position needs both a courtesy title and a signature, there is no need to split \b and \f into two fields. Write both into the field code of the same field, in the order “prefix first, suffix second”; line 10 of the template is written exactly that way:
{ MERGEFIELD XX_Name \b "Mr " \f " (Author)" }
Passing jason tang produces Mr jason tang (Author) after the merge: one field value, with both the title and the signature attached at once.
Build and Execute a Conditional Field
Field switches can only change the shape; they cannot choose different text according to the data. When “show this sentence if the data meets the condition, otherwise show that one” is needed, a conditional field (an IF field) is required: an IF field holds a merge field as the test, followed by two result texts, and Word evaluates it when the document is saved.
{ IF { MERGEFIELD Count } > "1" "Greater than one" "Less than one" }
Taken apart, this stretch contains four kinds of element:
- an
IfField, whose field type is set toFieldType.FieldIf; - a merge field node (
FieldType.FieldMergeField) — the value being compared in the condition; - the operator and the two branch texts, appended as ordinary text fragments;
- a
FieldMarkof typeFieldMarkType.FieldEnd, which closes the field off and has to be assigned back toIfField.Endfor the field to be complete.
This document has no input template at all; both IF fields are built from scratch, so LoadFromFile is not needed — but the font still has to be loaded first for the layout Word performs when the document is saved.
The Code
The steps are as follows:
- Get the Spire.Doc WASM module and confirm that it is ready.
- Load the
ARIALUNI.TTFfont into the virtual file system (VFS). - Create a
Documentobject, add a section and then add the first paragraph. - Build an IF field with
IfField, appending in order the merge field that serves as the test, the operator, the comparison basis and the two branch texts. - Create a
FieldEndmark withCreateParagraphItem(), append it to the paragraph and assign it toIfField.End. - Build the second conditional field in the same way.
- Call
MailMerge.Execute()to supply values for the merge fields inside the conditional fields, and setIsUpdateFields = true. - Save the result file, read it back from the VFS and trigger the browser download.
The code for building and executing a conditional field is as follows:
function App() {
const ExecuteConditionalField = async () => {
// Get the Spire.Doc WASM module
const docModule = window.wasmModule?.spiredoc;
// Check if the module is ready
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Load the ARIALUNI.TTF font into the virtual file system (VFS)
await window.spire.FetchFileToVFS('ARIALUNI.TTF','/Library/Fonts/',`${process.env.PUBLIC_URL}/static/font/`);
// Create a document and add a section
let doc = new docModule.Document();
let section = doc.AddSection();
// First conditional field: Count > 1
let paragraph = section.AddParagraph();
let ifField1 = new docModule.IfField(doc);
ifField1.Type = docModule.FieldType.FieldIf;
ifField1.Code = "IF ";
paragraph.Items.Add(ifField1);
paragraph.AppendField("Count", docModule.FieldType.FieldMergeField);
paragraph.AppendText(" > ");
paragraph.AppendText("\"1\" ");
paragraph.AppendText("\"Greater than one\" ");
paragraph.AppendText("\"Less than one\"");
let end1 = doc.CreateParagraphItem(docModule.ParagraphItemType.FieldMark);
end1.Type = docModule.FieldMarkType.FieldEnd;
paragraph.Items.Add(end1);
ifField1.End = end1;
// Second conditional field: Age > 50
paragraph = section.AddParagraph();
let ifField2 = new docModule.IfField(doc);
ifField2.Type = docModule.FieldType.FieldIf;
ifField2.Code = "IF ";
paragraph.Items.Add(ifField2);
paragraph.AppendField("Age", docModule.FieldType.FieldMergeField);
paragraph.AppendText(" > ");
paragraph.AppendText("\"50\" ");
paragraph.AppendText("\"The old man\" ");
paragraph.AppendText("\"The young man\"");
let end2 = doc.CreateParagraphItem(docModule.ParagraphItemType.FieldMark);
end2.Type = docModule.FieldMarkType.FieldEnd;
paragraph.Items.Add(end2);
ifField2.End = end2;
// Supply values for the merge fields inside the conditional fields
let fieldNames = ["Count", "Age"];
let fieldValues = ["2", "30"];
doc.MailMerge.Execute(fieldNames, fieldValues);
// Make the fields evaluate when the document is saved
doc.IsUpdateFields = true;
// Define the output file name
const outputFileName = "ExecuteConditionalField-result.docx";
// Save the document
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
doc.Dispose()
// Read the saved file and convert it to a Blob
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Create and execute a conditional field</h1>
<button onClick={ExecuteConditionalField}>Generate</button>
</div>
);
}
export default App;
A few details of the assembly order matter:
ifField.Code = "IF "only marks the field type. What actually assembles the body of the field is theAppendFieldand theAppendTextcalls that follow; they are laid out into this field one after another.- The operator and the two branch texts are all appended with
AppendText, and the quotes among them have to be written into the string itself ("\"1\" "). An IF field requires both result texts to be quoted, and these quote characters are what does it. - The order is fixed: compared value → operator → comparison basis → true branch → false branch. The false branch needs no extra space after its closing quote.
FieldEndis the field's closing mark. It has to be created withCreateParagraphItem(ParagraphItemType.FieldMark), given itsend.Type, appended to the paragraph and then assigned toifField.End. Skip any one of those steps and the field is incomplete.IsUpdateFields = truedecides whether the field is evaluated when the document is saved; it belongs afterExecuteand beforeSaveToFile.
The Result
With Count = "2" and Age = "30", the two conditional fields take the true branch and the false branch respectively. The evaluated result of a conditional field is only one line of text and the rest of the page is blank, so the two branches are shown here in a table:
| Field values | First paragraph (IF Count > "1") |
Second paragraph (IF Age > "50") |
|---|---|---|
Count = "2", Age = "30" |
Greater than one |
The young man |
Count = "0", Age = "70" |
Less than one |
The old man |
Replacing the values flips the result, without changing either the template structure or the code.
FAQ
The conditional field saves as two blank paragraphs — why
Cause: A field in the document is only an instruction waiting to be evaluated. When IsUpdateFields is not set, saving does not trigger evaluation, neither branch's text is output, and the result looks like two blank paragraphs.
Solution: Turn the switch on after Execute and before SaveToFile:
doc.MailMerge.Execute(fieldNames, fieldValues);
doc.IsUpdateFields = true; // Make the fields evaluate when the document is saved
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
The prefix added by \b is stuck to the name — what now
Cause: The text argument of a switch carries no space of its own. The argument of \b Mr is the two characters Mr, and the field value follows immediately in the output, which gives Mrjason tang.
Solution: Write the space into the argument and wrap it in double quotes:
{ MERGEFIELD XX_Name \b "Mr " }
\f works the same way: write the leading space inside the quotes. One space between the field name and the switch, and between switches, is enough; extra spaces do not affect parsing.
What is the difference between \* FirstCap and \* Caps
\* FirstCap converts only the first character to upper case and leaves the rest alone: jason tang → Jason tang.
\* Caps converts the first letter of every word to upper case: jason tang → Jason Tang.
When checking these two switches, the field value has to be lower case or mixed case; if the value is already Jack Tang, both lines look the same as the default line.
Why do some switches written on a merge field have no effect
Not every Word field switch takes part in the text replacement of a mail merge. \* MERGEFORMAT preserves the field's formatting and never changes the text to begin with; switches such as \m, \v and \# 0.00 (a number format) usually produce no visible change when used on a plain-text field value.
The ones verified in this article and usable as they are: \b, \f, \* Upper, \* Lower, \* FirstCap, \* Caps, and \* MERGEFORMAT, which can be stacked with a prefix or a suffix.
Changing the values of Count and Age does not change the output
Cause: The result text of a conditional field is cached in the document. When IsUpdateFields is not on, saving keeps the previous evaluation; changing only the values passed to Execute without running the merge and saving again naturally leaves the old content in the file.
Solution: After every change, go through “pass values → evaluate → save” again:
// Pass the new Count and Age
doc.MailMerge.Execute(fieldNames, fieldValues);
// Re-evaluate on save so the result follows the new values
doc.IsUpdateFields = true;
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
Field name matching is case-insensitive: AppendField("Count", …) and Execute(["count"], …) are treated as the same field. What does affect the result is that the name itself cannot change — a field name not listed in Execute is not replaced, and its result area is left empty.
Get a Free License
Spire.Doc for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.
Hide Empty Word Regions and Identify Merge Fields with JavaScript in React
2026-10-16 08:16:25 Written by Amy ZhaoMail merge templates usually carry a few lines that only make sense when there is data behind them: a supplementary note, a remark, an optional cc. When the data is missing and only blank space is left behind, the layout is stretched apart, and the reader can hardly tell “this item was not included this time” from “something was left out”. The ideal outcome is for that line, together with the space it takes up, to disappear.
A related requirement turns up when a template has to be checked. When someone hands you a template, you need to know which merge fields it contains and how those fields are divided into merge regions. Expanding the field codes one by one to take stock of them is not realistic.
Spire.Doc for JavaScript handles both tasks in the browser: empty paragraphs and empty regions are cleaned up by the mail merge process itself, while region names and merge field names are read directly from MailMerge. The component runs on WebAssembly and reads and writes the template through a virtual file system (VFS), so no backend service is required.
This article covers two core features:
- Hide empty paragraphs and merge regions automatically after the merge
- Read the region names and merge field names from a Word template
For installation and project setup, refer to Integrating Spire.Doc for JavaScript in a React Project. The examples below assume Spire.Doc is installed and the WebAssembly module is initialized.
Hide Empty Paragraphs and Merge Regions After a Mail Merge
Prepare the Template
Two boolean properties on MailMerge control the automatic hiding of empty paragraphs and merge regions after a mail merge:
| Property | What it does | Default |
|---|---|---|
MailMerge.HideEmptyParagraphs |
Deletes paragraphs whose content is empty after the merge | false |
MailMerge.HideEmptyGroup |
Deletes merge regions whose internal paragraphs have all become empty | false |
Both properties are off by default and have to be set explicitly before MailMerge.Execute().
The sample template is a “Service Follow-up Confirmation” in which three optional constructs are written in three different ways:
| How it is written | Where it sits in the template | Merge result with hiding off |
|---|---|---|
«Optional Note» in a paragraph of its own |
after the introductory copy | kept as one blank line |
the merge region OptionalBlock (two region-marker paragraphs + two field paragraphs) |
after the delivery data row | kept as four blank lines |
Note: «Optional Remark» (label and field in one paragraph) |
before the signature | kept as the label Note: |
The part of the template that concerns hiding looks like this:
«Optional Note»
«GroupStart:OptionalBlock»
«Optional Line 1»
«Optional Line 2»
«GroupEnd:OptionalBlock»
Note: «Optional Remark»
In Word a region marker is a MERGEFIELD field like any other; only the field name is written as GroupStart:region name and GroupEnd:region name, and without quotation marks.
The Code
The steps are as follows:
- Get the Spire.Doc WASM module and confirm that it is ready.
- Load the sample template into the virtual file system (VFS), create a
Documentobject and load the file. - Before
MailMerge.Execute(), setHideEmptyParagraphsandHideEmptyGrouptotrue. - Call
MailMerge.Execute()with the field names and their values to run the mail merge. - Save the result with
Document.SaveToFile(), read it back from the VFS and trigger the browser download.
The code for hiding empty paragraphs and merge regions after a mail merge is as follows:
function App() {
const HideEmptyRegions = async () => {
// Get the Spire.Doc WASM module
const docModule = window.wasmModule?.spiredoc;
// Check if the module is ready
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Load the sample template into the virtual file system (VFS)
let inputFileName = "HideEmptyRegions.docx";
await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);
// Create a document and load the template
let doc = new docModule.Document();
doc.LoadFromFile(inputFileName);
// Provide values for three fields only; the optional fields in the
// template are left empty
let fieldNames = ["Contact Name", "Fax", "Date"];
let fieldValues = ["John Smith", "+1 (69) 123456", docModule.DateTime.get_Now().Date.ToString()];
// Hide the paragraphs and merge regions that come out empty
doc.MailMerge.HideEmptyParagraphs = true;
doc.MailMerge.HideEmptyGroup = true;
// Execute the mail merge
doc.MailMerge.Execute(fieldNames, fieldValues);
// Define the output file name and save the document
const outputFileName = "HideEmptyRegions-result.docx";
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
doc.Dispose()
// Read the saved file and convert it to a Blob
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Hide empty paragraphs and merge regions</h1>
<button onClick={HideEmptyRegions}>Generate</button>
</div>
);
}
export default App;
The key statements are the two lines before Execute():
doc.MailMerge.HideEmptyParagraphs = true;
doc.MailMerge.HideEmptyGroup = true;
Both live on MailMerge, both default to false, and both have to be turned on explicitly. The timing matters: they have to be set before Execute(), because the deletion happens during the merge, and changing the properties after the merge has finished has no effect on the result that has already been produced.
fieldNames lists only the three main fields; Optional Note, Optional Line 1, Optional Line 2 and Optional Remark are given no values, so all of them come out empty after the merge.
The Result
With both switches on, the empty paragraphs and the empty region disappear along with the merge, and the follow-up confirmation returns to a compact single-page layout:
Word output after empty paragraphs and regions have been hidden during the mail merge

Comparing the output with both switches off (the control) and with both switches on, item by item against the template:
| What the template holds | Both switches off | Both switches on |
|---|---|---|
«Optional Note» (a paragraph of its own) |
kept as one blank line | the whole paragraph is removed |
the region OptionalBlock (four paragraphs) |
kept as four blank lines | all four paragraphs are removed |
Note: «Optional Remark» |
keeps Note: |
keeps Note: |
| empty paragraphs already in the template (used for spacing) | kept | kept |
The control document has 46 lines of text in total and the result document has 41; the difference of 5 lines corresponds exactly to the paragraph holding «Optional Note» and the four paragraphs of the region OptionalBlock, while the empty lines the template already used for spacing are left untouched.
Two rules follow from this:
- The test is whether the paragraph still holds content after the merge, not whether the field received a value.
Optional Remarkin the third construct is just as empty, but the paragraph also holds the labelNote:, so the whole paragraph is kept and only the field's position is left blank. To make the label disappear together with the field, the label has to be folded into the field value. HideEmptyParagraphsis the property that actually performs the deletion. SettingHideEmptyGroup = truealone while leavingHideEmptyParagraphs = falseproduces output identical to leaving both switches off; setting onlyHideEmptyParagraphs = trueproduces output identical to turning both on.
Hide Only Empty Paragraphs Without Merge Regions
If a template only needs a line to disappear when it comes out empty and defines no merge regions, setting HideEmptyParagraphs alone is enough:
// When the template has no merge regions, this switch alone is enough
doc.MailMerge.HideEmptyParagraphs = true;
With that setting the paragraphs inside a region are still deleted one by one, so the region disappears as well, with the same result as turning both switches on. Conversely, if the template really does define regions that should be cleared as a whole, setting both properties expresses the intent more clearly.
Read the Region Names and Merge Field Names from a Word Template
The Approach
Reading the region names and the merge field list from a Word template takes three methods on MailMerge, each at a different granularity:
| Method | What it returns |
|---|---|
GetMergeGroupNames() |
every region name in the document |
GetMergeFieldNames({ groupName: "Products" }) |
the merge field names inside the named region |
GetMergeFieldNames() |
every merge field name in the document (including region markers) |
The second method receives the region name inside an object as a named argument. That is how the WebAssembly build handles an overloaded method: methods sharing a name are dispatched by argument count and argument name, so the call has to be written as { groupName: "Products" }; passing a plain string will not match that overload.
Prepare the Template
The sample template is a product and team information list that contains two merge regions, Products and TeamRanking:
«GroupStart:Products»
Owner «Contact Name»
Code «Number»
Category «Type»
Unit Price «Price»
Date «Date»
Vendor «Vendor»
«GroupEnd:Products»
«GroupStart:TeamRanking»
Participant «Name»
Points «Score»
Place «Ranking»
Award «Award»
«GroupEnd:TeamRanking»
Each field occupies a paragraph of its own, and the region markers are paragraphs of their own as well.
The Code
The steps are as follows:
- Get the Spire.Doc WASM module and confirm that it is ready.
- Load the sample template into the virtual file system (VFS), create a
Documentobject and load the file. - Call
GetMergeGroupNames(),GetMergeFieldNames({ groupName: "Products" })andGetMergeFieldNames()in turn to read the three lists. - Join the three lists into text and write it to the VFS with
window.dotnetRuntime.Module.FS.writeFile(). - Read that text file back from the VFS, build a
text/plainBlob and trigger the browser download.
The code for reading the region names and merge field names from a Word template is as follows:
function App() {
const IdentifyMergeFieldName = async () => {
// Get the Spire.Doc WASM module
const docModule = window.wasmModule?.spiredoc;
// Check if the module is ready
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Load the sample template into the virtual file system (VFS)
let inputFileName = "IdentifyMergeFieldNames.docx";
await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}static/data/`);
// Create a document and load the template
let doc = new docModule.Document();
doc.LoadFromFile(inputFileName);
// Read all region names
let GroupNames = doc.MailMerge.GetMergeGroupNames();
// Read the merge field names inside a specific region
let MergeFieldNamesWithinRegion = doc.MailMerge.GetMergeFieldNames({ groupName: "Products" });
// Read every merge field name in the document
let MergeFieldNames = doc.MailMerge.GetMergeFieldNames();
// Join the results into text
let content = [];
content.push("----------------Group Names-----------------------------------------\n");
for (let i = 0; i < GroupNames.length; i++) {
content.push(GroupNames[i] + "\n");
}
content.push("----------------Merge field names within a specific group-----------\n");
for (let j = 0; j < MergeFieldNamesWithinRegion.length; j++) {
content.push(MergeFieldNamesWithinRegion[j] + "\n");
}
content.push("----------------All of the merge field names------------------------\n");
for (let k = 0; k < MergeFieldNames.length; k++) {
content.push(MergeFieldNames[k] + "\n");
}
// Write the TXT file
const outputFileName = "IdentifyMergeFieldName-result.txt";
window.dotnetRuntime.Module.FS.writeFile(outputFileName, content.join("\n"));
// Close the document
doc.Close();
doc.Dispose()
// Read the saved file and convert it to a Blob
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "text/plain"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Read region names and merge field names from a template</h1>
<button onClick={IdentifyMergeFieldName}>Generate</button>
</div>
);
}
export default App;
Compared with the previous example, two things are different:
Execute()is not called. The example only reads the template's structure without merging any data, so the three lists can be read straight after loading.- The output goes through
window.dotnetRuntime.Module.FS.writeFile(fileName, text), writing the string directly into the virtual file system before atext/plainBlob triggers the download. It closes withdoc.Close(), because the example never changes the document's content.
All three methods return a collection (supporting length and indexed access) and are walked in the same way as Sections and Paragraphs.
The Result
Reading the two merge regions and all of the merge field names produces the following result:
Word output after the region names and merge field names have been read from a Word template

The output file is laid out as follows (the ---…--- lines are separators):
----------------Group Names-----------------------------------------
Products
TeamRanking
----------------Merge field names within a specific group-----------
Contact Name
Number
Type
Price
Date
Vendor
----------------All of the merge field names------------------------
Products
Contact Name
Number
Type
Price
Date
Vendor
Products
TeamRanking
Name
Score
Ranking
Award
TeamRanking
The three lists come out as:
Region names (2)
Products, TeamRanking
Field names inside the Products region (6)
Contact Name, Number, Type, Price, Date, Vendor
All field names (14 entries)
Products, Contact Name, Number, Type, Price, Date, Vendor, Products,
TeamRanking, Name, Score, Ranking, Award, TeamRanking
Two points are worth noting:
GetMergeFieldNames()with no argument counts the region markers themselves as field names.GroupStart:Productsis aMERGEFIELDfield in the document, so every region name appears twice: once for the opening marker and once for the closing marker. Among the 14 entries above,ProductsandTeamRankingeach appear twice.- The version that takes
groupNamereturns only the field names inside that region; none of the six results forProductscontains aGroupStart:orGroupEnd:marker.
Why is there an extra blank line between the entries? While the text is assembled,
content.push(name + "\n")already puts a newline at the end of every entry, andcontent.join("\n")adds another one between the elements, so two newlines end up in a row. For a compact list, drop the"\n"from thepushcall:
content.push(GroupNames[i]);
Filter Region Markers Out of the Full Field List
If the full list is really needed, take the region names first and filter them out of the result of GetMergeFieldNames():
const groups = doc.MailMerge.GetMergeGroupNames();
const names = doc.MailMerge.GetMergeFieldNames();
// Collect the region names into a set
const groupSet = {};
for (let i = 0; i < groups.length; i++) groupSet[groups[i]] = true;
// Filter out the region markers, keeping only real merge field names
const realFields = [];
for (let i = 0; i < names.length; i++) {
if (!groupSet[names[i]]) realFields.push(names[i]);
}
If only the field names inside one particular region are needed, the overload that takes groupName is simpler, and its result never contains region markers.
FAQ
The switches for hiding empty lines are on and the blank line is still there
Cause: The property tests whether the paragraph still holds content after the merge. If the paragraph contains fixed text besides the field (for example, Note:), or the field itself is given a value, the whole paragraph is kept.
Solution: Check that the paragraph you want hidden holds nothing but the field. If the label should disappear together with the field, fold it into the field value: pass a complete string such as Note: … when there is data, and an empty string when there is none:
// The paragraph holds a single field; the label is carried by the field value
let fieldNames = ["Contact Name", "Remark Line"];
let fieldValues = ["John Smith", ""];
Why does setting only HideEmptyGroup = true have no effect
Cause: HideEmptyParagraphs is what actually deletes paragraphs. A region disappears as a whole because the paragraphs inside it are deleted one by one; with HideEmptyGroup turned on alone, the output is identical to leaving both switches off.
Solution: Set both properties together. If the template uses no merge regions, setting HideEmptyParagraphs alone produces the same output.
GetMergeFieldNames({ groupName: ... }) returns no data
Cause: The method is overloaded, and the WebAssembly build dispatches by argument name; passing a plain string (such as GetMergeFieldNames("Products")) does not match the overload that takes a region name.
Solution: Pass an object with a key, taking the region name from the name after GroupStart: in the template:
const fields = doc.MailMerge.GetMergeFieldNames({ groupName: "Products" });
Why do region names appear in the result of GetMergeFieldNames()
Cause: The opening and closing markers of a region are also MERGEFIELD fields in the document, so they are counted when the method is called without arguments, and every region name appears twice.
Solution: When only the field names inside one region are needed, use the overload that takes groupName; when the full list is needed, filter it using the result of GetMergeGroupNames() (see “Filter Region Markers Out of the Full Field List” above).
The template defines merge regions but GetMergeGroupNames() returns 0
Cause: The field code of a region marker is written as MERGEFIELD GroupStart:region name, with the field name unquoted. Written as MERGEFIELD "GroupStart:Products", it is usually treated as an ordinary merge field name and does not form a region. In addition, GroupStart and GroupEnd have to appear in pairs with the same region name at both ends; a marker without its counterpart is not recognised as a region.
Solution: Check these two field codes in the template:
{ MERGEFIELD GroupStart:Products \* MERGEFORMAT }
{ MERGEFIELD GroupEnd:Products \* MERGEFORMAT }
Get a Free License
Spire.Doc for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.
Character formatting decides what text looks like on the page — which font it uses, how large it is, what color it is, and whether it is bold, slanted, underlined or struck through. In Word's Open XML structure these properties hang off the run, not the paragraph: every run in a sentence can carry a set of its own. Spire.Doc for JavaScript maps that structure onto the TextRange.CharacterFormat object and reads and writes it directly in the browser via WebAssembly, with no backend server required.
This article covers two core features:
For installation and project setup, refer to Integrating Spire.Doc for JavaScript in a React Project. The examples below assume Spire.Doc is installed and the WebAssembly module is initialized.
Set Character Formatting
After Document.AddSection() creates a section, section.AddParagraph() gives you a paragraph and paragraph.AppendText() appends text to it — the return value of AppendText is a TextRange object, and once you hold it you can set CharacterFormat on it directly. To break the line without starting a new paragraph, call paragraph.AppendBreak(wasmModule.BreakType.LineBreak).
The sample below walks through the common character formats one by one. Each line of text changes exactly one property, so the effect can be read straight off the result. One line is the exception: the font-name example deliberately mixes a Chinese word into its text, because only East Asian characters are read from the Far-East font slot that the line sets.
function App() {
const CharacterFormatting = async () => {
const docModule = window.wasmModule?.spiredoc;
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Create the document and the section
let doc = new docModule.Document();
let sec = doc.AddSection();
// The title paragraph takes the built-in "Title" style
let titleParagraph = sec.AddParagraph();
titleParagraph.AppendText("Character Formatting Overview");
titleParagraph.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Title });
let paragraph = sec.AddParagraph();
// Strikeout
let tr = paragraph.AppendText("Strikethough Text");
tr.CharacterFormat.IsStrikeout = true;
// Shadow
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Shadow Text");
tr.CharacterFormat.IsShadow = true;
// Small caps
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Small caps Text");
tr.CharacterFormat.IsSmallCaps = true;
// Double strikeout
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Double Strikethough Text");
tr.CharacterFormat.DoubleStrike = true;
// Outline
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Outline Text");
tr.CharacterFormat.IsOutLine = true;
// All caps
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("AllCaps Text");
tr.CharacterFormat.AllCaps = true;
// Subscript and superscript
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Text");
tr = paragraph.AppendText("SubScript");
tr.CharacterFormat.SubSuperScript = docModule.SubSuperScript.SubScript;
tr = paragraph.AppendText("And");
tr = paragraph.AppendText("SuperScript");
tr.CharacterFormat.SubSuperScript = docModule.SubSuperScript.SuperScript;
// Emboss, with a white text color so the effect shows up better
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Emboss Text");
tr.CharacterFormat.Emboss = true;
tr.CharacterFormat.TextColor = docModule.Color.get_White();
// Hidden text
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Hidden:");
tr = paragraph.AppendText("Hidden Text");
tr.CharacterFormat.Hidden = true;
// Engrave
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Engrave Text");
tr.CharacterFormat.Engrave = true;
tr.CharacterFormat.TextColor = docModule.Color.get_White();
// The Western, non-Far-East and Far-East font names are set separately
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("WesternFonts中文字体");
tr.CharacterFormat.FontNameAscii = "Calibri";
tr.CharacterFormat.FontNameNonFarEast = "Calibri";
tr.CharacterFormat.FontNameFarEast = "Simsun";
// Font size
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Font Size");
tr.CharacterFormat.FontSize = 20;
// Font color
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Font Color");
tr.CharacterFormat.TextColor = docModule.Color.get_Red();
// Bold + italic
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Bold Italic Text");
tr.CharacterFormat.Bold = true;
tr.CharacterFormat.Italic = true;
// Underline
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Underline Style");
tr.CharacterFormat.UnderlineStyle = docModule.UnderlineStyle.Single;
// Highlight (background)
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Highlight Text");
tr.CharacterFormat.HighlightColor = docModule.Color.get_Yellow();
// Character shading
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Text has shading");
tr.CharacterFormat.TextBackgroundColor = docModule.Color.get_Green();
// Border around the characters
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Border Around Text");
tr.CharacterFormat.Border.BorderType = docModule.BorderStyle.Single;
// Character scaling at 150%
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Text Scale");
tr.CharacterFormat.TextScale = 150;
// Character spacing of 2 points
paragraph.AppendBreak(docModule.BreakType.LineBreak);
tr = paragraph.AppendText("Character Spacing is 2 point");
tr.CharacterFormat.CharacterSpacing = 2;
// Define the output file name
const outputFileName = "CharacterFormatting-result.docx";
// Save the document to the given path
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
// Release resources
doc.Dispose();
// Read the saved file back and wrap it as a Blob
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Set Various Character Formats</h1>
<button onClick={CharacterFormatting}>Generate</button>
</div>
);
}
export default App;
Every line of the generated document shows the matching character effect, and can be compared with the code line by line

Apply Several Character Formats in One Paragraph
One paragraph can hold several TextRange objects, and each of them has its own CharacterFormat. That means layouts such as "the first half in bold blue, the second half back at a regular size" do not need to be split into two paragraphs — just call AppendText again; the format only applies to the text appended by that one call.
function App() {
const MultiStylesInAParagraph = async () => {
const docModule = window.wasmModule?.spiredoc;
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Create the document and the section
let doc = new docModule.Document();
let section = doc.AddSection();
// Add a new paragraph
let para = section.AddParagraph();
// The first TextRange: Calibri 16 pt, blue, bold, single underline
let range = para.AppendText("Quarterly Delivery Note ");
range.CharacterFormat.FontName = "Calibri";
range.CharacterFormat.FontSize = 16;
range.CharacterFormat.TextColor = docModule.Color.get_Blue();
range.CharacterFormat.Bold = true;
range.CharacterFormat.UnderlineStyle = docModule.UnderlineStyle.Single;
// The second TextRange: still the same paragraph, now Calibri 15 pt regular
range = para.AppendText("This delivery covers three business areas, and acceptance is based on the result of the online trial run");
range.CharacterFormat.FontName = "Calibri";
range.CharacterFormat.FontSize = 15;
// Define the output file name
const outputFileName = "MultiStylesInAParagraph-result.docx";
// Save the document to the given path
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
// Release resources
doc.Dispose();
// Read the saved file back and wrap it as a Blob
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Apply Multiple Character Formats in One Paragraph</h1>
<button onClick={MultiStylesInAParagraph}>Generate</button>
</div>
);
}
export default App;
The first half of the paragraph is bold, blue and underlined; the second half is back to a regular weight

FAQ
FontName is set, but the Chinese text does not change
Cause: CharacterFormat.FontName only writes to the Western (ascii/hAnsi) font slots, while Chinese characters are taken from the separate "East Asian" slot. The two are stored independently, so setting one leaves the other exactly as it was.
Solution: Set the font used by each script separately — FontNameAscii / FontNameNonFarEast for the Western text, FontNameFarEast for the Chinese:
tr.CharacterFormat.FontNameAscii = "Calibri";
tr.CharacterFormat.FontNameNonFarEast = "Calibri";
tr.CharacterFormat.FontNameFarEast = "Simsun";
Emboss and Engrave show no visible effect
Cause: Both effects rely on a light-and-shadow contrast, and the default black text on a white background has almost no visual difference to work with.
Solution: Set the text color to white, so that the characters are shaped by their highlight and shadow alone:
tr.CharacterFormat.Emboss = true;
tr.CharacterFormat.TextColor = wasmModule.Color.get_White();
The downloaded Word document cannot be opened, or is reported as corrupted
Cause: The MIME type was wrong when the Blob was created, so the browser cannot identify the file format correctly.
Solution: Make sure the exact docx MIME type is used:
const modifiedFile = new Blob([modifiedFileArray], {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"
});
Get a Free License
Spire.Doc for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.
Set Paragraph and List Formatting in Word with JavaScript in React
2026-10-16 07:59:18 Written by Amy ZhaoThe paragraph is the basic unit of layout in Word, and every paragraph in a document carries two formatting objects. Paragraph.Format controls how the paragraph itself is laid out — its border, alignment, shading, indentation, spacing before and after, and line spacing — while Paragraph.ListFormat controls the numbering or bullet it shows as a list item. The two are independent, so a single paragraph can be centred and indented and still be the third level of a numbered list. Spire.Doc for JavaScript reads and writes both objects directly in the browser via WebAssembly, with no backend server required.
This article covers two core features:
For installation and project setup, refer to Integrating Spire.Doc for JavaScript in a React Project. The examples below assume Spire.Doc is installed and the WebAssembly module is initialized.
Set Paragraph Formatting
Paragraph formatting is set through paragraph.Format. The sample below walks through a paragraph border, the five horizontal alignments, shading, three kinds of indentation and the spacing rules, one at a time:
function App() {
const ParagraphFormatting = async () => {
const docModule = window.wasmModule?.spiredoc;
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Create the document and the section
let doc = new docModule.Document();
let sec = doc.AddSection();
// Title
let para = sec.AddParagraph();
para.AppendText("Paragraph Formatting Overview");
para.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Title });
// Paragraph border
para = sec.AddParagraph();
para.AppendText("This paragraph has a red border.");
para.Format.Borders.BorderType = docModule.BorderStyle.Single;
para.Format.Borders.Color = docModule.Color.get_Red();
// The five horizontal alignments: left, center, right, justify, distribute
para = sec.AddParagraph();
para.AppendText("This paragraph is left-aligned.");
para.Format.HorizontalAlignment = docModule.HorizontalAlignment.Left;
para = sec.AddParagraph();
para.AppendText("This paragraph is center-aligned.");
para.Format.HorizontalAlignment = docModule.HorizontalAlignment.Center;
para = sec.AddParagraph();
para.AppendText("This paragraph is right-aligned.");
para.Format.HorizontalAlignment = docModule.HorizontalAlignment.Right;
para = sec.AddParagraph();
para.AppendText("This paragraph is justified.");
para.Format.HorizontalAlignment = docModule.HorizontalAlignment.Justify;
para = sec.AddParagraph();
para.AppendText("This paragraph is distributed.");
para.Format.HorizontalAlignment = docModule.HorizontalAlignment.Distribute;
// Paragraph shading
para = sec.AddParagraph();
para.AppendText("This paragraph has gray shading.");
para.Format.BackColor = docModule.Color.get_Gray();
// 10 pt left indent, 10 pt right indent, 15 pt first-line indent
para = sec.AddParagraph();
para.AppendText("This paragraph has a 10 pt left indent, a 10 pt right indent and a 15 pt first-line indent.");
para.Format.SetLeftIndent(10);
para.Format.SetRightIndent(10);
para.Format.SetFirstLineIndent(15);
// A negative value passed to the first-line indent is a hanging indent
para = sec.AddParagraph();
para.AppendText("This paragraph has a hanging indent of 15 pt.");
para.Format.SetFirstLineIndent(-15);
// 10 pt before, 20 pt after, line spacing of at least 10 pt
para = sec.AddParagraph();
para.AppendText("This paragraph has 10 pt before, 20 pt after and a line spacing of at least 10 pt.");
para.Format.AfterSpacing = 20;
para.Format.BeforeSpacing = 10;
para.Format.LineSpacingRule = docModule.LineSpacingRule.AtLeast;
para.Format.LineSpacing = 10;
// Define the output file name
const outputFileName = "ParagraphFormatting-result.docx";
// Save the document to the given path
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
// Release resources
doc.Dispose();
// Read the saved file back and wrap it as a Blob
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Set Various Paragraph Formats</h1>
<button onClick={ParagraphFormatting}>Generate</button>
</div>
);
}
export default App;
Each paragraph in the generated document shows its border, alignment, indentation and spacing in turn

Create and Apply List Styles
A list style is one kind of document style, created with document.Styles.Add({ listType, name }). listType is ListType.Numbered or ListType.Bulleted, and name is the custom style name you choose.
A new style also needs the numbering rule for each level. Those settings live in ListRef.Levels and are indexed from 0:
NumberPrefix— the prefix template for the number, where%1stands for the first level's counter,%2for the second, and so on;PatternType— how the number is rendered, e.g.ListPatternType.Arabicfor Arabic numerals;- on the paragraph side,
ListFormat.ApplyStyle(styleName)attaches the list style, andListFormat.ListLevelNumbersets which level the paragraph sits at (0 is the first level).
function App() {
const Lists = async () => {
const docModule = window.wasmModule?.spiredoc;
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Create the document and the section
const doc = new docModule.Document();
let sec = doc.AddSection();
// The title and the group headings
let paragraph = sec.AddParagraph();
paragraph.AppendText("List Styles Overview");
paragraph.ApplyStyle({ builtinStyle: docModule.BuiltinStyle.Title });
paragraph = sec.AddParagraph();
paragraph.AppendText("Numbered list:").CharacterFormat.Bold = true;
// Create the numbered list style and configure the numbering for levels 1 and 2
let numberList = doc.Styles.Add({ listType: docModule.ListType.Numbered, name: "numberList" });
numberList.ListRef.Levels.get_Item(1).NumberPrefix = "%1.";
numberList.ListRef.Levels.get_Item(1).PatternType = docModule.ListPatternType.Arabic;
numberList.ListRef.Levels.get_Item(2).NumberPrefix = "%1.%2.";
numberList.ListRef.Levels.get_Item(2).PatternType = docModule.ListPatternType.Arabic;
// Create the bulleted list style
let bulletList = doc.Styles.Add({ listType: docModule.ListType.Bulleted, name: "bulletList" });
// Add both list styles to the document's style collection
doc.Styles.Add(numberList);
doc.Styles.Add(bulletList);
// Apply the numbered list style paragraph by paragraph, setting the level with ListLevelNumber
paragraph = sec.AddParagraph();
paragraph.AppendText("Overview");
paragraph.ListFormat.ApplyStyle(numberList.Name);
paragraph = sec.AddParagraph();
paragraph.AppendText("Delivery Scope");
paragraph.ListFormat.ApplyStyle(numberList.Name);
paragraph = sec.AddParagraph();
paragraph.AppendText("Three Business Areas");
paragraph.ListFormat.ApplyStyle(numberList.Name);
paragraph.ListFormat.ListLevelNumber = 1;
paragraph = sec.AddParagraph();
paragraph.AppendText("Data Ingestion");
paragraph.ListFormat.ApplyStyle(numberList.Name);
paragraph.ListFormat.ListLevelNumber = 1;
paragraph = sec.AddParagraph();
paragraph.AppendText("API Integration");
paragraph.ListFormat.ApplyStyle(numberList.Name);
paragraph.ListFormat.ListLevelNumber = 2;
paragraph = sec.AddParagraph();
paragraph.AppendText("Field Verification");
paragraph.ListFormat.ApplyStyle(numberList.Name);
paragraph.ListFormat.ListLevelNumber = 2;
paragraph = sec.AddParagraph();
paragraph.AppendText("Canary Release");
paragraph.ListFormat.ApplyStyle(numberList.Name);
paragraph.ListFormat.ListLevelNumber = 2;
paragraph = sec.AddParagraph();
paragraph.AppendText("Acceptance Criteria");
paragraph.ListFormat.ApplyStyle(numberList.Name);
paragraph.ListFormat.ListLevelNumber = 1;
paragraph = sec.AddParagraph();
paragraph.AppendText("Follow-up Plan");
paragraph.ListFormat.ApplyStyle(numberList.Name);
// The bulleted list
paragraph = sec.AddParagraph();
paragraph.AppendText("Bulleted list:").CharacterFormat.Bold = true;
paragraph = sec.AddParagraph();
paragraph.AppendText("Clause One");
paragraph.ListFormat.ApplyStyle(bulletList.Name);
paragraph = sec.AddParagraph();
paragraph.AppendText("Clause Two");
paragraph.ListFormat.ApplyStyle(bulletList.Name);
paragraph = sec.AddParagraph();
paragraph.AppendText("Supplementary Notes");
paragraph.ListFormat.ApplyStyle(bulletList.Name);
paragraph.ListFormat.ListLevelNumber = 1;
paragraph = sec.AddParagraph();
paragraph.AppendText("Exceptions");
paragraph.ListFormat.ApplyStyle(bulletList.Name);
paragraph.ListFormat.ListLevelNumber = 1;
paragraph = sec.AddParagraph();
paragraph.AppendText("Clause Three");
paragraph.ListFormat.ApplyStyle(bulletList.Name);
// Define the output file name
const outputFileName = "Lists-result.docx";
// Save the document to the given path
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
doc.Dispose();
// Read the saved file back and wrap it as a Blob
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Create and Apply List Styles</h1>
<button onClick={Lists}>Generate</button>
</div>
);
}
export default App;
The numbered list generates 1. / 1.1. style counters automatically, while the bulleted list indents by level

FAQ
The hanging indent does not take effect
Cause: A negative value passed to SetFirstLineIndent means a hanging indent, but if the paragraph has no left indent of its own there is no room for the hanging part to pull back into, so the effect is almost invisible.
Solution: Give the paragraph a left indent first, then use a negative first-line indent for the hanging effect:
para.Format.SetLeftIndent(20);
para.Format.SetFirstLineIndent(-15);
The list numbers do not appear at the expected level
Cause: ListLevelNumber defaults to 0 (the first level) when it is not set, so a child paragraph that does not set its level explicitly is numbered alongside its parent.
Solution: Set the level explicitly on every paragraph that needs one, keeping in mind that it is also counted from 0:
paragraph.ListFormat.ApplyStyle(numberList.Name);
paragraph.ListFormat.ListLevelNumber = 1; // Second level, numbered 1.1.
A paragraph has both shading and a border, but only one of them shows
Cause: Format.BackColor is paragraph shading and Format.Borders is the paragraph border. Both are written under the same paragraph property node, so a misspelled property name is silently ignored.
Solution: Check which property belongs where — shading goes on BackColor, the border on Borders.BorderType:
para.Format.BackColor = docModule.Color.get_Gray();
para.Format.Borders.BorderType = docModule.BorderStyle.Single;
para.Format.Borders.Color = docModule.Color.get_Red();
Get a Free License
Spire.Doc for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.
Insert Content based on Word Bookmarks with JavaScript in React
2026-10-16 07:58:52 Written by Amy ZhaoA bookmark is a named anchor that marks a specific position or a range of content in a Word document. Writing content into a bookmark lets business data land at the designated place in a template automatically, without the caller having to know how the document is actually laid out. Spire.Doc for JavaScript performs this entirely in the browser via WebAssembly, using a virtual file system (VFS) to manage fonts, documents and image resources — no backend server required.
This article covers two core features:
For installation and project setup, refer to Integrating Spire.Doc for JavaScript in a React Project. The examples below assume Spire.Doc is installed and the WebAssembly module is initialized.
Insert Document Content at a Bookmark
Inserting document content into a bookmark runs in three stages: first, instantiate two Document objects and load a file into each, one for the document to be filled and one that provides the content; then locate the bookmark by name with BookmarksNavigator.MoveToBookmark, take the bookmark's start marker through CurrentBookmark.BookmarkStart, work out the index of the paragraph holding that bookmark within the section through its OwnerParagraph and the section's IndexOf, and insert every paragraph of the source document after that index (each one Clone()d first); last, save the document, read the generated file from VFS, wrap it as a Blob and trigger a browser download.
function App() {
const InsertDocAtBookmark = async () => {
const docModule = window.wasmModule?.spiredoc;
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Load the target document to be filled into VFS
let inputFileName_1 = "InsertDocAtBookmark1.docx";
await window.spire.FetchFileToVFS(inputFileName_1, "", `${process.env.PUBLIC_URL}/static/data/`);
// Load the source document that provides the content into VFS
let inputFileName_2 = "InsertDocAtBookmark2.docx";
await window.spire.FetchFileToVFS(inputFileName_2, "", `${process.env.PUBLIC_URL}/static/data/`);
// Load the target document
let document1 = new docModule.Document();
document1.LoadFromFile(inputFileName_1);
// Load the source document
let document2 = new docModule.Document();
document2.LoadFromFile(inputFileName_2);
// Get the first section of the target document
let section1 = document1.Sections.get_Item();
// Create a bookmark navigator to locate the bookmark
let bn = new docModule.BookmarksNavigator(document1);
// Find the bookmark by name
bn.MoveToBookmark("Test", true, true);
// Get the bookmark's start marker
let start = bn.CurrentBookmark.BookmarkStart;
// Get the paragraph that holds the start marker
let para = start.OwnerParagraph;
// Get the index of that paragraph in the section
let index = section1.Body.ChildObjects.IndexOf(para);
// Insert the paragraphs of the source document one by one after the paragraph holding the bookmark
for (let i = 0; i < document2.Sections.Count; i++) {
let section2 = document2.Sections.get_Item(i);
for (let j = section2.Paragraphs.Count-1; j >=0; j--) {
let paragraph = section2.Paragraphs.get_Item(j);
section1.Body.ChildObjects.Insert(index + 1, paragraph.Clone());
}
}
// Define the output file name
const outputFileName = "InsertDocAtBookmark-result.docx";
// Save the document
document1.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
document1.Dispose();
document2.Dispose();
// Read the saved file and convert it to a Blob
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Insert a document at a Word bookmark</h1>
<button onClick={InsertDocAtBookmark}>Generate</button>
</div>
);
}
export default App;
Word output after the paragraphs of the source document have been inserted at the bookmark

Insert an Image at a Bookmark
A bookmark marks a position in the document, so it can carry non-text content just as well — inserting a seal image at a signature bookmark in a contract template, for example. The core process runs in three stages: first, instantiate a Document and load the file; then locate the bookmark with a BookmarksNavigator, create a temporary section with AddSection, add a paragraph inside that temporary section and append the image to it with AppendPicture, insert the whole paragraph at the bookmark with InsertParagraph, and finally remove the temporary section; last, save the document, read the generated file from VFS, wrap it as a Blob and trigger a browser download.
function App() {
const InsertImageAtBookmark = async () => {
const docModule = window.wasmModule?.spiredoc;
if (!docModule) {
alert('Spire.Doc is not ready yet');
return;
}
// Load the target document into VFS
let inputFileName = "InsertImageAtBookmark.docx";
await window.spire.FetchFileToVFS(inputFileName, "", `${process.env.PUBLIC_URL}/static/data/`);
// Load the image to be inserted into VFS
let imageFileName = "Word.png";
await window.spire.FetchFileToVFS(imageFileName, "", `${process.env.PUBLIC_URL}/static/data/`);
// Load the document
let doc = new docModule.Document();
doc.LoadFromFile(inputFileName);
// Create a bookmark navigator to locate the bookmark
let bn = new docModule.BookmarksNavigator(doc);
// Find the bookmark by name
bn.MoveToBookmark("Test", true, true);
// Add a temporary section to hold the image paragraph
let section0 = doc.AddSection();
// Add a paragraph in the temporary section
let paragraph = section0.AddParagraph();
// Append the image to the paragraph
paragraph.AppendPicture({ imgFile: imageFileName });
// Insert that paragraph at the bookmark
bn.InsertParagraph(paragraph);
// Remove the temporary section
doc.Sections.Remove(section0);
// Define the output file name
const outputFileName = "InsertImageAtBookmark-result.docx";
// Save the document
doc.SaveToFile({ fileName: outputFileName, fileFormat: docModule.FileFormat.Docx2013 });
doc.Dispose();
// Read the saved file and convert it to a Blob
const modifiedFileArray = window.dotnetRuntime.Module.FS.readFile(outputFileName);
const blob = new Blob([modifiedFileArray], {type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document"});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = outputFileName;
a.click();
URL.revokeObjectURL(url);
};
return (
<div style={{ textAlign: 'center', height: '300px' }}>
<h1>Insert an image at a Word bookmark</h1>
<button onClick={InsertImageAtBookmark}>Generate</button>
</div>
);
}
export default App;
Word output after the image has been inserted at the bookmark

FAQ
The bookmark cannot be found and MoveToBookmark fails to locate it
Cause: MoveToBookmark locates a bookmark by its name. The name is case-sensitive and has to match the bookmark name in the document exactly. If the spelling does not match, or the target document has no bookmark with that name at all, the lookup fails.
Solution: Before locating, walk the Bookmarks collection to confirm the bookmark names the document actually carries, then pass the exact name:
// Walk all the bookmarks in the document to check whether the name matches
for (let i = 0; i < doc.Bookmarks.Count; i++) {
console.log(doc.Bookmarks.get_Item(i).Name);
}
// Once the bookmark is confirmed to exist, locate it by its exact name
let bn = new wasmModule.BookmarksNavigator(doc);
bn.MoveToBookmark("Test", true, true);
The inserted image is larger than the page
Cause: AppendPicture inserts the image at its original pixel size and does not fit it to the page width. If the image resolution is far above the available page width, it overflows the page or stretches the original layout once inserted.
Solution: AppendPicture returns the picture object, so it can be scaled proportionally to a maximum width before it is inserted at the bookmark (Width and Height are measured in points):
// Append the image and keep the returned picture object
let paragraph = section0.AddParagraph();
let picture = paragraph.AppendPicture({ imgFile: imageFileName });
// Scale proportionally to the maximum width
let maxWidth = 400;
if (picture.Width > maxWidth) {
picture.Height = picture.Height * (maxWidth / picture.Width);
picture.Width = maxWidth;
}
Get a Free License
Spire.Doc for JavaScript offers a 30-day full-featured free trial license with no functional limitations. Apply here to evaluate before purchasing.