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:

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:

  1. Take the first text box with sheet.TextBoxes.get(0).
  2. Create a font object with workbook.CreateFont() and set its name, size, bold weight and color through FontName, Size, IsBold and Color.
  3. Get the rich text object with xlsModule.RichTextShape.Convert(textBox.RichText), then call SetFont(0, textBox.Text.length - 1, font) to apply the font to the whole text.
  4. Set textBox.Fill.FillType to xlsModule.ShapeFillType.SolidColor, then name the fill color with textBox.Fill.ForeColor.
  5. Take the fourth text box with sheet.TextBoxes.get(3) and set textBox.Line.Weight to 0 to remove its border.
  6. 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:

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:

  1. Take the second text box with sheet.TextBoxes.get(1).
  2. Set InnerLeftMargin, InnerRightMargin, InnerTopMargin and InnerBottomMargin in turn to leave room on all four sides.
  3. Set HAlignment to xlsModule.CommentHAlignType.Left and VAlignment to xlsModule.CommentVAlignType.Top, so the text starts from the top-left corner.
  4. Take the third text box with sheet.TextBoxes.get(2) and set IsWrapText to true to turn on text wrapping.
  5. 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:

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:

  1. Load the font file and the test data file into the VFS with window.spire.FetchFileToVFS.
  2. Create a workbook with new Workbook(), load the input file with workbook.LoadFromFile, and take the first worksheet with workbook.Worksheets.get(0).
  3. Take the source column's cell and the target column's cell row by row with sheet.Range.get.
  4. Assign the source cell's Style to the target cell's Style; the whole cell format follows, and the target cell's value is left untouched.
  5. Save the workbook with workbook.SaveToFile and dispose of it with workbook.Dispose to 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:

The whole cell format copied


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:

  1. Load the font file and the test data file into the VFS with window.spire.FetchFileToVFS.
  2. Create a workbook with new Workbook(), load the input file with workbook.LoadFromFile, and take the first worksheet with workbook.Worksheets.get(0).
  3. Create a named style with workbook.Styles.Add and set its font name, size, bold, text colour, fill colour, fill pattern and horizontal alignment.
  4. Apply the style to the header range by name with sheet.Range.get('A1:E1').CellStyleName.
  5. Clone it with the style's clone(), change only the text colour and the fill colour, and get the variant the total line needs.
  6. Apply the variant to the total line with sheet.Range.get('A8:E8').CellStyleName.
  7. Save the workbook with workbook.SaveToFile and dispose of it with workbook.Dispose to 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:

A named style cloned and reused


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:

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:

  1. Load the font and the test data file into the VFS with window.spire.FetchFileToVFS.
  2. Create a workbook with new Workbook(), then load the input file with workbook.LoadFromFile.
  3. Save the workbook with workbook.SaveToFile, naming xlsModule.FileFormat.Json in fileFormat.
  4. Dispose of the workbook with workbook.Dispose to 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:

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:

  1. Load the font and the test data file into the VFS with window.spire.FetchFileToVFS.
  2. Create the source workbook with new Workbook() and load the input file with workbook.LoadFromFile, then take the worksheet to export with Worksheets.get(2).
  3. Create a second workbook with new Workbook() and clear the three blank worksheets it starts with using Worksheets.Clear().
  4. Copy the target worksheet in with Worksheets.AddCopy({ sheet }), so that it is the only one left.
  5. Save the new workbook with SaveToFile, naming xlsModule.FileFormat.Json in fileFormat.
  6. 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:

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:

  1. Load the font and the test data file into the VFS with window.spire.FetchFileToVFS.
  2. Create the source workbook with new Workbook() and load the input file with workbook.LoadFromFile, then take the source worksheet with Worksheets.get(0).
  3. Mark out the cell range to export with sourceSheet.Range.get('A1:D6').
  4. Create a target workbook and clear its default worksheets with Worksheets.Clear(), then add a worksheet to receive the range with Worksheets.Add('RangeData').
  5. Take a destination range of the same size with targetSheet.Range.get('A1:D6') and copy the values and styles across with sourceSheet.Copy({ sourceRange, destRange, copyStyle: true }).
  6. Save the new workbook with SaveToFile, naming xlsModule.FileFormat.Json in fileFormat, 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:

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:

  1. Load the font and the test data file into the VFS with window.spire.FetchFileToVFS, load the workbook with new Workbook() and workbook.LoadFromFile, and take the customers worksheet with Worksheets.get(1).
  2. List the fields to keep in an array, each entry naming its column header, its output field name and how its value is read.
  3. Read the header row with sheet.get(1, column) and, using sheet.LastColumn, map each column header to its column number.
  4. Walk the rows from row 2 to sheet.LastRow, reading each value with cell.Text, cell.NumberValue, cell.BooleanValue or cell.DateTimeValue according to its field, and recording a blank cell as null.
  5. Build a yyyy-MM-dd date from the year, month and day of the Date object.
  6. Serialise the records with JSON.stringify and a two-space indent, then write the text into the VFS with FS.writeFile.
  7. Dispose of the workbook with workbook.Dispose to 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:

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.

