From 8c6e0b154ac07c1a1d7b27b3a82f568d9c049ac3 Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 13 Aug 2026 12:04:56 -0400 Subject: [PATCH 1/2] LT-22697: Normalize the range-element ids written to LIFT ranges FieldWorks holds strings as NFD and normalizes them to NFC when it exports LIFT, but several writes in the ranges file bypass the normalizing helper and emit the NFD unchanged. The part-of-speech and lexical-relation range-element ids go out through XmlUtils.MakeSafeXmlAttribute while the parent attribute of the same element, two lines away, goes through MakeSafeAndNormalizedAttribute. One name then appears in two encodings inside a single element, and a consumer that resolves a range value by string equality - a LIFT importer, a Send/Receive merge - fails to match it. Exports carrying it have been seen from 8.3.12 and 9.1.15. Route those writes through MakeSafeAndNormalizedAttribute: the part-of-speech and lexical-relation ids, the morph-type id, and the leading-symbol, trailing-symbol, and stem-name feature-set traits. The morph-type id was written with no XML escaping either, so a morph type whose name contains an ampersand or an angle bracket produced a file no conforming parser can read; the same helper escapes it. The two writes left unnormalized in the ranges output are writing system tags, where normalizing is a no-op. Co-authored-by: Claude Opus 5 (1M context) --- Src/LexText/LexTextControls/LiftExporter.cs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/Src/LexText/LexTextControls/LiftExporter.cs b/Src/LexText/LexTextControls/LiftExporter.cs index dfb2807aa5..9fc44d9a0e 100644 --- a/Src/LexText/LexTextControls/LiftExporter.cs +++ b/Src/LexText/LexTextControls/LiftExporter.cs @@ -1735,13 +1735,13 @@ private void WritePartOfSpeechRangeElement(TextWriter w, IPartOfSpeech pos) { var liftIdOwner = ((IPartOfSpeech)(pos.Owner)).Name.BestAnalysisVernacularAlternative.Text; w.WriteLine("", - XmlUtils.MakeSafeXmlAttribute(liftId), pos.Guid, + MakeSafeAndNormalizedAttribute(liftId), pos.Guid, MakeSafeAndNormalizedAttribute(liftIdOwner)); } else { w.WriteLine("", - XmlUtils.MakeSafeXmlAttribute(liftId), pos.Guid); + MakeSafeAndNormalizedAttribute(liftId), pos.Guid); } WriteAllForms(w, "label", null, "form", pos.Name); WriteAllForms(w, "abbrev", null, "form", pos.Abbreviation); @@ -1772,13 +1772,13 @@ private void WriteLexRefType(TextWriter w, ILexRefType refer) { var liftIdOwner = ((ILexRefType)refer.Owner).Name.BestAnalysisVernacularAlternative.Text; w.WriteLine("", - XmlUtils.MakeSafeXmlAttribute(liftId), refer.Guid, + MakeSafeAndNormalizedAttribute(liftId), refer.Guid, MakeSafeAndNormalizedAttribute(liftIdOwner)); } else { w.WriteLine("", - XmlUtils.MakeSafeXmlAttribute(liftId), refer.Guid); + MakeSafeAndNormalizedAttribute(liftId), refer.Guid); } WriteAllForms(w, "label", null, "form", refer.Name); WriteAllForms(w, "abbrev", null, "form", refer.Abbreviation); @@ -2170,19 +2170,19 @@ void WriteMorphTypeRange(TextWriter w) var liftId = type.Name.get_String(m_wsEn).Text; if (String.IsNullOrEmpty(liftId)) liftId = type.Name.BestAnalysisVernacularAlternative.Text; - w.WriteLine("", liftId, type.Guid); + w.WriteLine("", MakeSafeAndNormalizedAttribute(liftId), type.Guid); WriteAllForms(w, "label", null, "form", type.Name); WriteAllForms(w, "abbrev", null, "form", type.Abbreviation); WriteAllForms(w, "description", null, "form", type.Description); if (type.Prefix != null) { w.WriteLine("", - XmlUtils.MakeSafeXmlAttribute(type.Prefix)); + MakeSafeAndNormalizedAttribute(type.Prefix)); } if (type.Postfix != null) { w.WriteLine("", - XmlUtils.MakeSafeXmlAttribute(type.Postfix)); + MakeSafeAndNormalizedAttribute(type.Postfix)); } w.WriteLine(""); } @@ -2291,7 +2291,7 @@ void WriteStemNameRanges(TextWriter w) foreach (var region in stem.RegionsOC) { w.WriteLine("", - XmlUtils.MakeSafeXmlAttribute(region.LiftName)); + MakeSafeAndNormalizedAttribute(region.LiftName)); } w.WriteLine(""); } From 8f72ebfe7d8a26b2c31199ef893565e3fc8e14aa Mon Sep 17 00:00:00 2001 From: Danny Rorabaugh Date: Thu, 13 Aug 2026 14:11:42 -0400 Subject: [PATCH 2/2] LT-22697: Add ranges export tests for normalization and escaping Two cases the ranges export got wrong, neither covered before. A part of speech whose name carries a combining diacritic: the range-element id has to agree with the parent attribute naming it and with its own label, all three being the same name. A morph type whose name holds an ampersand: the ranges document has to parse at all, and the id has to read back as the name stored. Each covers one of the writes the previous commit routed through MakeSafeAndNormalizedAttribute. Co-authored-by: Claude Opus 5 (1M context) --- .../LexTextControlsTests/LiftExportTests.cs | 80 +++++++++++++++++++ 1 file changed, 80 insertions(+) diff --git a/Src/LexText/LexTextControls/LexTextControlsTests/LiftExportTests.cs b/Src/LexText/LexTextControls/LexTextControlsTests/LiftExportTests.cs index abe90cc5a7..d64bc01a0f 100644 --- a/Src/LexText/LexTextControls/LexTextControlsTests/LiftExportTests.cs +++ b/Src/LexText/LexTextControls/LexTextControlsTests/LiftExportTests.cs @@ -1983,6 +1983,86 @@ public void LiftExportRanges_PartOfSpeechCatalogIdIsExported() AssertThatXmlIn.Dom(xdocRangeFile).HasAtLeastOneMatchForXpath("//range[@id='grammatical-info']/range-element/trait[@name='catalog-source-id']"); } + ///-------------------------------------------------------------------------------------- + /// + /// LT-22697: FLEx holds names decomposed and normalizes them on export. A + /// range-element id must be normalized like the parent attribute naming it and like + /// its own label, or a consumer comparing raw strings cannot resolve the reference. + /// + ///-------------------------------------------------------------------------------------- + [Test] + public void LiftExportRanges_PartOfSpeechIdIsNormalizedLikeItsParentAndLabel() + { + const string ksDecomposed = "Comple\u0301ments"; // e + U+0301 + const string ksComposed = "Compl\u00e9ments"; // U+00E9 + IPartOfSpeech parentPos = null; + IPartOfSpeech childPos = null; + NonUndoableUnitOfWorkHelper.Do(m_cache.ActionHandlerAccessor, () => + { + var posFactory = m_cache.ServiceLocator.GetInstance(); + parentPos = posFactory.Create(); + m_cache.LangProject.PartsOfSpeechOA.PossibilitiesOS.Add(parentPos); + parentPos.Name.set_String(m_cache.DefaultAnalWs, ksDecomposed); + childPos = posFactory.Create(); + parentPos.SubPossibilitiesOS.Add(childPos); + childPos.Name.set_String(m_cache.DefaultAnalWs, "Comple\u0301ment du lieu"); + }); + var xdocRangeFile = new XmlDocument(); + using (var w = new StringWriter()) + { + // SUT + new LiftExporter(m_cache).ExportLiftRanges(w); + xdocRangeFile.LoadXml(w.ToString()); + } + + var parentElement = xdocRangeFile.SelectSingleNode(string.Format( + "//range[@id='grammatical-info']/range-element[@guid='{0}']", parentPos.Guid)); + var childElement = xdocRangeFile.SelectSingleNode(string.Format( + "//range[@id='grammatical-info']/range-element[@guid='{0}']", childPos.Guid)); + Assert.That(parentElement, Is.Not.Null); + Assert.That(childElement, Is.Not.Null); + var sId = parentElement.Attributes["id"].Value; + Assert.That(sId, Is.EqualTo(ksComposed), + "the id must be normalized, not the decomposed form held in memory"); + Assert.That(childElement.Attributes["parent"].Value, Is.EqualTo(sId), + "the parent attribute must match the id it names"); + Assert.That(parentElement.SelectSingleNode("label/form/text").InnerText, Is.EqualTo(sId), + "the label must match the id of its own element"); + } + + ///-------------------------------------------------------------------------------------- + /// + /// LT-22697: the morph-type id was written raw, so a name holding a markup character + /// left the whole ranges document unparseable. + /// + ///-------------------------------------------------------------------------------------- + [Test] + public void LiftExportRanges_MorphTypeIdWithMarkupCharacterIsEscaped() + { + const string ksName = "prefix & suffix"; + IMoMorphType morphType = null; + NonUndoableUnitOfWorkHelper.Do(m_cache.ActionHandlerAccessor, () => + { + morphType = m_cache.ServiceLocator.GetInstance().Create(); + m_cache.LangProject.LexDbOA.MorphTypesOA.PossibilitiesOS.Add(morphType); + morphType.Name.set_String(m_cache.DefaultAnalWs, ksName); + }); + var xdocRangeFile = new XmlDocument(); + using (var w = new StringWriter()) + { + // SUT + new LiftExporter(m_cache).ExportLiftRanges(w); + Assert.That(() => xdocRangeFile.LoadXml(w.ToString()), Throws.Nothing, + "an unescaped id leaves the whole ranges document unparseable"); + } + + var element = xdocRangeFile.SelectSingleNode(string.Format( + "//range[@id='morph-type']/range-element[@guid='{0}']", morphType.Guid)); + Assert.That(element, Is.Not.Null); + Assert.That(element.Attributes["id"].Value, Is.EqualTo(ksName), + "the parsed id must be the name as stored, escaping undone"); + } + private int m_flidLongText; private void AddStTextCustomFieldAndData()