@@ -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=""$"#,##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