When 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:

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:

  1. Create a Word document and lay out the fixed content.
  2. Place the cursor where the variable content should go and press Ctrl + F9 to insert a pair of field markers.
  3. Enter the field code inside the markers, for example MERGEFIELD "Contact Name" \* MERGEFORMAT; enclose the field name in double quotes when it contains spaces.
  4. Press F9 to update the field. The position then displays placeholder text in the form «Contact Name».
  5. 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:

  1. Get the Spire.Doc WASM module and confirm that it is ready.
  2. Load the mail merge template and the font into the virtual file system (VFS).
  3. Create a Document object and load the template.
  4. Prepare the field name array and the field value array so that their order corresponds one to one.
  5. Call the Document.MailMerge.Execute() method to execute the mail merge.
  6. 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

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:

  1. Get the Spire.Doc WASM module and confirm that it is ready.
  2. Load the mail merge template and the font into the virtual file system (VFS).
  3. Use Intl.DateTimeFormat("de-DE", formatOptions) to format the current time as a German-style date string.
  4. Replace the value of the Date field with this string, and keep the other field values unchanged.
  5. Call the Document.MailMerge.Execute() method to execute the mail merge.
  6. 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

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:

  1. Get the Spire.PDF WASM module and make sure it is ready.
  2. Read the SVG file into the virtual file system (VFS).
  3. Create a PdfDocument object and load the SVG with the LoadFromSvg() method.
  4. Use the PdfDocument.SaveToFile() method to save the result file.
  5. 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:

The single-page PDF converted from the SVG bar chart


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:

  1. Get the Spire.PDF WASM module and make sure it is ready.
  2. Read the existing PDF and the SVG logo into the virtual file system (VFS).
  3. Create a PdfDocument object for each and load the existing PDF and the SVG.
  4. Use the CreateTemplate() method to turn the SVG's first page into a template, then draw it onto the target page with the Canvas.DrawTemplate() method, at a given position and size.
  5. 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:

The SVG logo inserted at the top right of page 1 of the existing PDF


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.

