#3079·exceljs

xlsx.load throws "Cannot create property 'richText' on string" when a shared string mixes a bare <t> with <r> runs

Author: IvesPeiCreated Sep 8, 2026Updated Sep 13, 2026

Bug

workbook.xlsx.load() throws when xl/sharedStrings.xml contains an <si> that holds both a bare <t> and one or more rich-text <r> runs:

xml
<si><t></t><r><rPr><u val="single"/><color theme="10"/></rPr><t>link text</t></r></si>
TypeError: Cannot create property 'richText' on string ''
    at SharedStringXform.parseClose (lib/xlsx/xform/strings/shared-string-xform.js:78)
    at SharedStringsXform.parseClose (lib/xlsx/xform/strings/shared-strings-xform.js:111)
    at XLSX.load (lib/xlsx/xlsx.js:337)

With a non-empty leading <t> the message becomes ... on string 'xy'. The same shape on an inline string (<c t="inlineStr"><is><t>xy</t><r>…</r></is></c>) throws from lib/xlsx/xform/sheet/cell-xform.js for the same reason.

This shape is valid per ECMA-376 — CT_Rst is the sequence t? r* rPh* phoneticPr?, so a <t> followed by <r> elements is allowed. A real-world workbook exported from Lark/Feishu carried 671 such strings (one per hyperlink cell, each with an empty <t/> ahead of the styled run). openpyxl 3.1.5 reads the same file without complaint.

Root cause

lib/xlsx/xform/strings/shared-string-xform.js, parseClose (line numbers from current master):

javascript
case 'r': {
  let rt = this.model.richText;
  if (!rt) {
    rt = this.model.richText = [];      // :78 — throws when this.model is a string
  }
  …
case 't':
  this.model = this.parser.model;       // :84 — replaces the model with a primitive string

Closing the bare <t> assigns a primitive string to this.model; closing the following <r> then tries to write richText onto that string.

cell-xform.js has the same pattern for inline strings: this.model.value = this.model.value || {} only rescues an empty string, so a non-empty leading <t> still throws.

Minimal repro

javascript
const JSZip = require('jszip'); const ExcelJS = require('exceljs');
const H='<?xml version="1.0" encoding="UTF-8" standalone="yes"?>';
const M='http://schemas.openxmlformats.org/spreadsheetml/2006/main';
const R='http://schemas.openxmlformats.org/officeDocument/2006/relationships';
const z = new JSZip();
z.file('[Content_Types].xml', H+'<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types"><Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/><Default Extension="xml" ContentType="application/xml"/><Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/><Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/><Override PartName="/xl/sharedStrings.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sharedStrings+xml"/></Types>');
z.file('_rels/.rels', H+'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="'+R+'/officeDocument" Target="xl/workbook.xml"/></Relationships>');
z.file('xl/workbook.xml', H+'<workbook xmlns="'+M+'" xmlns:r="'+R+'"><sheets><sheet name="S" sheetId="1" r:id="rId1"/></sheets></workbook>');
z.file('xl/_rels/workbook.xml.rels', H+'<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships"><Relationship Id="rId1" Type="'+R+'/worksheet" Target="worksheets/sheet1.xml"/><Relationship Id="rId2" Type="'+R+'/sharedStrings" Target="sharedStrings.xml"/></Relationships>');
z.file('xl/worksheets/sheet1.xml', H+'<worksheet xmlns="'+M+'"><sheetData><row r="1"><c r="A1" t="s"><v>0</v></c></row></sheetData></worksheet>');
z.file('xl/sharedStrings.xml', H+'<sst xmlns="'+M+'" count="1" uniqueCount="1"><si><t></t><r><t>abc</t></r></si></sst>');
z.generateAsync({type:'nodebuffer'}).then(b => new ExcelJS.Workbook().xlsx.load(b)).then(() => console.log('ok'), e => console.error(e.message));
// → Cannot create property 'richText' on string ''

Suggested fix

In shared-string-xform.js case 'r', promote a primitive model back to an object before writing, keeping any non-empty leading text as the first run:

javascript
case 'r': {
  if (this.model === null || typeof this.model !== 'object') {
    const priorText = this.model;
    this.model = {};
    if (priorText) this.model.richText = [{text: priorText}];
  }
  let rt = this.model.richText;
  …

and mirror it in cell-xform.js case 'r' (test typeof this.model.value !== 'object' rather than relying on || {}). Verified locally against 4.4.0: the Lark workbook then loads with all 671 strings intact (cell.text correct for every one).

Environment: exceljs 4.4.0, Node 24.