diff --git a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Cell.java b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Cell.java
index 72abade4..4d1d6604 100644
--- a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Cell.java
+++ b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Cell.java
@@ -58,6 +58,8 @@ void write(Writer w, int r, int c) throws IOException {
w.append(">");
if (value instanceof Formula) {
w.append("").append(((Formula) value).getExpression()).append("");
+ } else if (value instanceof RichText) {
+ ((RichText) value).write(w);
} else if (value instanceof String) {
w.append("").appendEscaped((String) value).append("");
} else if (value != null) {
@@ -86,7 +88,7 @@ static String getCellType(Object value) {
return "s";
} else if (value instanceof Boolean) {
return "b";
- } else if (value instanceof String) {
+ } else if (value instanceof String || value instanceof RichText) {
return "inlineStr";
} else {
return "n";
@@ -155,6 +157,15 @@ void setInlineString(String v) {
value = v;
}
+ /**
+ * Assign a rich inline string to this cell.
+ *
+ * @param v Rich inline string value.
+ */
+ void setInlineString(RichText v) {
+ value = v;
+ }
+
/**
* Get the style of this cell.
*
diff --git a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/RichText.java b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/RichText.java
new file mode 100644
index 00000000..5462ef26
--- /dev/null
+++ b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/RichText.java
@@ -0,0 +1,212 @@
+/*
+ * Copyright 2026 Dhatim.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.dhatim.fastexcel;
+
+import java.io.IOException;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+/**
+ * Rich inline string value: a sequence of {@link Run runs}, each carrying its
+ * own font formatting. Pass an instance to
+ * {@link Worksheet#inlineString(int, int, RichText)} to write it.
+ *
+ *
Wire format follows OOXML (ECMA-376 §18.4):
+ * {@code .........}.
+ *
+ *
Use {@link #builder()} to construct an instance. The {@link Run}
+ * constructor is package-private on purpose — runs are created through the
+ * builder.
+ */
+public final class RichText {
+
+ /**
+ * A single run: text plus optional font formatting.
+ * A {@code null} formatting field means "inherit from cell style"
+ * (i.e. the property is omitted from {@code }).
+ */
+ public static final class Run {
+ private final String text;
+ private final boolean bold;
+ private final boolean italic;
+ private final boolean underlined;
+ private final Integer fontSize; // points; null = inherit
+ private final String fontName; // null = inherit
+ private final String fontColor; // RRGGBB or AARRGGBB hex; null = inherit
+
+ Run(String text, boolean bold, boolean italic, boolean underlined,
+ Integer fontSize, String fontName, String fontColor) {
+ this.text = text == null ? "" : text;
+ this.bold = bold;
+ this.italic = italic;
+ this.underlined = underlined;
+ this.fontSize = fontSize;
+ this.fontName = fontName;
+ this.fontColor = fontColor;
+ }
+
+ public String getText() {
+ return text;
+ }
+
+ void write(Writer w) throws IOException {
+ w.append("");
+ boolean hasProps = bold || italic || underlined
+ || fontSize != null || fontName != null || fontColor != null;
+ if (hasProps) {
+ w.append("");
+ if (bold) {
+ w.append("");
+ }
+ if (italic) {
+ w.append("");
+ }
+ if (underlined) {
+ w.append("");
+ }
+ if (fontSize != null) {
+ w.append("");
+ }
+ if (fontColor != null) {
+ w.append("");
+ }
+ if (fontName != null) {
+ w.append("");
+ }
+ w.append("");
+ }
+ // xml:space="preserve" so leading/trailing whitespace and newlines survive.
+ w.append("").appendEscaped(text).append("");
+ }
+ }
+
+ /**
+ * Builder for a single {@link Run}. Obtained from
+ * {@link Builder#run(String)}. Setters are optional; unset fields inherit
+ * from the cell style.
+ */
+ public static final class RunBuilder {
+ private final Builder parent;
+ private final String text;
+ private boolean bold;
+ private boolean italic;
+ private boolean underlined;
+ private Integer fontSize;
+ private String fontName;
+ private String fontColor;
+
+ RunBuilder(Builder parent, String text) {
+ this.parent = parent;
+ this.text = text;
+ }
+
+ public RunBuilder bold() {
+ this.bold = true;
+ return this;
+ }
+
+ public RunBuilder italic() {
+ this.italic = true;
+ return this;
+ }
+
+ public RunBuilder underlined() {
+ this.underlined = true;
+ return this;
+ }
+
+ public RunBuilder fontSize(int points) {
+ this.fontSize = points;
+ return this;
+ }
+
+ public RunBuilder fontName(String name) {
+ this.fontName = name;
+ return this;
+ }
+
+ /**
+ * Hex color in {@code "RRGGBB"} or {@code "AARRGGBB"} form.
+ *
+ * @param hexRgbOrArgb Hex color string.
+ * @return This builder.
+ */
+ public RunBuilder fontColor(String hexRgbOrArgb) {
+ this.fontColor = hexRgbOrArgb;
+ return this;
+ }
+
+ /** Finishes this run and returns the parent builder for chaining. */
+ public Builder end() {
+ parent.runs.add(new Run(text, bold, italic, underlined, fontSize, fontName, fontColor));
+ return parent;
+ }
+ }
+
+ /** Builder for {@link RichText}. */
+ public static final class Builder {
+ private final List runs = new ArrayList<>();
+
+ Builder() {
+ }
+
+ /**
+ * Begin a new run. {@code null} text is treated as empty.
+ *
+ * @param text Run text.
+ * @return A {@link RunBuilder} for the new run.
+ */
+ public RunBuilder run(String text) {
+ return new RunBuilder(this, text);
+ }
+
+ public RichText build() {
+ return new RichText(runs);
+ }
+ }
+
+ /** Returns a new {@link Builder}. */
+ public static Builder builder() {
+ return new Builder();
+ }
+
+ private final List runs;
+
+ /**
+ * @param runs Runs that make up this rich string. Must not be {@code null};
+ * an empty list is allowed and produces an empty {@code }.
+ */
+ public RichText(List runs) {
+ this.runs = new ArrayList<>(Objects.requireNonNull(runs, "runs"));
+ }
+
+ public List getRuns() {
+ return Collections.unmodifiableList(runs);
+ }
+
+ /**
+ * Writes the {@code ...} body. The enclosing {@code } is the caller's job.
+ */
+ void write(Writer w) throws IOException {
+ w.append("");
+ for (Run run : runs) {
+ run.write(w);
+ }
+ w.append("");
+ }
+}
diff --git a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Worksheet.java b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Worksheet.java
index 8f68d939..55debd8c 100644
--- a/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Worksheet.java
+++ b/fastexcel-writer/src/main/java/org/dhatim/fastexcel/Worksheet.java
@@ -743,6 +743,20 @@ public void inlineString(int r, int c, String value) {
cell(r, c).setInlineString(value);
}
+ /**
+ * Set the cell value at the given coordinates as a rich inline string.
+ * Each {@link RichText.Run} carries its own font formatting, so a single
+ * cell can mix bold/italic/colored/sized fragments. Built via
+ * {@link RichText#builder()}.
+ *
+ * @param r Zero-based row number.
+ * @param c Zero-based column number.
+ * @param value Cell value.
+ */
+ public void inlineString(int r, int c, RichText value) {
+ cell(r, c).setInlineString(value);
+ }
+
/**
* Get a new style setter for a cell.
*
diff --git a/fastexcel-writer/src/test/java/org/dhatim/fastexcel/PoiCompatibilityTest.java b/fastexcel-writer/src/test/java/org/dhatim/fastexcel/PoiCompatibilityTest.java
index 5f57d94d..67b3debf 100644
--- a/fastexcel-writer/src/test/java/org/dhatim/fastexcel/PoiCompatibilityTest.java
+++ b/fastexcel-writer/src/test/java/org/dhatim/fastexcel/PoiCompatibilityTest.java
@@ -869,4 +869,72 @@ void testFreezePaneWithDoubleDigitColumn() throws IOException {
assertEquals(27, xws.getPaneInformation().getVerticalSplitLeftColumn());
}
+ @Test
+ void richInlineStringPreservesPerRunFormatting() throws IOException {
+ RichText rt = RichText.builder()
+ .run("Pickable (A)")
+ .fontSize(11).fontName("Calibri").fontColor("FF000000")
+ .end()
+ .run("\n*Direct contract only")
+ .bold().fontSize(11).fontName("Calibri").fontColor("FF70AD47")
+ .end()
+ .build();
+
+ byte[] data = writeWorkbook(wb -> {
+ Worksheet ws = wb.newWorksheet("Sheet1");
+ ws.inlineString(0, 0, rt);
+ });
+
+ XSSFWorkbook xwb = new XSSFWorkbook(new ByteArrayInputStream(data));
+ XSSFCell cell = xwb.getSheetAt(0).getRow(0).getCell(0);
+ XSSFRichTextString rts = cell.getRichStringCellValue();
+
+ assertEquals("Pickable (A)\n*Direct contract only", rts.getString());
+ assertEquals(2, rts.numFormattingRuns());
+
+ XSSFFont run0Font = rts.getFontOfFormattingRun(0);
+ assertFalse(run0Font.getBold(), "first run is not bold");
+
+ XSSFFont run1Font = rts.getFontOfFormattingRun(1);
+ assertTrue(run1Font.getBold(), "second run is bold");
+ byte[] run1Rgb = run1Font.getXSSFColor().getRGB();
+ assertNotNull(run1Rgb);
+ assertEquals((byte) 0x70, run1Rgb[run1Rgb.length - 3]);
+ assertEquals((byte) 0xAD, run1Rgb[run1Rgb.length - 2]);
+ assertEquals((byte) 0x47, run1Rgb[run1Rgb.length - 1]);
+ }
+
+ @Test
+ void richInlineStringPreservesLeadingAndTrailingWhitespace() throws IOException {
+ RichText rt = RichText.builder()
+ .run(" leading and trailing ").bold().end()
+ .build();
+
+ byte[] data = writeWorkbook(wb -> {
+ Worksheet ws = wb.newWorksheet("Sheet1");
+ ws.inlineString(0, 0, rt);
+ });
+
+ XSSFWorkbook xwb = new XSSFWorkbook(new ByteArrayInputStream(data));
+ XSSFCell cell = xwb.getSheetAt(0).getRow(0).getCell(0);
+ assertEquals(" leading and trailing ", cell.getRichStringCellValue().getString());
+ }
+
+ @Test
+ void richInlineStringWithoutAnyFormattingPropertiesEmitsBareRun() throws IOException {
+ RichText rt = RichText.builder()
+ .run("plain a").end()
+ .run("plain b").end()
+ .build();
+
+ byte[] data = writeWorkbook(wb -> {
+ Worksheet ws = wb.newWorksheet("Sheet1");
+ ws.inlineString(0, 0, rt);
+ });
+
+ XSSFWorkbook xwb = new XSSFWorkbook(new ByteArrayInputStream(data));
+ XSSFCell cell = xwb.getSheetAt(0).getRow(0).getCell(0);
+ assertEquals("plain aplain b", cell.getRichStringCellValue().getString());
+ }
+
}