Skip to content

Commit 2a05ffb

Browse files
arcaputo3claude
andauthored
fix(ooxml): Preserve styles on untouched sheets during metadata writes (TJC-751) (#220)
* fix(ooxml): Preserve styles on untouched sheets during metadata writes (TJC-751) StyleIndex.fromWorkbookWithSource only created style remappings for sheets in tracker.modifiedSheets, but metadata-only changes (add-sheet, reorder) cause ALL sheets to be regenerated from the domain model. Unmodified sheets received empty remappings, causing every cell's styleId to fall back to 0 (default), silently stripping all styling. Generate remappings for all sheets unconditionally. Unmodified sheets' styles all originate from the source file, so every canonical key is found in the unified index — no new styles are added, and the remapping is identity-like. When sheets are copied verbatim (the normal surgical path), the extra remappings simply go unused. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * fix(ooxml): Address PR review — perf guard, resource leak, reorder test 1. Performance: restore skip for unmodified sheets when only modifiedSheets is set (not modifiedMetadata/reorderedSheets). Avoids O(n × styles) of unused remapping work. 2. Test: wrap ZipFile and temp files in try/finally to prevent resource leaks on assertion failure. 3. Test: add reorder variant (reorderedSheets=true) covering the other code path that triggers full-sheet regeneration. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 2b9a213 commit 2a05ffb

2 files changed

Lines changed: 259 additions & 17 deletions

File tree

xl-ooxml/src/com/tjclp/xl/ooxml/style/StyleIndex.scala

Lines changed: 18 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -160,22 +160,20 @@ object StyleIndex:
160160
* Build style index for workbook with source, preserving original styles.
161161
*
162162
* This variant is used during surgical modification to avoid corruption:
163-
* - Deduplicates styles ONLY from modified sheets (optimal compression)
164-
* - Preserves original styles from source for unmodified sheets (no remapping needed)
165-
* - Ensures unmodified sheets' style references remain valid after write
163+
* - Preserves ALL original styles from source (including duplicates)
164+
* - Deduplicates only truly new styles from modified sheets
165+
* - Generates remappings for ALL sheets (not just modified ones)
166166
*
167-
* Strategy:
168-
* 1. Parse original styles.xml to get complete WorkbookStyles
169-
* 2. Deduplicate styles from modified sheets only
170-
* 3. Ensure all original styles are present in output (fill gaps if needed)
171-
* 4. Return remappings ONLY for modified sheets (unmodified sheets use original IDs)
167+
* All sheets need remappings because metadata-only changes (add-sheet, reorder) cause ALL sheets
168+
* to be regenerated from the domain model, not copied verbatim from source. Without remappings,
169+
* unmodified-but-regenerated sheets would fall back to styleId 0, stripping all styles (TJC-751).
172170
*
173171
* @param wb
174172
* The workbook with modified sheets
175173
* @param ctx
176174
* Source context providing modification tracker and original file path
177175
* @return
178-
* (StyleIndex with all original + deduplicated styles, Map[modifiedSheetIdx -> remapping])
176+
* (StyleIndex with all original + new styles, Map[sheetIdx -> remapping])
179177
*/
180178
@SuppressWarnings(
181179
Array(
@@ -188,9 +186,8 @@ object StyleIndex:
188186
wb: Workbook,
189187
ctx: SourceContext
190188
): (StyleIndex, Map[Int, Map[Int, Int]]) =
191-
// Extract values from context
192189
val tracker = ctx.modificationTracker
193-
val modifiedSheetIndices = tracker.modifiedSheets
190+
val needsRemappingForAll = tracker.modifiedMetadata || tracker.reorderedSheets
194191
val sourcePath = ctx.sourcePath
195192
import scala.collection.mutable
196193
import java.util.zip.ZipInputStream
@@ -251,9 +248,13 @@ object StyleIndex:
251248
}))
252249
var nextNumFmtId = if numFmtsBuilder.isEmpty then 164 else numFmtsBuilder.map(_._1).max + 1
253250

