@@ -57,9 +57,18 @@ public ResourceFile Read(TextReader reader, LanguageInfo metadata)
5757 {
5858 var content = reader . ReadToEnd ( ) ;
5959
60- // Parse the document - use XDocument.Parse for string content
61- // This handles encoding properly for strings (avoids UTF-8/UTF-16 BOM issues)
62- var doc = XDocument . Parse ( content , LoadOptions . PreserveWhitespace ) ;
60+ // Use secure XML settings to prevent XXE attacks
61+ var settings = new XmlReaderSettings
62+ {
63+ DtdProcessing = DtdProcessing . Prohibit ,
64+ XmlResolver = null
65+ } ;
66+
67+ XDocument doc ;
68+ using ( var xmlReader = XmlReader . Create ( new StringReader ( content ) , settings ) )
69+ {
70+ doc = XDocument . Load ( xmlReader , LoadOptions . PreserveWhitespace ) ;
71+ }
6372
6473 // Detect version from the parsed document (more reliable than stream-based detection)
6574 var version = DetectVersionFromDocument ( doc ) ;
@@ -86,9 +95,9 @@ private string DetectVersionFromDocument(XDocument doc)
8695
8796 // Check namespace
8897 var ns = root . GetDefaultNamespace ( ) . NamespaceName ;
89- if ( ns . Contains ( "2.0" ) )
98+ if ( ns == XliffVersionDetector . Xliff20Namespace )
9099 return "2.0" ;
91- if ( ns . Contains ( "1.2" ) )
100+ if ( ns == XliffVersionDetector . Xliff12Namespace )
92101 return "1.2" ;
93102
94103 // Check version attribute
@@ -105,7 +114,7 @@ private string DetectVersionFromDocument(XDocument doc)
105114 if ( root . Attribute ( "srcLang" ) != null )
106115 return "2.0" ;
107116
108- return "1.2 " ; // Default to 1.2
117+ return "unknown " ; // Let caller decide how to handle
109118 }
110119
111120 /// <summary>
@@ -223,7 +232,7 @@ private List<ResourceEntry> ParseXliff12(XDocument doc, LanguageInfo metadata)
223232 // Always prefer target value (which contains the localized text)
224233 var pluralValue = target ?? source ;
225234
226- if ( ! string . IsNullOrEmpty ( pluralId ) && pluralValue != null )
235+ if ( ! string . IsNullOrEmpty ( pluralId ) && ! string . IsNullOrEmpty ( pluralValue ) )
227236 {
228237 // Extract plural category from id (e.g., "key[one]", "key[other]")
229238 var category = ExtractPluralCategory ( pluralId ) ;
@@ -244,7 +253,10 @@ private List<ResourceEntry> ParseXliff12(XDocument doc, LanguageInfo metadata)
244253 return new ResourceEntry
245254 {
246255 Key = id ,
247- Value = pluralForms . GetValueOrDefault ( "other" ) ?? pluralForms . Values . FirstOrDefault ( ) ,
256+ Value = pluralForms . GetValueOrDefault ( "other" )
257+ ?? pluralForms . GetValueOrDefault ( "one" )
258+ ?? pluralForms . Values . FirstOrDefault ( )
259+ ?? "" ,
248260 Comment = string . IsNullOrEmpty ( comment ) ? null : comment ,
249261 IsPlural = true ,
250262 PluralForms = pluralForms
@@ -282,12 +294,24 @@ private List<ResourceEntry> ParseXliff20(XDocument doc, LanguageInfo metadata)
282294 var groups = fileElement . Elements ( ns + "group" ) ;
283295 foreach ( var group in groups )
284296 {
285- var nestedUnits = group . Descendants ( ns + "unit" ) ;
286- foreach ( var unit in nestedUnits )
297+ // Check if this is a plural group (has multiple units with same base key)
298+ var groupUnits = group . Elements ( ns + "unit" ) . ToList ( ) ;
299+ if ( groupUnits . Count > 1 && IsPluralGroup ( groupUnits ) )
287300 {
288- var entry = ParseUnit20 ( unit , ns , metadata ) ;
289- if ( entry != null )
290- entries . Add ( entry ) ;
301+ var pluralEntry = ParsePluralGroup20 ( group , ns , metadata ) ;
302+ if ( pluralEntry != null )
303+ entries . Add ( pluralEntry ) ;
304+ }
305+ else
306+ {
307+ // Regular group - parse nested units
308+ var nestedUnits = group . Descendants ( ns + "unit" ) ;
309+ foreach ( var unit in nestedUnits )
310+ {
311+ var entry = ParseUnit20 ( unit , ns , metadata ) ;
312+ if ( entry != null )
313+ entries . Add ( entry ) ;
314+ }
291315 }
292316 }
293317 }
@@ -356,26 +380,117 @@ private List<ResourceEntry> ParseXliff20(XDocument doc, LanguageInfo metadata)
356380 /// Extracts the plural category from an ID like "key[one]" or "key_plural_one".
357381 /// </summary>
358382 private static string ? ExtractPluralCategory ( string id )
383+ {
384+ return ExtractPluralCategoryWithBase ( id ) . category ;
385+ }
386+
387+ /// <summary>
388+ /// Extracts both the base name and plural category from an ID like "key[one]" or "key_plural_one".
389+ /// Returns (baseName, category) or (id, null) if not a plural suffix key.
390+ /// </summary>
391+ private static ( string baseName , string ? category ) ExtractPluralCategoryWithBase ( string id )
359392 {
360393 // Pattern 1: key[category]
361394 var bracketStart = id . LastIndexOf ( '[' ) ;
362395 var bracketEnd = id . LastIndexOf ( ']' ) ;
363- if ( bracketStart >= 0 && bracketEnd > bracketStart )
396+ // Validate: brackets must be in correct order and have content between them
397+ if ( bracketStart >= 0 && bracketEnd > bracketStart + 1 )
364398 {
365- return id . Substring ( bracketStart + 1 , bracketEnd - bracketStart - 1 ) ;
399+ var baseName = id . Substring ( 0 , bracketStart ) ;
400+ var category = id . Substring ( bracketStart + 1 , bracketEnd - bracketStart - 1 ) ;
401+ return ( baseName , category ) ;
366402 }
367403
368404 // Pattern 2: key_plural_category
369405 var categories = new [ ] { "zero" , "one" , "two" , "few" , "many" , "other" } ;
370406 foreach ( var cat in categories )
371407 {
372- if ( id . EndsWith ( $ "_{ cat } ", StringComparison . OrdinalIgnoreCase ) ||
373- id . EndsWith ( $ "_plural_{ cat } ", StringComparison . OrdinalIgnoreCase ) )
408+ if ( id . EndsWith ( $ "_{ cat } ", StringComparison . OrdinalIgnoreCase ) )
409+ {
410+ return ( id . Substring ( 0 , id . Length - cat . Length - 1 ) , cat ) ;
411+ }
412+ if ( id . EndsWith ( $ "_plural_{ cat } ", StringComparison . OrdinalIgnoreCase ) )
374413 {
375- return cat ;
414+ return ( id . Substring ( 0 , id . Length - cat . Length - 8 ) , cat ) ;
376415 }
377416 }
378417
379- return null ;
418+ return ( id , null ) ;
419+ }
420+
421+ /// <summary>
422+ /// Checks if a group's units represent a plural entry.
423+ /// Returns true if all units share the same base key and there are multiple units.
424+ /// </summary>
425+ private bool IsPluralGroup ( List < XElement > units )
426+ {
427+ if ( units . Count <= 1 )
428+ return false ;
429+
430+ // Extract base keys (remove [category] suffix)
431+ var baseKeys = units
432+ . Select ( u => u . Attribute ( "id" ) ? . Value )
433+ . Where ( id => ! string . IsNullOrEmpty ( id ) )
434+ . Select ( id => ExtractPluralCategoryWithBase ( id ! ) . baseName )
435+ . Distinct ( )
436+ . ToList ( ) ;
437+
438+ // If all units share the same base key and there are multiple units, it's a plural group
439+ return baseKeys . Count == 1 && units . Count > 1 ;
440+ }
441+
442+ /// <summary>
443+ /// Parses a plural group in XLIFF 2.0.
444+ /// </summary>
445+ private ResourceEntry ? ParsePluralGroup20 ( XElement group , XNamespace ns , LanguageInfo metadata )
446+ {
447+ var id = group . Attribute ( "id" ) ? . Value ?? group . Attribute ( "name" ) ? . Value ;
448+ if ( string . IsNullOrEmpty ( id ) )
449+ return null ;
450+
451+ var pluralForms = new Dictionary < string , string > ( ) ;
452+ var units = group . Elements ( ns + "unit" ) . ToList ( ) ;
453+
454+ foreach ( var unit in units )
455+ {
456+ var unitId = unit . Attribute ( "id" ) ? . Value ;
457+ if ( string . IsNullOrEmpty ( unitId ) )
458+ continue ;
459+
460+ // Extract category from ID like "key[one]" or "key[other]"
461+ var ( baseName , category ) = ExtractPluralCategoryWithBase ( unitId ) ;
462+ if ( string . IsNullOrEmpty ( category ) )
463+ continue ;
464+
465+ // Get target value from segment
466+ var segment = unit . Element ( ns + "segment" ) ;
467+ var target = segment ? . Element ( ns + "target" ) ? . Value ;
468+ var source = segment ? . Element ( ns + "source" ) ? . Value ;
469+
470+ pluralForms [ category ] = target ?? source ?? "" ;
471+ }
472+
473+ if ( pluralForms . Count == 0 )
474+ return null ;
475+
476+ // Get notes from group
477+ var notesElement = group . Element ( ns + "notes" ) ;
478+ var notes = notesElement ? . Elements ( ns + "note" )
479+ . Select ( n => n . Value )
480+ . Where ( n => ! string . IsNullOrEmpty ( n ) )
481+ ?? Enumerable . Empty < string > ( ) ;
482+ var comment = string . Join ( "\n " , notes ) ;
483+
484+ return new ResourceEntry
485+ {
486+ Key = id ,
487+ Value = pluralForms . GetValueOrDefault ( "other" )
488+ ?? pluralForms . GetValueOrDefault ( "one" )
489+ ?? pluralForms . Values . FirstOrDefault ( )
490+ ?? "" ,
491+ Comment = string . IsNullOrEmpty ( comment ) ? null : comment ,
492+ IsPlural = true ,
493+ PluralForms = pluralForms
494+ } ;
380495 }
381496}
0 commit comments