Skip to content

Commit 96ecb87

Browse files
kah-jaFrooodle
andauthored
fix(csv): keep exported cell values literal in spreadsheet imports (#7227)
Co-authored-by: Anthony Stirling <77850077+Frooodle@users.noreply.github.com>
1 parent 85eed31 commit 96ecb87

6 files changed

Lines changed: 188 additions & 2 deletions

File tree

Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
package stirling.software.common.util;
2+
3+
import java.util.ArrayList;
4+
import java.util.List;
5+
import java.util.regex.Pattern;
6+
7+
import lombok.experimental.UtilityClass;
8+
9+
/**
10+
* Keeps CSV cell values literal when the file is opened in a spreadsheet application.
11+
*
12+
* <p>CSV quoting protects the record structure only. Excel, LibreOffice Calc and Google Sheets
13+
* strip the surrounding quotes on import and evaluate a cell that starts with {@code =}, {@code +},
14+
* {@code -} or {@code @} as a formula. Cell values taken from a document a user supplied are
15+
* therefore prefixed with a single quote, which spreadsheets read as "the rest of this cell is
16+
* text". Leading whitespace, including the tab and carriage return that spreadsheets discard before
17+
* evaluating, is skipped when looking for the trigger character.
18+
*
19+
* <p>Values that are a number with optional sign, group separators and decimal point stay
20+
* untouched: they cannot reference a cell or call a function, and prefixing them would turn the
21+
* numeric columns of an extracted table into text.
22+
*/
23+
@UtilityClass
24+
public class CsvSanitizer {
25+
26+
private static final String FORMULA_TRIGGERS = "=+-@";
27+
28+
private static final String TEXT_PREFIX = "'";
29+
30+
private static final Pattern PLAIN_NUMBER = Pattern.compile("[-+]?[0-9]+(?:[.,][0-9]+)*");
31+
32+
/**
33+
* Prefixes a cell value with a single quote when a spreadsheet would otherwise read it as a
34+
* formula.
35+
*
36+
* @param value the cell value, may be null
37+
* @return the value as literal text, null and empty input returned unchanged
38+
*/
39+
public String sanitizeCell(String value) {
40+
if (value == null || value.isEmpty()) {
41+
return value;
42+
}
43+
String candidate = value.stripLeading();
44+
if (candidate.isEmpty() || FORMULA_TRIGGERS.indexOf(candidate.charAt(0)) < 0) {
45+
return value;
46+
}
47+
if (PLAIN_NUMBER.matcher(candidate).matches()) {
48+
return value;
49+
}
50+
return TEXT_PREFIX + value;
51+
}
52+
53+
/**
54+
* Applies {@link #sanitizeCell(String)} to every cell of a record.
55+
*
56+
* @param row the record, may be null
57+
* @return a new list holding the sanitized cells, null input returned unchanged
58+
*/
59+
public List<String> sanitizeRow(List<String> row) {
60+
if (row == null) {
61+
return row;
62+
}
63+
List<String> sanitized = new ArrayList<>(row.size());
64+
for (String cell : row) {
65+
sanitized.add(sanitizeCell(cell));
66+
}
67+
return sanitized;
68+
}
69+
}
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
package stirling.software.common.util;
2+
3+
import static org.junit.jupiter.api.Assertions.assertEquals;
4+
import static org.junit.jupiter.api.Assertions.assertNull;
5+
6+
import java.util.Arrays;
7+
import java.util.List;
8+
9+
import org.junit.jupiter.api.Test;
10+
import org.junit.jupiter.params.ParameterizedTest;
11+
import org.junit.jupiter.params.provider.ValueSource;
12+
13+
class CsvSanitizerTest {
14+
15+
@ParameterizedTest
16+
@ValueSource(
17+
strings = {
18+
"=SUM(A1:A2)",
19+
"+HYPERLINK(\"http://example.com\")",
20+
"-WEBSERVICE(\"http://example.com\")",
21+
"@SUM(1+1)",
22+
" =SUM(A1:A2)",
23+
"\t=SUM(A1:A2)",
24+
"\r@SUM(1+1)"
25+
})
26+
void prefixesValuesASpreadsheetWouldEvaluate(String value) {
27+
assertEquals("'" + value, CsvSanitizer.sanitizeCell(value));
28+
}
29+
30+
@ParameterizedTest
31+
@ValueSource(
32+
strings = {
33+
"Alice",
34+
"1234",
35+
"-42",
36+
"-1,234.56",
37+
"+0.5",
38+
" -42",
39+
"total = 12",
40+
"a=b",
41+
" "
42+
})
43+
void leavesPlainTextAndNumbersUnchanged(String value) {
44+
assertEquals(value, CsvSanitizer.sanitizeCell(value));
45+
}
46+
47+
@Test
48+
void handlesNullAndEmptyValues() {
49+
assertNull(CsvSanitizer.sanitizeCell(null));
50+
assertEquals("", CsvSanitizer.sanitizeCell(""));
51+
assertNull(CsvSanitizer.sanitizeRow(null));
52+
}
53+
54+
@Test
55+
void sanitizesEveryCellOfARecord() {
56+
List<String> row = Arrays.asList("=SUM(A1:A2)", "Alice", null, "-42");
57+
58+
assertEquals(
59+
Arrays.asList("'=SUM(A1:A2)", "Alice", null, "-42"), CsvSanitizer.sanitizeRow(row));
60+
}
61+
}

app/core/src/main/java/stirling/software/SPDF/controller/api/converters/ExtractCSVController.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@
3535
import stirling.software.common.model.tool.ToolFormat;
3636
import stirling.software.common.model.tool.ToolIO;
3737
import stirling.software.common.service.CustomPDFDocumentFactory;
38+
import stirling.software.common.util.CsvSanitizer;
3839
import stirling.software.common.util.GeneralUtils;
3940
import stirling.software.common.util.WebResponseUtils;
4041

@@ -73,7 +74,7 @@ public ResponseEntity<?> pdfToCsv(@ModelAttribute PDFWithPageNums request) throw
7374
StringWriter sw = new StringWriter();
7475
try (CSVPrinter printer = format.print(sw)) {
7576
for (List<String> row : fragments.get(i).rawRows()) {
76-
printer.printRecord(row);
77+
printer.printRecord(CsvSanitizer.sanitizeRow(row));
7778
}
7879
}
7980
csvEntries.add(

app/core/src/main/java/stirling/software/SPDF/controller/api/form/FormFillController.java

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@
4343

4444
import stirling.software.common.model.FormFieldWithCoordinates;
4545
import stirling.software.common.service.CustomPDFDocumentFactory;
46+
import stirling.software.common.util.CsvSanitizer;
4647
import stirling.software.common.util.ExceptionUtils;
4748
import stirling.software.common.util.FormUtils;
4849
import stirling.software.common.util.TempFile;
@@ -280,7 +281,11 @@ public ResponseEntity<byte[]> extractCsv(
280281
csvWriter.writeNext(header);
281282

282283
for (FormUtils.FormFieldInfo field : fields) {
283-
csvWriter.writeNext(new String[] {field.name(), field.value()});
284+
csvWriter.writeNext(
285+
new String[] {
286+
CsvSanitizer.sanitizeCell(field.name()),
287+
CsvSanitizer.sanitizeCell(field.value())
288+
});
284289
}
285290
}
286291

app/core/src/test/java/stirling/software/SPDF/controller/api/converters/ExtractCSVControllerMoreTest.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,31 @@ void loadFailurePropagates() throws Exception {
193193
}
194194
}
195195

196+
@Test
197+
@DisplayName("cells a spreadsheet would evaluate are written as literal text")
198+
void formulaCellsAreQuoted() throws Exception {
199+
PDFWithPageNums request = new PDFWithPageNums();
200+
request.setFileInput(pdf("table.pdf"));
201+
request.setPageNumbers("all");
202+
203+
when(pdfDocumentFactory.load(request)).thenReturn(docWithPages(1));
204+
when(tabulaTableParser.parse(any(PDDocument.class), eq(1)))
205+
.thenReturn(
206+
List.of(
207+
fragment(
208+
List.of(
209+
List.of("=SUM(A1:A2)", "@SUM(1+1)"),
210+
List.of("Alice", "-42")))));
211+
212+
ResponseEntity<?> response = controller.pdfToCsv(request);
213+
214+
String body = response.getBody().toString();
215+
assertThat(body).contains("\"'=SUM(A1:A2)\"").contains("\"'@SUM(1+1)\"");
216+
assertThat(body).doesNotContain("\"=SUM(A1:A2)\"").doesNotContain("\"@SUM(1+1)\"");
217+
// Text and numeric cells keep their original form.
218+
assertThat(body).contains("\"Alice\"").contains("\"-42\"");
219+
}
220+
196221
@Test
197222
@DisplayName("single table CSV body is quote-wrapped per the EXCEL/QuoteMode.ALL format")
198223
void csvBodyIsQuoted() throws Exception {

app/core/src/test/java/stirling/software/SPDF/controller/api/form/FormFillControllerTest.java

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,12 @@
1010
import java.io.IOException;
1111
import java.nio.file.Files;
1212

13+
import org.apache.pdfbox.cos.COSName;
1314
import org.apache.pdfbox.pdmodel.PDDocument;
1415
import org.apache.pdfbox.pdmodel.PDPage;
1516
import org.apache.pdfbox.pdmodel.common.PDRectangle;
1617
import org.apache.pdfbox.pdmodel.interactive.form.PDAcroForm;
18+
import org.apache.pdfbox.pdmodel.interactive.form.PDTextField;
1719
import org.junit.jupiter.api.BeforeEach;
1820
import org.junit.jupiter.api.DisplayName;
1921
import org.junit.jupiter.api.Nested;
@@ -87,6 +89,16 @@ private PDDocument createMinimalPdf() {
8789
return doc;
8890
}
8991

92+
private PDDocument createPdfWithTextField(String name, String value) {
93+
PDDocument doc = createMinimalPdf();
94+
PDAcroForm acroForm = doc.getDocumentCatalog().getAcroForm();
95+
PDTextField field = new PDTextField(acroForm);
96+
field.setPartialName(name);
97+
field.getCOSObject().setString(COSName.V, value);
98+
acroForm.getFields().add(field);
99+
return doc;
100+
}
101+
90102
private byte[] pdfBytes() throws IOException {
91103
try (PDDocument doc = createMinimalPdf();
92104
ByteArrayOutputStream baos = new ByteArrayOutputStream()) {
@@ -183,6 +195,19 @@ void validPdfNullData() throws Exception {
183195
assertThat(csv).contains("Field Name");
184196
}
185197

198+
@Test
199+
@DisplayName("keeps a field value a spreadsheet would evaluate as literal text")
200+
void formulaFieldValueIsQuoted() throws Exception {
201+
MockMultipartFile file = pdfFile();
202+
PDDocument doc = createPdfWithTextField("note", "=SUM(A1:A2)");
203+
when(pdfDocumentFactory.load(eq(file), eq(true))).thenReturn(doc);
204+
205+
ResponseEntity<byte[]> response = controller.extractCsv(file, null);
206+
207+
String csv = new String(response.getBody());
208+
assertThat(csv).contains("\"'=SUM(A1:A2)\"").doesNotContain("\"=SUM(A1:A2)\"");
209+
}
210+
186211
@Test
187212
@DisplayName("throws for null file")
188213
void nullFile() {

0 commit comments

Comments
 (0)