254-
// Step 4: Process ONLY modified sheets for style remapping
255-
val remappings = wb.sheets.zipWithIndex.flatMap { case (sheet, sheetIdx) =>
256-
if modifiedSheetIndices.contains(sheetIdx) then
251+
// Step 4: Process sheets for style remapping
252+
// When metadata or reorder changes force ALL sheets to be regenerated from domain model,
253+
// every sheet needs a remapping — otherwise unmodified sheets fall back to styleId 0,
254+
// stripping all styles (TJC-751). When only specific sheets are modified, unmodified
255+
// sheets are copied verbatim from source and don't need remappings.
256+
val remappings = wb.sheets.zipWithIndex.map { case (sheet, sheetIdx) =>
257+
if needsRemappingForAll || tracker.modifiedSheets.contains(sheetIdx) then
257258
val registry = sheet.styleRegistry
258259
val remapping = mutable.Map[Int, Int]()
259260

@@ -302,10 +303,10 @@ object StyleIndex:
302303
case _ => ()
303304
}
304305

305-
Some(sheetIdx -> remapping.toMap)
306+
sheetIdx -> remapping.toMap
306307
else
307-
// Unmodified sheet - no remapping needed (uses original style IDs)
308-
None
308+
// Unmodified sheet copied verbatim from source — remapping unused
309+
sheetIdx -> Map.empty[Int, Int]
309310
}.toMap
310311

311312
// Step 5: Finalize component vectors (original + any new components)

xl-ooxml/test/src/com/tjclp/xl/ooxml/XlsxWriterCorruptionRegressionSpec.scala

Lines changed: 241 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1128,6 +1128,247 @@ class XlsxWriterCorruptionRegressionSpec extends FunSuite:
11281128
Files.deleteIfExists(output)
11291129
}
11301130

