From 535e5b096b32a02493477427cb4fb6ec817b50f5 Mon Sep 17 00:00:00 2001 From: minkyu725 Date: Tue, 5 May 2026 04:06:07 +0900 Subject: [PATCH] feat: support rich inline strings via Worksheet.inlineString(int, int, RichText) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a RichText value type and a Worksheet.inlineString(int, int, RichText) overload so callers can write a single cell with mixed per-run formatting (bold, italic, underline, font size/name/color). Output follows OOXML (ECMA-376 §18.4): ......... API: RichText rt = RichText.builder() .run("Pickable (A)").end() .run("\n*Direct contract only").bold().fontColor("FF70AD47").end() .build(); worksheet.inlineString(row, col, rt); Notes: - RichText (and its Builder/RunBuilder) are public; Run constructor is package-private so runs are produced through the builder. - Unset font fields on a run are omitted from , letting Excel inherit from the cell style — matches the existing inheritance contract for the default cell font. - xml:space="preserve" is always emitted so leading/trailing whitespace and newlines round-trip correctly. - Three POI round-trip tests added to PoiCompatibilityTest covering per-run formatting, leading/trailing whitespace, and bare-run output. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../main/java/org/dhatim/fastexcel/Cell.java | 13 +- .../java/org/dhatim/fastexcel/RichText.java | 212 ++++++++++++++++++ .../java/org/dhatim/fastexcel/Worksheet.java | 14 ++ .../fastexcel/PoiCompatibilityTest.java | 68 ++++++ 4 files changed, 306 insertions(+), 1 deletion(-) create mode 100644 fastexcel-writer/src/main/java/org/dhatim/fastexcel/RichText.java 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()); + } + }