diff --git a/src/index.js b/src/index.js index 7158c52..e461e30 100755 --- a/src/index.js +++ b/src/index.js @@ -104,16 +104,23 @@ class Workbook { // Copy sheet file self.archive.file(arcName, etree.tostring(sheet.root)); self.archive.files[arcName].options.binary = binary; + + // Add content type for the new sheet + var sheetContentType = etree.SubElement(self.contentTypes, 'Override'); + sheetContentType.attrib.PartName = '/' + arcName; + sheetContentType.attrib.ContentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml'; + // copy sheet name in workbook var newSheet = etree.SubElement(self.workbook.find('sheets'), 'sheet'); - newSheet.attrib.name = copyName || 'Sheet' + newSheetIndex; + const finalSheetName = copyName || 'Sheet' + newSheetIndex; + newSheet.attrib.name = finalSheetName; newSheet.attrib.sheetId = newSheetIndex; newSheet.attrib['r:id'] = 'rId' + newSheetIndex; // Copy definedName if any self.workbook.findall('definedNames/definedName').forEach(element => { if (element.text && element.text.split("!").length && element.text.split("!")[0] == sheetName) { var newDefinedName = etree.SubElement(self.workbook.find('definedNames'), 'definedName', element.attrib); - newDefinedName.text = `${copyName}!${element.text.split("!")[1]}`; + newDefinedName.text = `${finalSheetName}!${element.text.split("!")[1]}`; newDefinedName.attrib.localSheetId = newSheetIndex - 1; } }); @@ -122,11 +129,85 @@ class Workbook { newRel.attrib.Type = 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet'; newRel.attrib.Target = fileName; - //Copy rels sheet - TODO : Maybe we can copy also the 'Target' files in rels, but Excel make this automaticly + // Copy sheet relationships and their target files + var sourceRels = self.loadSheetRels(sheet.filename); var relFileName = 'worksheets' + '/_rels/' + 'sheet' + newSheetIndex + '.xml.rels'; var relArcName = self.prefix + '/' + relFileName; - self.archive.file(relArcName, etree.tostring(self.loadSheetRels(sheet.filename).root)); - self.archive.files[relArcName].options.binary = true; + var newRelsRoot = self.cloneElement(sourceRels.root, true); + + // Generate a new UUID for comments (shared between comments.xml and threadedComment.xml) + var newCommentUuid = self.generateUUID(); + + // Process each relationship to copy target files with unique names + sourceRels.root.findall('Relationship').forEach(function(rel, index) { + var relType = rel.attrib.Type; + var target = rel.attrib.Target; + + // Relationship types that require copying the target file + var needsFileCopy = [ + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments', + 'http://schemas.microsoft.com/office/2017/10/relationships/threadedComment', + 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing' + ]; + + if (needsFileCopy.indexOf(relType) !== -1) { + var sheetDirectory = path.dirname(sheet.filename); + var sourceFilePath = path.join(sheetDirectory, target).replace(/\\/g, '/'); + var sourceFile = self.archive.file(sourceFilePath); + + if (sourceFile) { + // Generate unique file name based on newSheetIndex + var fileExtension = path.extname(target); + var fileBaseName = path.basename(target, fileExtension); + var fileDir = path.dirname(target); + var baseNameWithoutNumber = fileBaseName.replace(/\d+$/, ''); + var newFileName = baseNameWithoutNumber + newSheetIndex + fileExtension; + var newTarget = path.join(fileDir, newFileName).replace(/\\/g, '/'); + var newFilePath = path.join(sheetDirectory, newTarget).replace(/\\/g, '/'); + + // Copy file in binary mode to preserve UTF-8 encoding + var binaryContent = sourceFile.asBinary(); + + // Apply file-specific transformations + if (relType === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing') { + // Update VML data attribute to make it unique per sheet + binaryContent = binaryContent.replace(/data="\d+"/, 'data="' + newSheetIndex + '"'); + + } else if (relType === 'http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments') { + // Replace comment UUIDs in both tc= and xr:uid= attributes + var uuidWithoutBraces = newCommentUuid.replace(/[{}]/g, ''); + binaryContent = binaryContent.replace(/(tc=\{)[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}(\}<\/author>)/gi, '$1' + uuidWithoutBraces + '$2'); + binaryContent = binaryContent.replace(/(xr:uid="\{)[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}(\}\")/gi, '$1' + uuidWithoutBraces + '$2'); + + var commentsContentType = etree.SubElement(self.contentTypes, 'Override'); + commentsContentType.attrib.PartName = '/' + newFilePath; + commentsContentType.attrib.ContentType = 'application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml'; + + } else if (relType === 'http://schemas.microsoft.com/office/2017/10/relationships/threadedComment') { + // Replace only the comment UUID in id= attribute (preserve personId) + var uuidWithoutBraces = newCommentUuid.replace(/[{}]/g, ''); + binaryContent = binaryContent.replace(/(\sid="\{)[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12}(\}")/gi, '$1' + uuidWithoutBraces + '$2'); + + var threadedCommentContentType = etree.SubElement(self.contentTypes, 'Override'); + threadedCommentContentType.attrib.PartName = '/' + newFilePath; + threadedCommentContentType.attrib.ContentType = 'application/vnd.ms-excel.threadedcomments+xml'; + } + + self.archive.file(newFilePath, binaryContent); + self.archive.files[newFilePath].options.binary = true; // Force binary mode to preserve UTF-8 encoding + + // Update relationship target in the copied rels file + var newRelInRels = newRelsRoot.findall('Relationship')[index]; + if (newRelInRels) { + newRelInRels.attrib.Target = newTarget; + } + } + } + }); + + self.archive.file(relArcName, etree.tostring(newRelsRoot)); + self.archive.files[relArcName].options.binary = binary; + self.archive.file('[Content_Types].xml', etree.tostring(self.contentTypes)); self._rebuild(); return self; @@ -901,6 +982,21 @@ class Workbook { return str; } + // Generate a UUID v4 for comment IDs + generateUUID() { + // Format: {XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX} + var hexDigits = '0123456789ABCDEF'; + var uuid = '{'; + for (var i = 0; i < 36; i++) { + if (i === 8 || i === 13 || i === 18 || i === 23) { + uuid += '-'; + } else { + uuid += hexDigits[Math.floor(Math.random() * 16)]; + } + } + uuid += '}'; + return uuid; + } // Is ref a range? isRange(ref) { return ref.indexOf(':') !== -1; diff --git a/test/crud-test.ts b/test/crud-test.ts index db3c654..b772206 100644 --- a/test/crud-test.ts +++ b/test/crud-test.ts @@ -1530,6 +1530,84 @@ describe("CRUD operations", function() { done(); }); }); + + it("copies sheets with comments", function(done) { + + fs.readFile(path.join(__dirname, "templates", "test-copy-sheet-with-comments.xlsx"), function(err, data) { + expect(err).toBeNull(); + try { + var t = new XlsxTemplate(data); + t.copySheet("Feuil1", "Feuil3", true); + var newData = t.generate(); + + // Verify that each sheet has its own comment files + var sheet1Rels = etree.parse(t.archive.file("xl/worksheets/_rels/sheet1.xml.rels").asText()).getroot(); + var sheet3Rels = etree.parse(t.archive.file("xl/worksheets/_rels/sheet3.xml.rels").asText()).getroot(); + var sheet1CommentsRel = sheet1Rels.find("./Relationship[@Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments']"); + var sheet3CommentsRel = sheet3Rels.find("./Relationship[@Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/comments']"); + var sheet1ThreadedCommentsRel = sheet1Rels.find("./Relationship[@Type='http://schemas.microsoft.com/office/2017/10/relationships/threadedComment']"); + var sheet3ThreadedCommentsRel = sheet3Rels.find("./Relationship[@Type='http://schemas.microsoft.com/office/2017/10/relationships/threadedComment']"); + var sheet1VmlDrawingRel = sheet1Rels.find("./Relationship[@Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing']"); + var sheet3VmlDrawingRel = sheet3Rels.find("./Relationship[@Type='http://schemas.openxmlformats.org/officeDocument/2006/relationships/vmlDrawing']"); + + // Relationship Target must be different + expect(sheet1CommentsRel.attrib.Target).not.toEqual(sheet3CommentsRel.attrib.Target); + expect(sheet1ThreadedCommentsRel.attrib.Target).not.toEqual(sheet3ThreadedCommentsRel.attrib.Target); + expect(sheet1VmlDrawingRel.attrib.Target).not.toEqual(sheet3VmlDrawingRel.attrib.Target); + + // Verify copied files exist with correct numeric naming + expect(t.archive.file("xl/comments3.xml")).not.toBeNull(); + expect(t.archive.file("xl/threadedComments/threadedComment3.xml")).not.toBeNull(); + expect(t.archive.file("xl/drawings/vmlDrawing3.vml")).not.toBeNull(); + + // Verify Content Types are registered + var contentTypes = etree.parse(t.archive.file("[Content_Types].xml").asText()).getroot(); + expect(contentTypes.find("./Override[@PartName='/xl/worksheets/sheet3.xml']")).not.toBeNull(); + expect(contentTypes.find("./Override[@PartName='/xl/comments3.xml']")).not.toBeNull(); + expect(contentTypes.find("./Override[@PartName='/xl/threadedComments/threadedComment3.xml']")).not.toBeNull(); + + + // Verify UUIDs are consistent across comment files + var comments3Content = t.archive.file("xl/comments3.xml").asText(); + var threadedComment3Content = t.archive.file("xl/threadedComments/threadedComment3.xml").asText(); + var threadedComment1Content = t.archive.file("xl/threadedComments/threadedComment1.xml").asText(); + + var commentsUuidMatch = comments3Content.match(/xr:uid="\{([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})\}"/i); + expect(commentsUuidMatch).not.toBeNull(); + var commentsUuid = commentsUuidMatch ? commentsUuidMatch[1] : null; + + var commentsAuthorUuidMatch = comments3Content.match(/tc=\{([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})\}<\/author>/i); + expect(commentsAuthorUuidMatch).not.toBeNull(); + var commentsAuthorUuid = commentsAuthorUuidMatch ? commentsAuthorUuidMatch[1] : null; + + var threadedCommentUuidMatch = threadedComment3Content.match(/\sid="\{([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})\}"/i); + expect(threadedCommentUuidMatch).not.toBeNull(); + var threadedCommentUuid = threadedCommentUuidMatch ? threadedCommentUuidMatch[1] : null; + + // All three UUIDs must match + expect(commentsUuid).toEqual(commentsAuthorUuid); + expect(commentsUuid).toEqual(threadedCommentUuid); + + // Verify personId is preserved from original sheet (references persons/person.xml) + var personIdMatch = threadedComment3Content.match(/personId="\{([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})\}"/i); + expect(personIdMatch).not.toBeNull(); + var personId = personIdMatch ? personIdMatch[1] : null; + expect(personId).not.toEqual(commentsUuid); + + var personId1Match = threadedComment1Content.match(/personId="\{([0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})\}"/i); + expect(personId1Match).not.toBeNull(); + var personId1 = personId1Match ? personId1Match[1] : null; + expect(personId).toEqual(personId1); + + fs.writeFileSync("test/output/copy-sheet-with-comments.xlsx", newData, "binary"); + + done(); + } catch (err) { + done(err); + } + }); + + }); }); describe("Rebuild file", function() { diff --git a/test/templates/test-copy-sheet-with-comments.xlsx b/test/templates/test-copy-sheet-with-comments.xlsx new file mode 100644 index 0000000..3f098ce Binary files /dev/null and b/test/templates/test-copy-sheet-with-comments.xlsx differ