1131+
// ===== Regression Test for Style Stripping on Untouched Sheets (TJC-751) =====
1132+
1133+
test("add-sheet preserves styles on untouched sheets (TJC-751)") {
1134+
// Regression test for: styles stripped on ALL untouched sheets during metadata-only writes
1135+
// Bug: StyleIndex.fromWorkbookWithSource only created remappings for modified sheets,
1136+
// but metadata changes (add-sheet) force ALL sheets to be regenerated from domain model.
1137+
// Unmodified sheets got Map.empty remapping → all styles fell back to styleId 0 (default).
1138+
// Impact: P0 - All styling (bold, colors, number formats) silently stripped from untouched sheets
1139+
1140+
val source = createWorkbookWithRichStyles()
1141+
val output = Files.createTempFile("tjc-751-add-sheet", ".xlsx")
1142+
1143+
try
1144+
// Read the workbook (populates SourceContext)
1145+
val wb = XlsxReader.read(source).fold(err => fail(s"Failed to read: $err"), identity)
1146+
assertEquals(wb.sheets.size, 2, "Source should have 2 sheets")
1147+
1148+
// Add a new sheet — triggers modifiedMetadata=true, modifiedSheets stays empty
1149+
val withNewSheet = wb.put(Sheet("NewComps").put(ref"A1" -> "New data"))
1150+
1151+
// Verify tracker state: metadata modified but no existing sheets marked modified
1152+
val tracker = withNewSheet.sourceContext.get.modificationTracker
1153+
assert(tracker.modifiedMetadata, "modifiedMetadata should be true after add-sheet")
1154+
assert(
1155+
!tracker.modifiedSheets.contains(0) && !tracker.modifiedSheets.contains(1),
1156+
"Existing sheets should NOT be in modifiedSheets"
1157+
)
1158+
1159+
// Write the modified workbook
1160+
XlsxWriter
1161+
.write(withNewSheet, output)
1162+
.fold(err => fail(s"Failed to write: $err"), identity)
1163+
1164+
// Read back and verify
1165+
val result = XlsxReader.read(output).fold(err => fail(s"Failed to reload: $err"), identity)
1166+
assertEquals(result.sheets.size, 3, "Output should have 3 sheets")
1167+
1168+
// CRITICAL: Verify styles on "Styled" sheet are preserved (not stripped to default)
1169+
val styled = result("Styled").fold(err => fail(s"Sheet Styled not found: $err"), identity)
1170+
1171+
// A1 should be bold
1172+
val a1Style = styled(ref"A1").styleId
1173+
.flatMap(id => styled.styleRegistry.get(id))
1174+
assert(a1Style.isDefined, "A1 should have a style")
1175+
assert(a1Style.get.font.bold, "A1 should be bold (was stripped to default!)")
1176+
1177+
// B1 should have a colored fill
1178+
val b1Style = styled(ref"B1").styleId
1179+
.flatMap(id => styled.styleRegistry.get(id))
1180+
assert(b1Style.isDefined, "B1 should have a style")
1181+
assert(
1182+
b1Style.get.fill != Fill.default,
1183+
"B1 should have colored fill (was stripped to default!)"
1184+
)
1185+
1186+
// CRITICAL: Verify styles on "AlsoStyled" sheet are preserved
1187+
val alsoStyled =
1188+
result("AlsoStyled").fold(err => fail(s"Sheet AlsoStyled not found: $err"), identity)
1189+
1190+
val a1Style2 = alsoStyled(ref"A1").styleId
1191+
.flatMap(id => alsoStyled.styleRegistry.get(id))
1192+
assert(a1Style2.isDefined, "AlsoStyled A1 should have a style")
1193+
assert(a1Style2.get.font.bold, "AlsoStyled A1 should be bold (was stripped!)")
1194+
1195+
// Also verify at the raw XML level: check that cells in untouched sheets have s= attributes
1196+
val outputZip = new ZipFile(output.toFile)
1197+
try
1198+
val sheet1Xml = readEntryString(outputZip, outputZip.getEntry("xl/worksheets/sheet1.xml"))
1199+
1200+
// Cells with styles should have s="N" where N > 0
1201+
val cellsWithStyles =
1202+
"""s="(\d+)"""".r.findAllMatchIn(sheet1Xml).map(_.group(1).toInt).toList
1203+
assert(
1204+
cellsWithStyles.exists(_ > 0),
1205+
s"Sheet1 cells should have non-zero style indices but found: $cellsWithStyles"
1206+
)
1207+
finally outputZip.close()
1208+
finally
1209+
Files.deleteIfExists(source)
1210+
Files.deleteIfExists(output)
1211+
}
1212+
1213+
test("reorder preserves styles on all sheets (TJC-751 variant)") {
1214+
// Same bug trigger via reorderedSheets=true instead of modifiedMetadata=true
1215+
val source = createWorkbookWithRichStyles()
1216+
val output = Files.createTempFile("tjc-751-reorder", ".xlsx")
1217+
1218+
try
1219+
val wb = XlsxReader.read(source).fold(err => fail(s"Failed to read: $err"), identity)
1220+
1221+
// Reorder sheets: [Styled, AlsoStyled] → [AlsoStyled, Styled]
1222+
val reordered = wb
1223+
.reorder(Vector(SheetName.unsafe("AlsoStyled"), SheetName.unsafe("Styled")))
1224+
.fold(err => fail(s"Failed to reorder: $err"), identity)
1225+
1226+
val tracker = reordered.sourceContext.get.modificationTracker
1227+
assert(tracker.reorderedSheets, "reorderedSheets should be true")
1228+
1229+
XlsxWriter
1230+
.write(reordered, output)
1231+
.fold(err => fail(s"Failed to write: $err"), identity)
1232+
1233+
val result = XlsxReader.read(output).fold(err => fail(s"Failed to reload: $err"), identity)
1234+
assertEquals(result.sheetNames.map(_.value), Vector("AlsoStyled", "Styled"))
1235+
1236+
// Verify styles survived the reorder
1237+
val styled = result("Styled").fold(err => fail(s"Sheet not found: $err"), identity)
1238+
val a1Style = styled(ref"A1").styleId.flatMap(id => styled.styleRegistry.get(id))
1239+
assert(a1Style.isDefined, "A1 should have a style after reorder")
1240+
assert(a1Style.get.font.bold, "A1 should be bold after reorder (was stripped!)")
1241+
1242+
val b1Style = styled(ref"B1").styleId.flatMap(id => styled.styleRegistry.get(id))
1243+
assert(b1Style.isDefined, "B1 should have a style after reorder")
1244+
assert(b1Style.get.fill != Fill.default, "B1 fill stripped after reorder!")
1245+
1246+
val alsoStyled =
1247+
result("AlsoStyled").fold(err => fail(s"Sheet not found: $err"), identity)
1248+
val a1Style2 = alsoStyled(ref"A1").styleId.flatMap(id => alsoStyled.styleRegistry.get(id))
1249+
assert(a1Style2.isDefined, "AlsoStyled A1 should have a style after reorder")
1250+
assert(a1Style2.get.font.bold, "AlsoStyled A1 should be bold after reorder!")
1251+
finally
1252+
Files.deleteIfExists(source)
1253+
Files.deleteIfExists(output)
1254+
}
1255+
1256+
private def createWorkbookWithRichStyles(): Path =
1257+
val path = Files.createTempFile("test-rich-styles", ".xlsx")
1258+
val out = new ZipOutputStream(Files.newOutputStream(path))
1259+
out.setLevel(1)
1260+
1261+
try
1262+
writeEntry(
1263+
out,
1264+
"[Content_Types].xml",
1265+
"""<?xml version="1.0"?>
1266+
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
1267+
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
1268+
<Default Extension="xml" ContentType="application/xml"/>
1269+
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
1270+
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
1271+
<Override PartName="/xl/worksheets/sheet2.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
1272+
<Override PartName="/xl/styles.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml"/>
1273+
</Types>"""
1274+
)
1275+
1276+
writeEntry(
1277+
out,
1278+
"_rels/.rels",
1279+
"""<?xml version="1.0"?>
1280+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1281+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
1282+
</Relationships>"""
1283+
)
1284+
1285+
writeEntry(
1286+
out,
1287+
"xl/workbook.xml",
1288+
"""<?xml version="1.0"?>
1289+
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships">
1290+
<sheets>
1291+
<sheet name="Styled" sheetId="1" r:id="rId1"/>
1292+
<sheet name="AlsoStyled" sheetId="2" r:id="rId2"/>
1293+
</sheets>
1294+
</workbook>"""
1295+
)
1296+
1297+
writeEntry(
1298+
out,
1299+
"xl/_rels/workbook.xml.rels",
1300+
"""<?xml version="1.0"?>
1301+
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
1302+
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
1303+
<Relationship Id="rId2" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet2.xml"/>
1304+
<Relationship Id="rId3" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/styles" Target="styles.xml"/>
1305+
</Relationships>"""
1306+
)
1307+
1308+
// Styles: bold font, red solid fill, currency number format
1309+
writeEntry(
1310+
out,
1311+
"xl/styles.xml",
1312+
"""<?xml version="1.0"?>
1313+
<styleSheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1314+
<numFmts count="1">
1315+
<numFmt numFmtId="164" formatCode="&quot;$&quot;#,##0.00"/>
1316+
</numFmts>
1317+
<fonts count="2">
1318+
<font><sz val="11"/><name val="Calibri"/></font>
1319+
<font><b/><sz val="11"/><name val="Calibri"/></font>
1320+
</fonts>
1321+
<fills count="3">
1322+
<fill><patternFill patternType="none"/></fill>
1323+
<fill><patternFill patternType="gray125"/></fill>
1324+
<fill><patternFill patternType="solid"><fgColor rgb="FFFF0000"/></patternFill></fill>
1325+
</fills>
1326+
<borders count="1"><border><left/><right/><top/><bottom/><diagonal/></border></borders>
1327+
<cellStyleXfs count="1"><xf numFmtId="0" fontId="0" fillId="0" borderId="0"/></cellStyleXfs>
1328+
<cellXfs count="4">
1329+
<xf numFmtId="0" fontId="0" fillId="0" borderId="0" xfId="0"/>
1330+
<xf numFmtId="0" fontId="1" fillId="0" borderId="0" xfId="0" applyFont="1"/>
1331+
<xf numFmtId="0" fontId="0" fillId="2" borderId="0" xfId="0" applyFill="1"/>
1332+
<xf numFmtId="164" fontId="1" fillId="0" borderId="0" xfId="0" applyNumberFormat="1" applyFont="1"/>
1333+
</cellXfs>
1334+
<cellStyles count="1"><cellStyle name="Normal" xfId="0" builtinId="0"/></cellStyles>
1335+
</styleSheet>"""
1336+
)
1337+
1338+
// Sheet 1: "Styled" — A1 is bold, B1 has red fill, C1 has currency format
1339+
writeEntry(
1340+
out,
1341+
"xl/worksheets/sheet1.xml",
1342+
"""<?xml version="1.0"?>
1343+
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1344+
<sheetData>
1345+
<row r="1">
1346+
<c r="A1" s="1" t="inlineStr"><is><t>Bold Header</t></is></c>
1347+
<c r="B1" s="2" t="inlineStr"><is><t>Red Cell</t></is></c>
1348+
<c r="C1" s="3"><v>1234.56</v></c>
1349+
</row>
1350+
</sheetData>
1351+
</worksheet>"""
1352+
)
1353+
1354+
// Sheet 2: "AlsoStyled" — A1 is bold
1355+
writeEntry(
1356+
out,
1357+
"xl/worksheets/sheet2.xml",
1358+
"""<?xml version="1.0"?>
1359+
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
1360+
<sheetData>
1361+
<row r="1">
1362+
<c r="A1" s="1" t="inlineStr"><is><t>Bold in Sheet 2</t></is></c>
1363+
</row>
1364+
</sheetData>
1365+
</worksheet>"""
1366+
)
1367+
1368+
finally out.close()
1369+
1370+
path
1371+
11311372
private def createWorkbookWithoutSST(): Path =
11321373
// Create a minimal workbook that uses inline strings (no sharedStrings.xml)
11331374
val path = Files.createTempFile("test-no-sst", ".xlsx")

0 commit comments

Comments
 (0)