NanoXLSX.Compatibility 3.2.0
Loading...
Searching...
No Matches
CompatibilityPreparingInlineWriteProcessor.cs
1/*
2 * NanoXLSX is a small .NET library to generate and read XLSX (Microsoft Excel 2007 or newer) files in an easy and native way
3 * Copyright Raphael Stoeckli © 2026
4 * This library is licensed under the MIT License.
5 * You find a copy of the license in project folder or on: http://opensource.org/licenses/MIT
6 */
7
8using NanoXLSX.Exceptions;
10using NanoXLSX.Interfaces.Writer;
11using NanoXLSX.Registry;
12using NanoXLSX.Registry.Attributes;
13using NanoXLSX.Utils;
14using System;
15using System.Collections.Generic;
16using System.Linq;
17
19{
23 [NanoXlsxQueuePlugIn(PlugInUUID = "MAIN_COMPATIBILITY_WRITE_INLINE_PREPARATION_PROCESSOR", QueueUUID = PlugInUUID.PreparingInlineProcessor, PlugInOrder = 2000)]
24 internal class CompatibilityPreparingInlineWriteProcessor : IPluginInlineWriteProcessor
25 {
26 #region privateFields
27 private Dictionary<int, Dictionary<string, ExternalLinkResolution>> resolvedFormulas;
28 private Dictionary<int, ExternalLinkResolution> resolvedDefinedNames;
29 #endregion
30 #region properties
34 public IWriteContext WriteContext { get; set; }
35 #endregion
36 #region methods
41 public void Init(IWriteContext context)
42 {
43 this.WriteContext = context;
44 }
45
49 public void Execute()
50 {
51 resolvedFormulas = new Dictionary<int, Dictionary<string, ExternalLinkResolution>>();
52 resolvedDefinedNames = new Dictionary<int, ExternalLinkResolution>();
53
54 if (!WriteContext.Workbook.Features.ContainsExternalLinks)
55 {
56 return; // No external links to process
57 }
58 List<ExternalLink> externalLinks = WriteContext.Workbook.AuxiliaryData
59 .GetDataList<ExternalLink>(PlugInUUID.CompatibilityInlineProcessor, CompatibilityConstants.EXTERNAL_LINK_OBJECT_ENTITY)
60 .OfType<ExternalLink>()
61 .ToList(); // Returns a null-free list
62 List<ExternalLinkCandidate> candidates = CreateExternalLinkCandidates(externalLinks);
63 if (WriteContext.Workbook.Features.ContainsWorksheetFormulas)
64 {
65 ResolveExternalLinksFromFormulas(candidates);
66 }
67 if (WriteContext.Workbook.Features.ContainsDefinedNameFormulas)
68 {
69 ResolveExternalLinksFromDefinedNames(candidates);
70 }
71 StoreResolutions();
72 // TODO If other resources contains possibly external links, add further handling here
73 }
74
84 private void ResolveExternalLinksFromFormulas(List<ExternalLinkCandidate> candidates)
85 {
86 for (int worksheetIndex = 0; worksheetIndex < WriteContext.Workbook.Worksheets.Count; worksheetIndex++)
87 {
88 Worksheet worksheet = WriteContext.Workbook.Worksheets[worksheetIndex];
89 if (!worksheet.Features.ContainsWorksheetFormulas || !worksheet.Features.ContainsExternalLinks)
90 {
91 continue; // No external links on this worksheet
92 }
93 foreach (Cell cell in worksheet.CellValues)
94 {
95 if (cell.DataType != Cell.CellType.Formula || cell.Formula == null || !cell.Formula.Features.ContainsExternalLinks)
96 {
97 continue; // No formula or no external link in formula
98 }
99
100 string expression = GetFormulaExpression(cell);
101 string cellAddress = Cell.ResolveCellAddress(cell.ColumnNumber, cell.RowNumber);
102 ExternalLinkResolution result = ResolveExpression(
103 expression,
104 new SourceInfo("cell formula", worksheet.SheetName + "!" + cellAddress),
105 candidates);
106 if (result == null)
107 {
108 continue;
109 }
110 // Note: The worksheet writer or will pass the 1-based sheetID, not the 0-based index
111 if (!resolvedFormulas.TryGetValue(worksheet.SheetID, out Dictionary<string, ExternalLinkResolution> worksheetResolutions))
112 {
113 worksheetResolutions = new Dictionary<string, ExternalLinkResolution>();
114 resolvedFormulas.Add(worksheet.SheetID, worksheetResolutions);
115 }
116 worksheetResolutions[cellAddress] = result;
117 }
118 }
119 }
120
130 private void ResolveExternalLinksFromDefinedNames(List<ExternalLinkCandidate> candidates)
131 {
132 IReadOnlyList<DefinedName> definedNames = WriteContext.Workbook.GetDefinedNames();
133 for (int i = 0; i < definedNames.Count; i++)
134 {
135 DefinedName definedName = definedNames[i];
136 if (!definedName.Features.ContainsExternalLinks)
137 {
138 continue;
139 }
140 ExternalLinkResolution result = ResolveExpression(
141 definedName.TextValue,
142 new SourceInfo("defined name", definedName.Name),
143 candidates);
144 if (result != null)
145 {
146 resolvedDefinedNames[i] = result;
147 }
148 }
149 }
150
156 private static string GetFormulaExpression(Cell cell)
157 {
158 if (cell.Formula.DefinedNameReference != null)
159 {
160 return cell.Formula.DefinedNameReference.Name;
161 }
162 return cell.Formula.Expression;
163 }
164
168 private void StoreResolutions()
169 {
170 WriteContext.Workbook.AuxiliaryData.SetData(
171 PlugInUUID.CompatibilityInlineProcessor,
172 CompatibilityConstants.EXTERNAL_LINK_RESOLVED_FORMULAS_ENTITY,
173 resolvedFormulas);
174 WriteContext.Workbook.AuxiliaryData.SetData(
175 PlugInUUID.CompatibilityInlineProcessor,
176 CompatibilityConstants.EXTERNAL_LINK_RESOLVED_DEFINED_NAMES_ENTITY,
177 resolvedDefinedNames);
178 }
179
187 private static ExternalLinkResolution ResolveExpression(
188 string expression,
189 SourceInfo sourceInfo,
190 List<ExternalLinkCandidate> candidates)
191 {
192 if (ExternalLinkFormulaUtils.DetectExternalLinkId(expression))
193 {
194 throw GetUnsupportedNumericReference(sourceInfo);
195 }
196
197 ValidateCaseInsensitiveAmbiguities(expression, sourceInfo, candidates);
198
199 string resolvedExpression = expression;
200 HashSet<int> matchedIndexes = new HashSet<int>();
201 foreach (ExternalLinkCandidate candidate in candidates)
202 {
203 if (!TryReplaceOutsideStringConstants(
204 resolvedExpression,
205 candidate.Text,
206 "[" + ParserUtils.ToString(candidate.LinkIndexes[0]) + "]",
207 out string replacedExpression))
208 {
209 continue;
210 }
211 if (candidate.LinkIndexes.Count > 1)
212 {
213 throw GetAmbiguousReference(sourceInfo, candidate.Text, candidate.LinkIndexes);
214 }
215
216 int linkIndex = candidate.LinkIndexes[0];
217 resolvedExpression = replacedExpression;
218 matchedIndexes.Add(linkIndex);
219 }
220
221 if (ExternalLinkFormulaUtils.TryFindUnresolvedExternalLink(resolvedExpression, out string unresolvedToken))
222 {
223 throw GetUnregisteredReference(sourceInfo, unresolvedToken);
224 }
225
226 if (matchedIndexes.Count == 0)
227 {
228 return null;
229 }
230 return new ExternalLinkResolution(resolvedExpression, matchedIndexes.OrderBy(index => index).ToList());
231 }
232
239 private static void ValidateCaseInsensitiveAmbiguities(
240 string expression,
241 SourceInfo sourceInfo,
242 List<ExternalLinkCandidate> candidates)
243 {
244 foreach (IGrouping<string, ExternalLinkCandidate> group in candidates.GroupBy(
245 candidate => candidate.Text,
246 StringComparer.OrdinalIgnoreCase))
247 {
248 List<int> indexes = group
249 .SelectMany(candidate => candidate.LinkIndexes)
250 .Distinct()
251 .OrderBy(index => index)
252 .ToList();
253 if (indexes.Count < 2)
254 {
255 continue;
256 }
257
258 string representative = group.First().Text;
259 int position = 0;
260 while ((position = IndexOfOutsideStringConstants(
261 expression,
262 representative,
263 position,
264 StringComparison.OrdinalIgnoreCase)) >= 0)
265 {
266 string matchedText = expression.Substring(position, representative.Length);
267 bool hasExactCandidate = group.Any(candidate =>
268 string.Equals(candidate.Text, matchedText, StringComparison.Ordinal));
269 if (!hasExactCandidate)
270 {
271 throw GetAmbiguousReference(sourceInfo, matchedText, indexes);
272 }
273 position += representative.Length;
274 }
275 }
276 }
277
285 private static NotSupportedContentException GetAmbiguousReference(SourceInfo sourceInfo, string matchedText, IEnumerable<int> linkIndexes)
286 {
287 string indexes = string.Join(", ", linkIndexes.Select(ParserUtils.ToString));
288 return new NotSupportedContentException(
289 "The " + sourceInfo.SourceKind + " '" + sourceInfo.SourceIdentifier + "' contains the ambiguous external link '" +
290 matchedText + "', which matches external link indexes " + indexes + ".");
291 }
292
296 private static NotSupportedContentException GetUnsupportedNumericReference(SourceInfo sourceInfo)
297 {
298 return new NotSupportedContentException(
299 "The " + sourceInfo.SourceKind + " '" + sourceInfo.SourceIdentifier +
300 "' contains a numeric OOXML external-link identifier. Use a human-readable external workbook reference and register the workbook with WorkbookExtensions.AddExternalLink before saving.");
301 }
302
306 private static NotSupportedContentException GetUnregisteredReference(SourceInfo sourceInfo, string unresolvedToken)
307 {
308 return new NotSupportedContentException(
309 "The " + sourceInfo.SourceKind + " '" + sourceInfo.SourceIdentifier + "' contains the unregistered external link '" +
310 unresolvedToken + "'. Register the external workbook with WorkbookExtensions.AddExternalLink before saving.");
311 }
312
316 private static bool TryReplaceOutsideStringConstants(
317 string expression,
318 string oldValue,
319 string newValue,
320 out string result)
321 {
322 System.Text.StringBuilder builder = null;
323 bool insideStringConstant = false;
324 int unchangedSectionStart = 0;
325 for (int i = 0; i < expression.Length; i++)
326 {
327 char current = expression[i];
328 if (current == '"')
329 {
330 if (insideStringConstant && i + 1 < expression.Length && expression[i + 1] == '"')
331 {
332 i++;
333 continue;
334 }
335 insideStringConstant = !insideStringConstant;
336 continue;
337 }
338 if (insideStringConstant || i + oldValue.Length > expression.Length ||
339 string.CompareOrdinal(expression, i, oldValue, 0, oldValue.Length) != 0 ||
340 !HasValidCandidatePrefix(expression, i, oldValue))
341 {
342 continue;
343 }
344
345 if (builder == null)
346 {
347 builder = new System.Text.StringBuilder(expression.Length);
348 }
349 builder.Append(expression, unchangedSectionStart, i - unchangedSectionStart);
350 builder.Append(newValue);
351 i += oldValue.Length - 1;
352 unchangedSectionStart = i + 1;
353 }
354
355 if (builder == null)
356 {
357 result = expression;
358 return false;
359 }
360 builder.Append(expression, unchangedSectionStart, expression.Length - unchangedSectionStart);
361 result = builder.ToString();
362 return true;
363 }
364
368 private static int IndexOfOutsideStringConstants(
369 string expression,
370 string value,
371 int startIndex,
372 StringComparison comparison)
373 {
374 bool insideStringConstant = false;
375 for (int i = 0; i + value.Length <= expression.Length; i++)
376 {
377 char current = expression[i];
378 if (current == '"')
379 {
380 if (insideStringConstant && i + 1 < expression.Length && expression[i + 1] == '"')
381 {
382 i++;
383 continue;
384 }
385 insideStringConstant = !insideStringConstant;
386 continue;
387 }
388 if (i >= startIndex && !insideStringConstant &&
389 string.Compare(expression, i, value, 0, value.Length, comparison) == 0 &&
390 HasValidCandidatePrefix(expression, i, value))
391 {
392 return i;
393 }
394 }
395 return -1;
396 }
397
401 private static bool HasValidCandidatePrefix(string expression, int matchIndex, string candidate)
402 {
403 if (matchIndex == 0 || candidate.Length == 0 || candidate[0] != '[')
404 {
405 return true;
406 }
407
408 char previous = expression[matchIndex - 1];
409 return !char.IsLetterOrDigit(previous) && previous != '_' && previous != '.' &&
410 previous != ':' && previous != '/' && previous != '\\';
411 }
412
418 private static List<ExternalLinkCandidate> CreateExternalLinkCandidates(List<ExternalLink> externalLinks)
419 {
420 Dictionary<string, HashSet<int>> candidates = new Dictionary<string, HashSet<int>>(StringComparer.Ordinal);
421
422 for (int i = 0; i < externalLinks.Count; i++)
423 {
424 ExternalLink externalLink = externalLinks[i];
425 int linkIndex = i + 1;
426 HashSet<string> linkCandidates = new HashSet<string>(StringComparer.Ordinal);
427 foreach (string uri in externalLink.GetWorkbookLocations())
428 {
429 foreach (string candidate in CreateUriCandidates(uri))
430 {
431 linkCandidates.Add(candidate);
432 }
433 }
434 string readableToken = externalLink.ReadableReferenceToken;
435 if (!string.IsNullOrEmpty(readableToken))
436 {
437 linkCandidates.Add(readableToken);
438 linkCandidates.Add(readableToken.Replace('\\', '/'));
439 linkCandidates.Add(readableToken.Replace('/', '\\'));
440 }
441 foreach (string candidate in linkCandidates)
442 {
443 if (!candidates.TryGetValue(candidate, out HashSet<int> indexes))
444 {
445 indexes = new HashSet<int>();
446 candidates[candidate] = indexes;
447 }
448 indexes.Add(linkIndex);
449 }
450 }
451
452 return candidates
453 .Select(pair => new ExternalLinkCandidate(
454 pair.Key,
455 pair.Value.OrderBy(index => index).ToList()))
456 .OrderByDescending(candidate => candidate.Text.Length)
457 .ThenBy(candidate => candidate.Text, StringComparer.Ordinal)
458 .ToList();
459 }
460
466 private static HashSet<string> CreateUriCandidates(string uriText)
467 {
468 HashSet<string> candidates = new HashSet<string>(StringComparer.Ordinal);
469 string value = uriText.Trim(); // Should already be sanitized
470
471 bool isAbsoluteUri = Uri.TryCreate(value, UriKind.Absolute, out Uri uri);
472
473 if (isAbsoluteUri && uri.IsFile)
474 {
475 string filePath = GetFilePathCandidate(uri);
476 AddPathCandidates(candidates, filePath, true);
477 return candidates;
478 }
479
480 // For relative paths or strings that are not valid absolute URIs, create slash and backslash aliases.
481 // For absolute non-file URIs, preserve the original separator form.
482 bool addSeparatorAliases = !isAbsoluteUri;
483 AddPathCandidates(candidates, value, addSeparatorAliases);
484 return candidates;
485 }
486
494 private static string GetFilePathCandidate(Uri uri)
495 {
496 // AbsolutePath is used instead of processing both LocalPath and AbsolutePath.
497 // This avoids generating mostly redundant candidates. AbsolutePath is still URI-escaped and must therefore be decoded.
498 string path = Uri.UnescapeDataString(uri.AbsolutePath).Replace('\\', '/');
499
500 bool isRemoteFile = !string.IsNullOrEmpty(uri.Host) && !uri.IsLoopback;
501
502 if (isRemoteFile)
503 {
504 string remotePath = path.TrimStart('/');
505
506 return "//" + uri.Host + "/" + remotePath;
507 }
508
509 // A Windows drive path in a file URI commonly has this form: "/C:/directory/file.xlsx". Remove the URI-specific leading slash.
510 if (path.Length >= 3 && path[0] == '/' && char.IsLetter(path[1]) && path[2] == ':')
511 {
512 path = path.Substring(1);
513 }
514 return path;
515 }
516
525 private static void AddPathCandidates(HashSet<string> candidates, string path, bool addSeparatorAliases)
526 {
527 string formulaPath = ToFormulaPath(path);
528
529 candidates.Add(formulaPath);
530 if (!addSeparatorAliases)
531 {
532 return;
533 }
534 candidates.Add(formulaPath.Replace('\\', '/'));
535 candidates.Add(formulaPath.Replace('/', '\\'));
536 }
537
545 private static string ToFormulaPath(string path)
546 {
547 string value = path.Trim();
548
549 int separator = Math.Max(value.LastIndexOf('/'), value.LastIndexOf('\\'));
550
551 string directory;
552 string filename;
553
554 if (separator >= 0)
555 {
556 directory = value.Substring(0, separator + 1);
557 filename = value.Substring(separator + 1);
558 }
559 else
560 {
561 directory = string.Empty;
562 filename = value;
563 }
564
565 if (filename[0] == '[' && filename[filename.Length - 1] == ']')
566 {
567 return value;
568 }
569
570 return directory + "[" + filename + "]";
571 }
572 #endregion
573
574 #region helperClasses
575
579 private sealed class ExternalLinkCandidate
580 {
584 public string Text { get; }
585 // Indices where external links may start and end
586 public List<int> LinkIndexes { get; }
587
593 public ExternalLinkCandidate(string text, List<int> linkIndexes)
594 {
595 Text = text;
596 LinkIndexes = linkIndexes;
597 }
598 }
599
603 private sealed class SourceInfo
604 {
608 public string SourceKind { get; }
612 public string SourceIdentifier { get; }
613
619 public SourceInfo(string sourceKind, string sourceIdentifier)
620 {
621 this.SourceKind = sourceKind;
622 this.SourceIdentifier = sourceIdentifier;
623 }
624 }
625 #endregion
626
627 }
628}