The 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:

  1. Get the Spire.Doc WASM module and confirm that it is ready.
  2. Load the sample template into the virtual file system (VFS), create a Document object and load the file.
  3. Prepare the field name and field value arrays: the field name is XX_Name and the field value is jason tang.
  4. Call Document.MailMerge.Execute() to run the mail merge.
  5. Save the result with Document.SaveToFile().
  6. 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

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 Mr is that pair of quotes. The argument of the first is Mr with a trailing space, which outputs Mr jason tang; the argument of the second is Mr with no space, so it sits flush against the field value and becomes Mrjason tang. To leave a gap between the added text and the field value, write the space inside the quotes.
  • \b is a prefix and \f is a suffix, and both can appear in the same field.
  • \* FirstCap capitalises only the first character (Jason tang), while \* Caps capitalises the first letter of every word (Jason Tang). The two names are similar but the effects differ.
  • \* MERGEFORMAT does 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:

  1. an IfField, whose field type is set to FieldType.FieldIf;
  2. a merge field node (FieldType.FieldMergeField) — the value being compared in the condition;
  3. the operator and the two branch texts, appended as ordinary text fragments;
  4. a FieldMark of type FieldMarkType.FieldEnd, which closes the field off and has to be assigned back to IfField.End for 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:

  1. Get the Spire.Doc WASM module and confirm that it is ready.
  2. Load the ARIALUNI.TTF font into the virtual file system (VFS).
  3. Create a Document object, add a section and then add the first paragraph.
  4. 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.
  5. Create a FieldEnd mark with CreateParagraphItem(), append it to the paragraph and assign it to IfField.End.
  6. Build the second conditional field in the same way.
  7. Call MailMerge.Execute() to supply values for the merge fields inside the conditional fields, and set IsUpdateFields = true.
  8. 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:

  1. ifField.Code = "IF " only marks the field type. What actually assembles the body of the field is the AppendField and the AppendText calls that follow; they are laid out into this field one after another.
  2. 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.
  3. The order is fixed: compared value → operator → comparison basis → true branch → false branch. The false branch needs no extra space after its closing quote.
  4. FieldEnd is the field's closing mark. It has to be created with CreateParagraphItem(ParagraphItemType.FieldMark), given its end.Type, appended to the paragraph and then assigned to ifField.End. Skip any one of those steps and the field is incomplete.
  5. IsUpdateFields = true decides whether the field is evaluated when the document is saved; it belongs after Execute and before SaveToFile.

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.

Mail 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:

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:

  1. Get the Spire.Doc WASM module and confirm that it is ready.
  2. Load the sample template into the virtual file system (VFS), create a Document object and load the file.
  3. Before MailMerge.Execute(), set HideEmptyParagraphs and HideEmptyGroup to true.
  4. Call MailMerge.Execute() with the field names and their values to run the mail merge.
  5. 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

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 Remark in the third construct is just as empty, but the paragraph also holds the label Note:, 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.
  • HideEmptyParagraphs is the property that actually performs the deletion. Setting HideEmptyGroup = true alone while leaving HideEmptyParagraphs = false produces output identical to leaving both switches off; setting only HideEmptyParagraphs = true produces 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:

  1. Get the Spire.Doc WASM module and confirm that it is ready.
  2. Load the sample template into the virtual file system (VFS), create a Document object and load the file.
  3. Call GetMergeGroupNames(), GetMergeFieldNames({ groupName: "Products" }) and GetMergeFieldNames() in turn to read the three lists.
  4. Join the three lists into text and write it to the VFS with window.dotnetRuntime.Module.FS.writeFile().
  5. Read that text file back from the VFS, build a text/plain Blob 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 a text/plain Blob triggers the download. It closes with doc.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

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:Products is a MERGEFIELD field 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, Products and TeamRanking each appear twice.
  • The version that takes groupName returns only the field names inside that region; none of the six results for Products contains a GroupStart: or GroupEnd: 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, and content.join("\n") adds another one between the elements, so two newlines end up in a row. For a compact list, drop the "\n" from the push call:

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

The result after setting various character formats


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

The result of mixing character formats inside one paragraph


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.

The 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

The result after setting various paragraph formats


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 %1 stands for the first level's counter, %2 for the second, and so on;
  • PatternType — how the number is rendered, e.g. ListPatternType.Arabic for Arabic numerals;
  • on the paragraph side, ListFormat.ApplyStyle(styleName) attaches the list style, and ListFormat.ListLevelNumber sets 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

The result after creating and applying list styles


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.

A 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

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

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.

Page 2 of 19
page 2