diff --git a/src/OriginalCircuit.Altium.Generators/AltiumRecordGenerator.cs b/src/OriginalCircuit.Altium.Generators/AltiumRecordGenerator.cs index 9d490ef..faa648d 100644 --- a/src/OriginalCircuit.Altium.Generators/AltiumRecordGenerator.cs +++ b/src/OriginalCircuit.Altium.Generators/AltiumRecordGenerator.cs @@ -293,10 +293,14 @@ public static ParameterCollection Parse(string data) var key = entry.Substring(0, equalsIndex); var value = equalsIndex < entry.Length - 1 ? entry.Substring(equalsIndex + 1) : """"; - // Handle UTF-8 prefix + // Handle UTF-8 prefix: the surrounding block was decoded as Windows-1252, so a + // %UTF8%-keyed value arrives one char per raw byte (mojibake). Decode it here so + // the UTF-8 variant — which Altium writes before its plain-ANSI twin, and which + // the first-match indexer therefore returns — carries the correct string. if (key.StartsWith(""%UTF8%"", StringComparison.OrdinalIgnoreCase)) { key = key.Substring(6); + value = global::OriginalCircuit.Altium.Serialization.AltiumEncoding.DecodeUtf8ParameterValue(value); } result._parameters.Add(new KeyValuePair(key, value.TrimEnd('\r', '\n'))); diff --git a/src/OriginalCircuit.Altium.Rendering.Core/SchComponentRenderer.cs b/src/OriginalCircuit.Altium.Rendering.Core/SchComponentRenderer.cs index 59df012..282cba3 100644 --- a/src/OriginalCircuit.Altium.Rendering.Core/SchComponentRenderer.cs +++ b/src/OriginalCircuit.Altium.Rendering.Core/SchComponentRenderer.cs @@ -751,7 +751,7 @@ public void RenderTextFrame(IRenderContext context, SchTextFrame textFrame) if (!string.IsNullOrEmpty(textFrame.Text)) { - var frameText = FixTextEncoding(textFrame.Text); + var frameText = textFrame.Text; var textColor = ColorHelper.BgrToArgb(textFrame.TextColor); var font = GetFont(textFrame.FontId); var fontSize = GetFontSize(textFrame.FontId); @@ -963,7 +963,7 @@ public void RenderJunction(IRenderContext context, SchJunction junction) public void RenderNetLabel(IRenderContext context, SchNetLabel netLabel) { if (string.IsNullOrEmpty(netLabel.Text)) return; - var netText = FixTextEncoding(netLabel.Text); + var netText = netLabel.Text; var (sx, sy) = _transform.WorldToScreen(netLabel.Location.X, netLabel.Location.Y); var color = GetArgbColor(netLabel.Color); @@ -1808,7 +1808,7 @@ public void RenderHarnessConnector(IRenderContext context, SchHarnessConnector c var font = GetFont(tl.FontId); // Altium anchors the harness-type label at the RIGHT of the text (the label's Location is // its right edge), so the name extends leftward from there. - context.DrawText(FixTextEncoding(tl.Text), tx, ty, GetFontSize(tl.FontId), tcolor, + context.DrawText(tl.Text, tx, ty, GetFontSize(tl.FontId), tcolor, new TextRenderOptions { FontFamily = font.FontName, @@ -1844,7 +1844,7 @@ private void RenderHarnessEntry(IRenderContext context, SchHarnessConnector conn // members inside the connector box). double margin = _transform.ScaleValue(Coord.FromMils(15)); double textX = right ? boxX + boxW - margin : boxX + margin; - context.DrawText(FixTextEncoding(entry.Text), textX, py, fontSize, textColor, + context.DrawText(entry.Text, textX, py, fontSize, textColor, new TextRenderOptions { FontFamily = font.FontName, @@ -2003,7 +2003,7 @@ private void RenderOverlines(IRenderContext context, List - private string ResolveStringIndirection(string text) => FixTextEncoding(Resolve(text)); + private string ResolveStringIndirection(string text) => Resolve(text); private string Resolve(string text) { @@ -2072,9 +2072,10 @@ private bool TryResolveComputedString(string name, out string value) } /// - /// Repairs UTF-8 parameter values that were decoded as Windows-1252 (Altium stores some text, - /// e.g. "µF", UTF-8-encoded behind a %UTF8% marker the reader doesn't decode, so "µ" arrives as - /// "µ"). Re-interprets the Latin-1 bytes as UTF-8 when that yields a valid string. + /// Repairs UTF-8 text that was decoded as Windows-1252, so "µ" arrives as "µ". Parameter-string + /// values are decoded by ParameterCollection.Parse (%UTF8% keys), so this remains only for pin + /// names, which come from binary pin records with no encoding marker. Re-interprets the Latin-1 + /// bytes as UTF-8 when that yields a valid string. /// internal static string FixTextEncoding(string text) { diff --git a/src/OriginalCircuit.Altium/Serialization/Readers/SchDocReader.cs b/src/OriginalCircuit.Altium/Serialization/Readers/SchDocReader.cs index bda55c7..3caec07 100644 --- a/src/OriginalCircuit.Altium/Serialization/Readers/SchDocReader.cs +++ b/src/OriginalCircuit.Altium/Serialization/Readers/SchDocReader.cs @@ -1054,9 +1054,10 @@ private static SchJunction CreateJunction(ParameterCollection p) private static SchParameter CreateParameter(ParameterCollection p, Dictionary? rawParameters = null) { var dto = Dto.Sch.SchParameterDto.FromParameters(p); + // ParameterCollection.Parse already decoded a %UTF8%-keyed Text value; the raw dictionary + // keeps the prefixed key, so detect it here only to preserve the prefix on write. var isUtf8 = rawParameters?.ContainsKey("%UTF8%TEXT") == true; var value = dto.Text ?? string.Empty; - if (isUtf8) value = AltiumEncoding.DecodeUtf8ParameterValue(value); return new SchParameter { Name = dto.Name ?? string.Empty, diff --git a/src/OriginalCircuit.Altium/Serialization/Readers/SchLibReader.cs b/src/OriginalCircuit.Altium/Serialization/Readers/SchLibReader.cs index bc66aea..db208b6 100644 --- a/src/OriginalCircuit.Altium/Serialization/Readers/SchLibReader.cs +++ b/src/OriginalCircuit.Altium/Serialization/Readers/SchLibReader.cs @@ -1675,11 +1675,10 @@ private static SchParameter CreateParameter(Dictionary parameter var paramCollection = ToParameterCollection(parameters); var dto = SchParameterDto.FromParameters(paramCollection); - // The Text value is UTF-8 when its key carried the %UTF8% prefix; the dictionary keeps the - // raw key, so detect it here and decode the (otherwise Windows-1252-mis-decoded) value. + // ParameterCollection.Parse already decoded a %UTF8%-keyed Text value; the dictionary keeps + // the raw key, so detect the prefix here only to preserve it on write (TextIsUtf8). var isUtf8 = parameters.ContainsKey("%UTF8%Text"); var value = dto.Text ?? string.Empty; - if (isUtf8) value = AltiumEncoding.DecodeUtf8ParameterValue(value); return new SchParameter { diff --git a/src/OriginalCircuit.Altium/Serialization/Writers/SchLibWriter.cs b/src/OriginalCircuit.Altium/Serialization/Writers/SchLibWriter.cs index 015b504..88fff42 100644 --- a/src/OriginalCircuit.Altium/Serialization/Writers/SchLibWriter.cs +++ b/src/OriginalCircuit.Altium/Serialization/Writers/SchLibWriter.cs @@ -368,9 +368,14 @@ internal static void WriteComponentRecord(BinaryFormatWriter writer, SchComponen }; // Altium omits ComponentDescription entirely when a component has no description. Writing an // empty value perturbs the byte-faithful record and reads back as "" instead of null, so only - // emit it (in its original position) when a description is actually present. + // emit it (in its original position) when a description is actually present. A description + // Windows-1252 cannot represent is promoted to a %UTF8% parameter, as with SchParameter.Text. if (component.Description != null) - parameters["ComponentDescription"] = component.Description; + { + var descUtf8 = RequiresUtf8(component.Description); + parameters[descUtf8 ? "%UTF8%ComponentDescription" : "ComponentDescription"] = + descUtf8 ? AltiumEncoding.EncodeUtf8ParameterValue(component.Description) : component.Description; + } parameters["PartCount"] = (component.PartCount + 1).ToString(CultureInfo.InvariantCulture); parameters["DisplayModeCount"] = "1"; parameters["IndexInSheet"] = "-1"; diff --git a/tests/OriginalCircuit.Altium.Tests/RoundTrip/Utf8ParameterRoundTripTests.cs b/tests/OriginalCircuit.Altium.Tests/RoundTrip/Utf8ParameterRoundTripTests.cs index 746ddcb..53dc17a 100644 --- a/tests/OriginalCircuit.Altium.Tests/RoundTrip/Utf8ParameterRoundTripTests.cs +++ b/tests/OriginalCircuit.Altium.Tests/RoundTrip/Utf8ParameterRoundTripTests.cs @@ -51,4 +51,35 @@ public void SchDoc_Utf8FlaggedParameter_RoundTripsValueAndFlag() Assert.Equal(text, param.Value); Assert.True(param.TextIsUtf8); } + + /// + /// Altium writes non-ASCII values twice: a %UTF8% variant first, then a plain-ANSI twin + /// (e.g. |%UTF8%ComponentDescription=KON 4.7µF|ComponentDescription=KON 4.7µF|). + /// Both collapse onto one key when the prefix is stripped; the UTF-8 variant must win and be + /// decoded, not returned as mojibake (bd-23s). + /// + [Fact] + public void ParameterCollection_Utf8AndAnsiDuplicate_ReturnsDecodedValue() + { + var collection = OriginalCircuit.Altium.Primitives.ParameterCollection.Parse( + "|RECORD=1|%UTF8%ComponentDescription=KON 4.7µF|ComponentDescription=KON 4.7µF|PartCount=2"); + + Assert.Equal("KON 4.7µF", collection["ComponentDescription"].AsStringOrDefault()); + } + + [Theory] + [InlineData("KON 4.7µF ±10%")] // Windows-1252 representable → plain ComponentDescription key + [InlineData("Shunt 5mΩ")] // Ω is not 1252-representable → %UTF8%ComponentDescription key + public void SchDoc_NonAsciiComponentDescription_RoundTrips(string description) + { + var doc = new SchDocument(); + doc.AddComponent(new SchComponent { Name = "R1", PartCount = 1, Description = description }); + + using var ms = new MemoryStream(); + new SchDocWriter().Write(doc, ms); + ms.Position = 0; + var readBack = new SchDocReader().Read(ms); + + Assert.Equal(description, readBack.Components.First().Description); + } }