diff --git a/src/ACadSharp.Tests/IO/Tangent/TchDataMapTests.cs b/src/ACadSharp.Tests/IO/Tangent/TchDataMapTests.cs new file mode 100644 index 000000000..93841ff2f --- /dev/null +++ b/src/ACadSharp.Tests/IO/Tangent/TchDataMapTests.cs @@ -0,0 +1,111 @@ +using ACadSharp.IO.DWG; +using ACadSharp.IO.DWG.Tangent; +using System; +using System.Linq; +using Xunit; +using Xunit.Abstractions; + +namespace ACadSharp.Tests.IO.Tangent +{ + /// + /// Tests for against real Tianzheng payloads. + /// + /// + /// The fixtures are retained custom-object payloads taken from real proxy entities of a Tianzheng + /// plumbing drawing (class id 575 == TCH_PIPE, 576 == TCH_PIPEFITTING). They cover both cases: + /// objects that carry properties and objects whose property map is empty. + /// + public class TchDataMapTests + { + private readonly ITestOutputHelper _output; + + public TchDataMapTests(ITestOutputHelper output) + { + this._output = output; + } + + [Theory] + [InlineData(PayloadPipeA, "PIPE_SYSTEM_NAME", "\u6d88\u9632")] + [InlineData(PayloadPipeA, "LINKED_DIM_TEXT", "DN25")] + [InlineData(PayloadPipeA, "PIPE_OUTER_DN", null)] + [InlineData(PayloadPipeA, "PIPE_THICK", null)] + public void ReadsNamedPropertiesFromRealPayload(string base64, string name, string expected) + { + TchDataMap map = read(base64); + + this._output.WriteLine(string.Join(" | ", map.Entries)); + Assert.True(map.Properties.ContainsKey(name), "missing property " + name); + + if (expected != null) + { + Assert.Equal(expected, map.Properties[name]); + } + } + + [Fact] + public void EmptyPropertyMapIsReportedAsEmpty() + { + // class id 576 == TCH_PIPEFITTING carries no properties at all + TchDataMap map = read(PayloadFitting); + + Assert.True(map.IsEmpty); + Assert.Empty(map.Properties); + Assert.True(map.IsComplete); + Assert.Equal(2, map.Entries.Count); + Assert.Equal( + new[] { TchDataMap.BlockBeginMarker, TchDataMap.BlockEndMarker }, + map.Entries); + } + + [Fact] + public void DecodesEveryFixtureAndReachesTheClosingMarker() + { + foreach (string payload in new[] { PayloadPipeA, PayloadPipeB, PayloadPipeC, PayloadFitting }) + { + TchDataMap map = read(payload); + Assert.NotNull(map); + Assert.True(map.IsComplete, "closing marker not reached"); + } + } + + [Fact] + public void TruncatedPayloadStopsWithoutThrowing() + { + byte[] full = Convert.FromBase64String(PayloadPipeA); + byte[] truncated = full.Take(full.Length / 2).ToArray(); + + TchDataMap map = TchDataMap.Read(truncated, ACadVersion.AC1027); + + Assert.NotNull(map); + Assert.False(map.IsComplete); + } + + private static TchDataMap read(string base64) + { + return TchDataMap.Read(Convert.FromBase64String(base64), ACadVersion.AC1027); + } + + private const string PayloadPipeA = + "Q9MAEkATgBLAEUARABfAEQASQBNAF8AVABFAFgAVABBEQATgAyADUAQ1QAEkAUABFAF8ATwBVAFQARQBSAF8ARABOAEQUABJAFAA" + + "RQBfAFMAWQBTAFQARQBNAF8ATgBBAE0ARQBAohtMpZClAASQBQAEUAXwBUAEgASQBDAEsARhUAEQAQgBYAEQAQQBUAEEATQBBAFA" + + "AXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBFlQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEUATgBEAF8AkCRA" + + "BOAI"; + + private const string PayloadPipeB = + "Q9MAEkATgBLAEUARABfAEQASQBNAF8AVABFAFgAVABBEQATgAyADUAQ1QAEkAUABFAF8ATwBVAFQARQBSAF8ARABOAEQUABJAFAA" + + "RQBfAFMAWQBTAFQARQBNAF8ATgBBAE0ARQBAohtMpZClAASQBQAEUAXwBUAEgASQBDAEsARhUAEQAQgBYAEQAQQBUAEEATQBBAFA" + + "AXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBFlQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEUATgBEAF8AkCRA" + + "BOAI"; + + private const string PayloadPipeC = + "Q9MAEkATgBLAEUARABfAEQASQBNAF8AVABFAFgAVABBEQATgAyADUAQ1QAEkAUABFAF8ATwBVAFQARQBSAF8ARABOAEQUABJAFAA" + + "RQBfAFMAWQBTAFQARQBNAF8ATgBBAE0ARQBAohtMpZClAASQBQAEUAXwBUAEgASQBDAEsARhUAEQAQgBYAEQAQQBUAEEATQBBAFA" + + "AXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBFlQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEUATgBEAF8AkCRA" + + "BOAI"; + + private const string PayloadFitting = + "RhUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBFlQARABCAFgARABBAFQAQQBNAEEAUABfAE" + + "IATABPAEMASwBfAEUATgBEAF8AqM"; + + } +} diff --git a/src/ACadSharp.Tests/IO/Tangent/TchObjectTests.cs b/src/ACadSharp.Tests/IO/Tangent/TchObjectTests.cs new file mode 100644 index 000000000..3107af653 --- /dev/null +++ b/src/ACadSharp.Tests/IO/Tangent/TchObjectTests.cs @@ -0,0 +1,785 @@ +using ACadSharp; +using ACadSharp.IO.DWG.Tangent; +using System; +using System.Collections.Generic; +using Xunit; + +namespace ACadSharp.Tests.IO.Tangent +{ + /// + /// Tests against payloads captured from a drawing that contains native Tianzheng objects + /// (TZ.dwg, contributed in issue #1131). Each fixture is the class specific data of one + /// object, taken byte for byte from UnknownEntity.RawData. + /// + public class TchObjectTests + { + //TCH_TEXT, handle 828, expected position verified against Tianzheng's own flattened export + private const string TextPayload = +"ChGQVQWQOQOBBEwC9uR0z13ECPgOLDkLIeNASZ7AEYAOABGADAARgAxADIARgAtADkAMwA4ADkALQA0ADkARgBDAC0AOAAzA" + +"DUARAAtADgAQwA4ADYAQwA2AEUARQBFAEUARABCAH0ASzhAOgA4QDmAOEA5wDhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAO" + +"UA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOkA4QDlAOAA+ADgAOkA4QDlAOE" + +"A5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEG4QDmAOEA5QDhAOQA" + +"RhUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBBVQAMgAwAGQAMQBBVQAMgAwAGQAMgB" + +"BVQAMgAwAGQAMwBBVQAMgAwAGQANABFlQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEUATgBEAF8ASZ7AEI" + +"AMAA3ADEAMwBFADYAQgAtAEEARgBGADYALQA0ADgANwAxAC0AQQAyADcAMwAtADcAOAA5ADYAOAAzADQARAA2ADYANwA2AH0" + +"AQbhAOUA4QDlAOEA5gBFFcAYQBsAGwAIAAmACAAVwBpAG4AZABvAHcAIAAmACAARABvAG8AcgBKOEA6ADhAOQA4QDmAOAA6Q" + +"DhAOoA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOYA4QD" + +"lAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QD" + +"lAEG4QDpAOEA6ADhAOYAQbhAOMA4QDlAOEA4wCKFqkBRSiIoCB/f"; + + //TCH_WALL, handle 833 + private const string WallPayload = +"ChGQOQOQUAAAAAAAAWUBBQAAAAAAABZQEIQhCEDkIQMAAEAAEVQMAAEAAAwRCSC4AP/XtxAjkZsiqwNPhQAkguAD/17cQISL" + +"nXOR7X50BJnsARgA4AEYAMABGADEAMgBGAC0AOQAzADgAOQAtADQAOQBGAEMALQA4ADMANQBEAC0AOABDADgANgBDADYARQB" + +"FAEUARQBEAEIAfQBLOEA5gDhAOYA4QDoAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDl" + +"AOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA6gDhAOUA4QDnAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhA" + +"OUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQbgAPcA4QDkAOEA4wBBuEA6ADhAOoA4QDqAEG4QDjAOEA" + +"5ADgAPgARhUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBBVQAMgAwAGIAMgBBVQAMgA" + +"wAGIAMwBBlQAMgAwAGQAMQAyAEFVAAyADAAZAA3AEFVAAyADAAcwAxAECQQCnfkGVAAyADAAcwAxADQAQEWeEFVAAyADAAcw" + +"A0AEDbmYakBZ4QVUADIAMABzADgAQVUADIAMABzADkAQPdTyluQlxBlQAMgAwAHUAMQAxAEGVAAyADAAdQAxADYAQVUADIAM" + +"AB1ADYARZUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBFAE4ARABfAEmewBCADAANwAxADMARQA2AEIALQB" + +"BAEYARgA2AC0ANAA4ADcAMQAtAEEAMgA3ADMALQA3ADgAOQA2ADgAMwA0AEQANgA2ADcANgB9AEG4QDqAOEA5QDhAOQASjhA" + +"OUA4QDlAOEA6ADgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhA" + +"OUA4QDlAEG4AD4AOEA5QDhAOQASzhAOYA4QDmAOAA+ADhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOU" + +"A4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASzhAOkA4QDmAOEA5ADhAOUA4QDlAOAA6QDhAOUA4QDlAOEA" + +"5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAS7hAOgA4QDkAOEA6gDhAOUA4" + +"QDlAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4" + +"QDlAEG4QDqAOEA5QDhAOcAQbhAOgA4QDjAOEA6QBCOEA6ADhAOkA4AD4AOEA5QBBuEA4wDhAOUA4QDpAEq4QDmAOAA9wDhAO" + +"YA4QDlAOAA6QDhAOUA4AD3AOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5gDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5gDgAP" + +"gASjhAOYA4QDlAOAA9wDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOE" + +"A5QDhAOUA4QDlAEo4QDmAOEA5QDgAPgA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA" + +"4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA5QDhAOUA4QDoAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5" + +"QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOoA4QDlAOEA6QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4Q" + +"DlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEG4QDmAOEA5QDhAOgAQbhAOgA4QDlAOEA5ACSjgAPcA4Q" + +"DlAOEA5gDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4Q" + +"DlAEG4QDlAOEA5QDhAOcAyjGpATOpAVkhAZ2gIQHWIQHWICAhAa6hAcuoKCkBRykBNqAgICB0A=="; + + //TCH_COLUMN, handle 827 + private const string ColumnPayload = +"ChGQOQhCEDAABAABBQAAAAAAABZQEICBEJILgA/9e3ECORmyKpAruFAAUE5GbIqsCI4UAkguAD/xPcQLkZsiqwNPhQCSC4AP" + +"/E9xAuRmyKrA0+FAJILgA/+p3EC5GbIqsCI4UAkguAD/6ncQJJnsAMgBFADAAMABDADIARgA4AC0ARgA2ADUANAAtADQAOQA" + +"yADgALQBBAEYAMwBEAC0AMABFAEUANgA0ADUANQA1ADgANAAyADMAfQBLOAA+ADhAOYA4QDlAOEA5QDhAOUA4ADpAOEA5QDh" + +"AOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA6QDhAOUA4AD3A" + +"OAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQbhAO" + +"gA4QDkAOEA5ABBuEA5wDhAOoA4QDkAEG4QDjAOEA5ADhAOgARhUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAX" + +"wBCAEUARwBJAE4AXwBBVQAMgAwAGIAMABBVQAMgAwAHMAMQBBaKUS3v3bd1RH1dBVQAMgAwAHMAMgBCVAAVQBCAF8ASABBAF" + +"QAQwBIAEFVAAyADAAcwAzAEFVAAyADAAcwA0AEFVAAyADAAcwA1AEDopRLezx4RZUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwB" + +"CAEwATwBDAEsAXwBFAE4ARABfAEmewBCADAANwAxADMARQA2AEIALQBBAEYARgA2AC0ANAA4ADcAMQAtAEEAMgA3ADMALQA3" + +"ADgAOQA2ADgAMwA0AEQANgA2ADcANgB9AEG4QDoAOEA5QDhAOQAS7hAOYA4QDkAOAA9wDhAOUA4QDlAOEA5QDgAOkA4QDlAO" + +"EA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEG4QDoAOEA5gDhAOQ" + +"AQbgAPcA4QDjAOEA4wBCOEA4wDhAOcA4AD4AOEA5QBBuEA5wDhAOUA4QDnAEG4AD4AOEA5QDhAOMASrhAOcA4AD3AOEA5QDh" + +"AOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBK" + +"OEA5QDhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlA" + +"OEA5QDhAOUApiHKkBKqEBXiEB1ikBRykBNvm"; + + //TCH_CUT, handle 1225, an outline with one arc edge + private const string CutPayload = +"ChGAhEBQQk2ZXF13XuQBin/6kEAdpAYC989e6oe9AGKf/qQQB2kCgL3z17qh70BQ0hKqpMDaQIk2ZXF13XuQFDSEqqkwNpAk" + +"mewAyAEUAMAAwAEMAMgBGADgALQBGADYANQA0AC0ANAA5ADIAOAAtAEEARgAzAEQALQAwAEUARQA2ADQANQA1ADUAOAA0ADI" + +"AMwB9AEs4QDqAOEA5gDhAOgA4QDlAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA" + +"5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDmAOEA5QDhAOgA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4" + +"QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBBuEA5QDhAOMA4QDlAEG4QDkAOEA5ADhAOYARhUAEQAQgBYAEQAQQBU" + +"AEEATQBBAFAAXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBFlQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfA" + +"EUATgBEAF8ASZ7AEIAMAA3ADEAMwBFADYAQgAtAEEARgBGADYALQA0ADgANwAxAC0AQQAyADcAMwAtADcAOAA5ADYAOAAzAD" + +"QARAA2ADYANwA2AH0AQbhAOMA4QDlAOEA6gBBuEA6gDhAOYA4AD3AEG4AD4AOEA5QDhAOMAQbgAPgA4QDlAOEA6gBBuEA5gD" + +"hAOUA4QDnAEG4QDjAOEA5QDhAOMAbBCpAURR"; + + //TCH_WALL, handle 700 + private const string WallFixture = +"ChGQOQOQUAAAAAAAAWUBBQAAAAAAABZQEIQhCEDkIQMAAEAAEVQMAAEAAAwRCSC4AP/XtxAjkZsiqwNPhQAkguAD/17cQISL" + +"nXOR7X50BJnsARgA4AEYAMABGADEAMgBGAC0AOQAzADgAOQAtADQAOQBGAEMALQA4ADMANQBEAC0AOABDADgANgBDADYARQB" + +"FAEUARQBEAEIAfQBLOEA5gDhAOYA4QDoAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDl" + +"AOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA6gDhAOUA4QDnAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhA" + +"OUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQbgAPcA4QDkAOEA4wBBuEA6ADhAOoA4QDqAEG4QDjAOEA" + +"5ADgAPgARhUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBBVQAMgAwAGIAMgBBVQAMgA" + +"wAGIAMwBBlQAMgAwAGQAMQAyAEFVAAyADAAZAA3AEFVAAyADAAcwAxAECQQCnfkGVAAyADAAcwAxADQAQEWeEFVAAyADAAcw" + +"A0AEDbmYakBZ4QVUADIAMABzADgAQVUADIAMABzADkAQPdTyluQlxBlQAMgAwAHUAMQAxAEGVAAyADAAdQAxADYAQVUADIAM" + +"AB1ADYARZUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBFAE4ARABfAEmewBCADAANwAxADMARQA2AEIALQB" + +"BAEYARgA2AC0ANAA4ADcAMQAtAEEAMgA3ADMALQA3ADgAOQA2ADgAMwA0AEQANgA2ADcANgB9AEG4QDqAOEA5QDhAOQASjhA" + +"OUA4QDlAOEA6ADgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhA" + +"OUA4QDlAEG4AD4AOEA5QDhAOQASzhAOYA4QDmAOAA+ADhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOU" + +"A4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASzhAOkA4QDmAOEA5ADhAOUA4QDlAOAA6QDhAOUA4QDlAOEA" + +"5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAS7hAOgA4QDkAOEA6gDhAOUA4" + +"QDlAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4" + +"QDlAEG4QDqAOEA5QDhAOcAQbhAOgA4QDjAOEA6QBCOEA6ADhAOkA4AD4AOEA5QBBuEA4wDhAOUA4QDpAEq4QDmAOAA9wDhAO" + +"YA4QDlAOAA6QDhAOUA4AD3AOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5gDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5gDgAP" + +"gASjhAOYA4QDlAOAA9wDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOE" + +"A5QDhAOUA4QDlAEo4QDmAOEA5QDgAPgA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA" + +"4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA5QDhAOUA4QDoAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5" + +"QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOoA4QDlAOEA6QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4Q" + +"DlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEG4QDmAOEA5QDhAOgAQbhAOgA4QDlAOEA5ACSjgAPcA4Q" + +"DlAOEA5gDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4Q" + +"DlAEG4QDlAOEA5QDhAOcAyjGpATOpAVkhAZ2gIQHWIQHWICAhAa6hAcuoKCkBRykBNqAgICB0A=="; + + //TCH_OPENING, handle 861 + private const string OpeningFixture = +"ChFAVlBhEJILgA/9e3ECCnUKe7V2uVACSC4AM/AdxAgp1Cnu1drlQEmewBEAEYANwAwADgAQgAwADEALQBFAEYAOQAwAC0AN" + +"AA5ADkAMgAtAEIAOABDADUALQAyAEIAOAA2AEEANwBGADkARAA1ADEANgB9AEs4QDkAOEA5gDgAPcA4QDlAOEA5QDgAOkA4Q" + +"DlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDpAOEA5QD" + +"hAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QB" + +"BuEA6ADhAOQA4QDlAEG4QDnAOEA6gDhAOUAQbhAOUA4QDkAOEA5wBBOiVl3oodRSQQNuZhqQ6JVGFQARABCAFgARABBAFQAQ" + +"QBNAEEAUABfAEIATABPAEMASwBfAEIARQBHAEkATgBfAEWVABEAEIAWABEAEEAVABBAE0AQQBQAF8AQgBMAE8AQwBLAF8ARQ" + +"BOAEQAXwBJnsAQgAwADcAMQAzAEUANgBCAC0AQQBGAEYANgAtADQAOAA3ADEALQBBADIANwAzAC0ANwA4ADkANgA4ADMANAB" + +"EADYANgA3ADYAfQBBuEA5QDhAOUA4AD3AEs4QDnAOAA9wDhAOcA4QDlAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhA" + +"OUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEs4QDmAOEA5gDhAOMA4QDlAOEA5QDgAOkA4QDlAO" + +"EA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEu4AD3AOEA4wDhAOQ" + +"A4QDmAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOU" + +"A4QDlAOEA5QBKOEA4wDhAOUA4QDqAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA" + +"5QDhAOUA4QDlAOEA5QDhAOUAQbgAPcA4QDmAOAA+ABBuEA6gDhAOUA4QDmAEo4QDnAOEA5QDhAOcA4ADpAOEA5QDhAOUA4QD" + +"lAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QCQbgAPcA4QDlAOAA+ABKOEA5Q" + +"DhAOQA4QDoAOAA6QDhAOoA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5Q" + +"DhAOUASzhAOoA4QDmAOEA5gDhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QD" + +"lAOEA5QDhAOUA4QDlAOEA5QDhAOUASrhAOkA4QDpAOEA6gDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDh" + +"AOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKuEA4wDhAOkA4QDqAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlA" + +"OEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEG4AD3AOEA5QDhAOoAQbhAOcA4QDlAOA" + +"A+ABBuEA6ADhAOgA4AD4AEG4QDmAOEA5QDhAOUAko4QDnAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOE" + +"A5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA4wDhAOUA4QDkAOAA6QDhAOUA4QDlAOEA5QDhAOUA" + +"4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOgA4QDlAOEA5QDgAOkA4QDlAOEA5" + +"QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDlAOEA5QDgAPgA4A" + +"DpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBLOAA+AD" + +"hAOYA4QDoAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QD" + +"hAOUA4QDlAOEA5QBeN6kBKykBn6kBbqEBXikBYykBSCkBZCkBY6kBM/r"; + + //TCH_ELEVATION, handle 1232 + private const string ElevationFixture = +"ChFAUGQEEEQUR0DuJAUyZtkCsxm7jPlfkQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAASZ7ADgAMAA2ADAARAAy" + +"ADgAMgAtADMARABGADIALQA0ADkARQBEAC0AOABCADAAMQAtAEQAQwA0ADEAOAA0AEUAMQBEADMAMQA3AH0ASzhAOQA4QDmA" + +"OEA6QDhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlA" + +"OEA5QDhAOUASjgAPcA4QDlAOEA5gDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAO" + +"UA4QDlAOEA5QDhAOUA4QDlAEG4QDqAOEA5QDgAPgAQcHaNia6GzKkShX81OnT0YVABEAEIAWABEAEEAVABBAE0AQQBQAF8AQ" + +"gBMAE8AQwBLAF8AQgBFAEcASQBOAF8ARZUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBFAE4ARABfAEmewB" + +"CADAANwAxADMARQA2AEIALQBBAEYARgA2AC0ANAA4ADcAMQAtAEEAMgA3ADMALQA3ADgAOQA2ADgAMwA0AEQANgA2ADcANgB" + +"9AEG4QDjAOEA5QDhAOYASjhAOQA4QDkAOEA6ADgAOkA4QDqAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlA" + +"OEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDkAOEA5QDhAOgA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAO" + +"UA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBBuEA5gDhAOgA4QDnAEI4QDqAOEA5gDhAOUA4QDjAEIMgAyAC4AN" + +"gA2ADYANgA5AEq4QDmAOEA5gDhAOYA4QDjAOAA6QDhAOkA4AD3AOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4Q" + +"DlAOEA5QDhAOUA4QDlAOEA5QDhAOkASjgAPcA4QDoAOEA4wDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QD" + +"hAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDmAOEA6ADhAOkA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDl" + +"AOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBMOAA+ADgAOwA4QDjAOEA5gDhAOUA4QDlAOEA5QDgA" + +"OkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEK4QDnAO" + +"EA5gDgAPcA4QDnAOEA6ACQbhAOMA4QDlAOEA5gDaIKkBQikBWCkBQnA="; + + //TCH_INDEXPOINTER, handle 936 + private const string IndexPointerFixture = +"ChFAkCAHQBBEFESLExEaLY9xA3AqZ9idM5kAUWKMixvTZQATxktS8sOZAAJJnsAMgBFADAAMABDADIARgA4AC0ARgA2ADUAN" + +"AAtADQAOQAyADgALQBBAEYAMwBEAC0AMABFAEUANgA0ADUANQA1ADgANAAyADMAfQBLOEA6ADhAOYA4QDnAOEA5QDhAOUA4A" + +"DpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA6QD" + +"hAOUA4AD3AOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QD" + +"hAOUAQbhAOcA4QDlAOEA6QBBQdo6GxNT25/uXBCAdo6GyHZVdbN2gPX0kARABGFQARABCAFgARABBAFQAQQBNAEEAUABfAEI" + +"ATABPAEMASwBfAEIARQBHAEkATgBfAEWVABEAEIAWABEAEEAVABBAE0AQQBQAF8AQgBMAE8AQwBLAF8ARQBOAEQAXwBJnsAQ" + +"gAwADcAMQAzAEUANgBCAC0AQQBGAEYANgAtADQAOAA3ADEALQBBADIANwAzAC0ANwA4ADkANgA4ADMANABEADYANgA3ADYAf" + +"QBBuEA5gDhAOUA4QDoAEo4AD4AOEA5ADhAOYA4ADpAOEA6gDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QD" + +"hAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA5wDhAOUA4QDnAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDl" + +"AOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQbgAPcA4QDoAOAA9wBBuAA9wDhAOYA4QDkAEo4QDpAOEA6gDgAPg" + +"A4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA" + +"6ADhAOgA4QDlAOAA6QDgAPgA4AD3AOEA5gDhAOYA4QDlAOEA5gDgAPgA4QDnAOEA5wDhAOcA4QDoAOEA5wDhAOcA4QDqAOEA" + +"5gDhAOkASjhAOkA4QDqAOEA6QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4" + +"QDlAOEA5QDhAOUA4QDlAEBMQBAS0ApBuEA4wDhAOUA4QDjAEG4QDlAOEA5QDgAPgATDhAOMA4ADsAOEA5wDhAOYA4QDlAOEA" + +"5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA" + +"5QBKOEA5QDhAOUA4AD4AOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4" + +"QDlAOEA5QDhAOUAQbgAPgA4QDkAOAA9wBKOEA5gDhAOUA4QDqAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QD" + +"lAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAvAlqQFDKQFYKQHSKQFDKH0="; + + //TCH_SYMB_SECTION, handle 937 + private const string SectionFixture = +"ChGQOQOBBEDEeJcinLiM91A/MeFGsyK60Cg+G/jcubZQPzHhRrMiutAexSuR+F6ND17FK5H4XokvXsUrkfhejQ9exSuR+F6J" + +"L0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEmewBEAEYANwAwADgAQgAwADEALQBFAEYAOQAwAC0ANAA5ADkAMg" + +"AtAEIAOABDADUALQAyAEIAOAA2AEEANwBGADkARAA1ADEANgB9AEs4AD4AOEA5gDhAOUA4QDlAOEA5QDgAOkA4QDlAOEA5QD" + +"hAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDoAOEA5QDgAPgA4ADp" + +"AOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBBuEA5gDhA" + +"OUA4QDmAEYVABEAEIAWABEAEEAVABBAE0AQQBQAF8AQgBMAE8AQwBLAF8AQgBFAEcASQBOAF8AQZUADIAMABkADMANQBBlQA" + +"MgAwAGQAMwA3AEWVABEAEIAWABEAEEAVABBAE0AQQBQAF8AQgBMAE8AQwBLAF8ARQBOAEQAXwBJnsAQgAwADcAMQAzAEUANg" + +"BCAC0AQQBGAEYANgAtADQAOAA3ADEALQBBADIANwAzAC0ANwA4ADkANgA4ADMANABEADYANgA3ADYAfQBBuEA6QDhAOUA4AD" + +"4AEo4QDpAOEA6gDgAPgA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDh" + +"AOUA4QDlAOEA5QBKOEA5gDhAOUA4QDjAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlA" + +"OEA5QDhAOUA4QDlAOEA5QDhAOUAQbgAPcA4QDoAOEA5wBBuEA5gDhAOYA4QDqAEo4QDmAOEA5ADhAOMA4ADpAOEA5gDhAOkA" + +"4QDmAOEA6gDgAPgA4QDjAOEA5wDhAOoA4QDkAOEA6gDgAPcA4AD4AOEA6ADgAPgA4QDkAOEA5gBBuEA6gDhAOMA4AD3AEBMQ" + +"BA0EAMAAxAEo4QDlAOEA5ADgAPgA4ADpAOEA6gDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDl" + +"AOEA5QDhAOUA4QDlAOEA5QBBuAA9wDhAOUA4QDpAEw4AD4AOAA7ADhAOcA4QDmAOEA5QDhAOUA4QDlAOAA6QDhAOUA4QDlAO" + +"EA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOMA4QDlAOAA9wDgAOk" + +"A4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEG4QDlAOEA" + +"5QDhAOoAQrhAOoA4QDmAOEA4wDhAOUA4AD3AEo4QDlAOEA5ADhAOYA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5Q" + +"DhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKuEA6QDhAOYA4QDqAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QD" + +"lAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEG4AD3AOEA4wDhAOoASjgAPgA4QDlA" + +"OEA5ADgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlA" + +"LgpqQFEKQHSKQFEKQFYf+g=="; + + //TCH_RECTSTAIR, handle 956 + private const string StairFixture = +"ChFAUHAkAAMMDEQcRObK2JiQ59xAjAYfPrhXnwQEmewA4ADAANgAwAEQAMgA4ADIALQAzAEQARgAyAC0ANAA5AEUARAAtADg" + +"AQgAwADEALQBEAEMANAAxADgANABFADEARAAzADEANwB9AEs4QDpAOEA5gDhAOoA4QDlAOEA5QDgAOkA4QDlAOEA5QDhAOUA" + +"4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDoAOEA5QDhAOYA4ADpAOEA5" + +"QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBBuEA6QDhAOYA4A" + +"D3AEG4QDmAOEA6gDgAPgAQZrWEVRH3dpX5yYcoJGFQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEIARQBHA" + +"EkATgBfAEWVABEAEIAWABEAEEAVABBAE0AQQBQAF8AQgBMAE8AQwBLAF8ARQBOAEQAXwBJnsAQgAwADcAMQAzAEUANgBCAC0" + +"AQQBGAEYANgAtADQAOAA3ADEALQBBADIANwAzAC0ANwA4ADkANgA4ADMANABEADYANgA3ADYAfQBBuEA6ADhAOUA4QDpAEo4" + +"QDpAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4" + +"QDlAOEA5QBLOEA6ADhAOYA4QDpAOEA6gDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5Q" + +"DhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBLOEA5wDhAOMA4QDmAOEA6ADhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QD" + +"lAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBLuAA9wDhAOYA4QDnAOEA4wDhAOQA4QDlAOAA6QDh" + +"AOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAS7hAOYA4QDmA" + +"OEA5wDhAOMA4QDlAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlA" + +"OEA5QDhAOUA4QDlAEI4QDpAOEA5gDhAOUA4QDlAEI4QDqAOEA5gDhAOQA4QDlAEu4QDjAOEA4wDhAOcA4QDqAOEA5wDhAOUA" + +"4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBLOEA6" + +"ADgAPgA4QDmAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5" + +"QDhAOUA4QDlAOEA5QBBuEA6QDhAOYA4QDoAEG4QDmAOEA5QDhAOUAQbhAOkA4QDjAOEA5ABKuEA5wDhAOcA4QDqAOEA5QDgA" + +"OkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDjAO" + +"EA5QDhAOcA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAO" + +"EA5QBEOEA6gDhAOQA4QDpAOAA+ADhAOkA4QDoAOEA6gDhAOgASzhAOMA4QDjAOEA5gDhAOUA4QDlAOAA6QDhAOUA4QDlAOEA" + +"5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASzhAOUA4QDmAOEA5QDhAOMA4" + +"QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAS" + +"zhAOcA4QDmAOEA6gDhAOMA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5Q" + +"DhAOUA4QDlAOEA5QDhAOUASzhAOYA4QDpAOEA6gDhAOgA4QDoAOAA6QDhAOgA4QDkAOAA9wDhAOcA4QDqAOAA9wDhAOUA4QD" + +"jAOEA5wDgAPcA4QDoAOEA6ADgAPcA4QDjAOAA9wDgAPgASzhAOcA4AD3AOEA6gDhAOgA4QDjAOAA6QDhAOMA4QDnAOEA5gDh" + +"AOQA4QDpAOEA5gDgAPgA4QDoAOEA5ADhAOYA4QDjAOEA4wDhAOYA4QDoAOEA5gDhAOYAQbhAOUA4QDlAOEA5wBKuEA4wDhAO" + +"cA4QDpAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAO" + +"UA4QDlAEq4QDoAOEA5wDhAOkA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOE" + +"A5QDhAOUA4QDlAOEA5QDhAOUASzhAOkA4QDnAOAA9wDhAOgA4QDqAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA" + +"4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOYA4QDlAOAA+ADgAOkA4QDqAOEA4wDhAOQA4QDqAOAA+" + +"ADgAPcA4QDoAOEA6ADhAOoA4QDqAOAA+ADgAPcA4QDjAOAA+ADgAPcA4AD3AEo4AD4AOEA5ADhAOMA4ADpAOEA6gDhAOUA4Q" + +"DlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBLuEA6ADhAOYA4QDnAOEA4wD" + +"hAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QD" + +"hAOUAS7gAPgA4QDmAOAA9wDhAOMA4QDlAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDl" + +"AOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEu4QDmAOEA5gDgAPcA4QDjAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhA" + +"OUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBLuEA6QDhAOYA4QDjAOEA4wDhAOUA4QDlAO" + +"AA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQbhAOk" + +"A4AD4AOEA5ACaUqkBOCApATqoKCkBOikBRSkBWCkBRH6"; + + //TCH_DIMENSION2, handle 921 + private const string DimensionFixture = +"ChGQUQDIiGcFBiMACEQkguAD/0XcQIqI/r/2H34kBJnsAMgBFADAAMABDADIARgA4AC0ARgA2ADUANAAtADQAOQAyADgALQB" + +"BAEYAMwBEAC0AMABFAEUANgA0ADUANQA1ADgANAAyADMAfQBLOEA6QDhAOYA4QDmAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDl" + +"AOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA5wDhAOUA4QDmAOAA6QDhA" + +"OUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQbhAOYA4QDlAO" + +"AA9wBGFQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEIARQBHAEkATgBfAEXVAAyADAAVgA5AF8AZABSAGUA" + +"YQBsAEQAaQBzAHQAMgBEAGkAbQBMAGkAbgBlAEWVABEAEIAWABEAEEAVABBAE0AQQBQAF8AQgBMAE8AQwBLAF8ARQBOAEQAX" + +"wBJnsAQgAwADcAMQAzAEUANgBCAC0AQQBGAEYANgAtADQAOAA3ADEALQBBADIANwAzAC0ANwA4ADkANgA4ADMANABEADYANg" + +"A3ADYAfQBBuEA6gDhAOUA4QDjAEo4AD3AOEA5wDhAOoA4ADpAOEA4wDgAPcA4QDkAOEA5gDgAPcA4QDqAOEA5ADhAOUA4QDo" + +"AOEA5gDhAOgA4AD4AOEA6gDgAPcA4QDnAOEA4wBKuEA4wDgAOwA4QDpAOEA6ADgAOkA4QDoAOEA5wDhAOMA4QDmAOAA+ADhA" + +"OMA4AD3AOEA5wDhAOMA4QDmAOAA+ADhAOgA4QDpAOAA9wDhAOUA4QDkAEG4QDjAOEA6gDhAOkAS7hAOMA4QDmAOEA5ADhAOY" + +"A4QDnAOEA5ADgAOkA4QDjAOEA6gDhAOYA4QDoAOEA5QDhAOgA4QDmAOEA5ADgAPcA4QDkAOEA5gDgAPgA4QDlAOEA5ADhAOk" + +"A4QDmAEu4QDlAOEA5gDhAOMA4QDjAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA" + +"5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBLuEA6QDhAOQA4QDlAOEA5gDhAOQA4QDnAOAA6QDhAOMA4QDqAOEA5wDhAOQA4" + +"QDoAOEA6QDhAOUA4QDpAOEA6QDhAOoA4QDoAOEA5wDhAOcA4QDqAOAA+ADgAPcASzhAOcA4AD3AOEA6QDhAOYA4QDnAOAA6Q" + +"DhAOYA4QDmAOEA6QDhAOgA4QDpAOEA5wDhAOUA4QDoAOEA6QDgAPcA4AD4AOEA6ADgAPgA4AD4AOEA5ADgAPcAS7gAPgA4QD" + +"kAOEA6gDhAOcA4QDnAOEA5wDgAOkA4QDjAOEA6gDgAPgA4QDjAOEA6QDhAOQA4QDkAOEA5wDhAOYA4QDmAOEA5gDhAOQA4QD" + +"pAOEA6ADhAOcA4QDnAEG4QDmAOEA5QDhAOYA5COpAUGpAcxy"; + + //TCH_TEXT, handle 829 + private const string SquareColumnFixture = +"ChGQVQWQOQOBBEbBi4iLTH3ECBSlmpfmkOFASZ7AEYAOABGADAARgAxADIARgAtADkAMwA4ADkALQA0ADkARgBDAC0AOAAzA" + +"DUARAAtADgAQwA4ADYAQwA2AEUARQBFAEUARABCAH0ASzhAOUA4QDmAOEA5ADhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAO" + +"UA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOkA4QDlAOAA+ADgAOkA4QDlAOE" + +"A5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEG4AD4AOEA5QDgAPcA" + +"RhUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBBVQAMgAwAGQAMQBBVQAMgAwAGQAMgB" + +"BVQAMgAwAGQAMwBBVQAMgAwAGQANABFlQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEUATgBEAF8ASZ7AEI" + +"AMAA3ADEAMwBFADYAQgAtAEEARgBGADYALQA0ADgANwAxAC0AQQAyADcAMwAtADcAOAA5ADYAOAAzADQARAA2ADYANwA2AH0" + +"AQbgAPcA4QDlAOEA6gBDVMAcQB1AGEAcgBlACAAQwBvAGwAdQBtAG4ASjhAOkA4QDkAOAA9wDgAOkA4QDqAOEA5QDhAOUA4Q" + +"DlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4AD3AOEA5QDhAOoA4ADpAOEA5QD" + +"hAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBBuEA4wDhAOgA4QDk" + +"AEG4QDlAOEA5QDhAOcAGhapAUUoiKAgf7w=="; + + //TCH_MTEXT, handle 949 + private const string MTextFixture = +"ChGQVQWQOQOBBEhF+0xNYe1kCEJmZ7s+5OVAAhFJnsARABGADcAMAA4AEIAMAAxAC0ARQBGADkAMAAtADQAOQA5ADIALQBCA" + +"DgAQwA1AC0AMgBCADgANgBBADcARgA5AEQANQAxADYAfQBLOEA5gDhAOYA4QDqAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAO" + +"EA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA4wDhAOUA4QDqAOAA6QDhAOU" + +"A4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQbgAPgA4QDlAOEA" + +"4wBGFQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEIARQBHAEkATgBfAEFVAAyADAAZAAxAEFVAAyADAAZAA" + +"yAEFVAAyADAAZAAzAEFVAAyADAAZAA0AEWVABEAEIAWABEAEEAVABBAE0AQQBQAF8AQgBMAE8AQwBLAF8ARQBOAEQAXwBJns" + +"AQgAwADcAMQAzAEUANgBCAC0AQQBGAEYANgAtADQAOAA3ADEALQBBADIANwAzAC0ANwA4ADkANgA4ADMANABEADYANgA3ADY" + +"AfQBBuEA5ADhAOUA4QDpAErTQB1AGwAdABpAGwAaQBuAGUAIABUAGUAeAB0AA0ACgBMAGkAbgBlACAAMgANAAoATABpAG4AZ" + +"QAgADMADQAKAC4ALgAuAA0ACgBMAGkAbgBlACAATgBKOEA4wDhAOQA4QDqAOAA6QDhAOoA4QDlAOEA5QDhAOUA4QDlAOEA5Q" + +"DhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjgAPgA4QDlAOEA6ADgAOkA4QDlAOEA5QDhAOUA4QD" + +"lAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEI4QDpAOEA5gDhAOQA4QDlAEG4QDpA" + +"OEA5QDhAOgASzhAOQA4QDjAOEA6gDhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAO" + +"UA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOoA4QDmAOEA5wDgAOkA4QDpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOE" + +"A5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDmAG4dqQFFKIigIHF"; + + //TCH_DRAWINGNAME, handle 939 + private const string DrawingNameFixture = +"ChGQQRqD65+LEYs81AgCkSsDO6PkQEmewBGADgARgAwAEYAMQAyAEYALQA5ADMAOAA5AC0ANAA5AEYAQwAtADgAMwA1AEQAL" + +"QA4AEMAOAA2AEMANgBFAEUARQBFAEQAQgB9AEs4AD4AOEA5gDhAOcA4QDlAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5Q" + +"DhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDqAOEA5QDhAOMA4ADpAOEA5QDhAOUA4QD" + +"lAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBBuAA9wDhAOUA4QDqAEYVABEA" + +"EIAWABEAEEAVABBAE0AQQBQAF8AQgBMAE8AQwBLAF8AQgBFAEcASQBOAF8ARZUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAE" + +"wATwBDAEsAXwBFAE4ARABfAEmewBCADAANwAxADMARQA2AEIALQBBAEYARgA2AC0ANAA4ADcAMQAtAEEAMgA3ADMALQA3ADg" + +"AOQA2ADgAMwA0AEQANgA2ADcANgB9AEG4AD4AOEA5QDhAOQAQ1EAHIAYQB3AGkAbgBnACAAVABpAHQAbABlAEFMQA6ADEAMA" + +"AwAEo4QDoAOEA5QDhAOgA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QD" + +"hAOUA4QDlAOEA5QBKOEA5wDhAOgA4QDoAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDl" + +"AOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOcA4QDqAOEA4wDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhA" + +"OUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDlAOEA5QDgAPgA4ADpAOEA6ADhAOUA4QDlAOEA5QDhAOUA4QDlAO" + +"EA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA5gDhAOUA4QDqAOAA6QDhAOcA4QDlAOEA5QDhAOU" + +"A4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQbhAOcA4QDlAOEA4wBBuAA+ADhAOYA4" + +"QDmAEG4QDqAOEA6ADhAOcAFB2pAUQoiKiI/L"; + + //TCH_DRAWINGINDEX, handle 1269 + private const string DrawingIndexFixture = +"ChFAXIAAAAAAAABzAQORVA5CEFAAAAAAAACRAQUAAAAAAAAIkBBQAAAAAAAAcwEDQFA5A4EEQMRFPT7DLFl2kAAudgrRQ70Q" + +"EmewBGADgARgAwAEYAMQAyAEYALQA5ADMAOAA5AC0ANAA5AEYAQwAtADgAMwA1AEQALQA4AEMAOAA2AEMANgBFAEUARQBFAE" + +"QAQgB9AEs4QDlAOEA5gDhAOQA4QDlAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOE" + +"A5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDoAOEA5QDgAPgA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA" + +"4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBBuEA5wDhAOUA4AD4AEFWQC5ZRFU3Y27eUYVABEAEIAWABEAEEAVAB" + +"BAE0AQQBQAF8AQgBMAE8AQwBLAF8AQgBFAEcASQBOAF8AQVUADIAMABkADEAQVUADIAMABkADIAQZUADIAMABkADIANwBBVQ" + +"AMgAwAGQAMwBBP5WDVSTj2VRQVUADIAMABkADQAQVUADIAMABkADUAQVUADIAMABkADYAQVUADIAMABkADcAQVUADIAMABkA" + +"DgAQVUADIAMABkADkARZUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBFAE4ARABfAEmewBCADAANwAxADMA" + +"RQA2AEIALQBBAEYARgA2AC0ANAA4ADcAMQAtAEEAMgA3ADMALQA3ADgAOQA2ADgAMwA0AEQANgA2ADcANgB9AEG4QDnAOEA5" + +"QDhAOcASjhAOgA4QDkAOAA+ADgAOkA4QDqAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4Q" + +"DlAOEA5QDhAOUA4QDlAEo4QDkAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QD" + +"hAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBBuEA6gDhAOgA4QDpAEo4AD4AOEA6ADhAOgA4ADpAOEA5QDhAOUA4QDlAOEA5QDhA" + +"OUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA5ADhAOUA4AD4AOAA6QDhAOoA4QDlAO" + +"EA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQExAJBDEAOgAyADAASjhAO" + +"UA4QDkAOAA9wDgAOkA4QDqAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAO" + +"UA4QDlAEo4AD3AOAA+ADhAOQA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOE" + +"A5QDhAOUA4QDlAOEA5QBBuEA5gDhAOYA4QDoAEG4QDoAOEA4wDhAOkASjgAPcA4QDlAOAA+ADgAOkA4QDlAOEA5QDhAOUA4Q" + +"DlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDnAOEA5wDgAPgA4ADpAOEA5QD" + +"hAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QAcKKkBQykBWCkB0ik" + +"BQykBWHJ"; + + private static byte[] decode(string value) => Convert.FromBase64String(value); + + [Fact] + public void TchTextPositionMatchesTheFlattenedExport() + { + TchText text = TchText.Parse("TCH_TEXT", decode(TextPayload), ACadVersion.AC1021); + + Assert.True(text.HasPosition); + Assert.Equal(39176.3507, text.InsertionPoint.X, 4); + Assert.Equal(29653.1919, text.InsertionPoint.Y, 4); + } + + [Fact] + public void TchTextReadsTheContent() + { + TchText text = TchText.Parse("TCH_TEXT", decode(TextPayload), ACadVersion.AC1021); + + //The export splits this into per-word text entities, but the object stores it whole. + Assert.Equal("Wall and Window and Door".Replace("and", "&"), text.Value); + } + + [Fact] + public void TchTextReadsContentThatLooksLikeNothingElse() + { + TchText text = TchText.Parse("TCH_TEXT", decode(SquareColumnFixture), ACadVersion.AC1021); + + Assert.Equal("Square Column", text.Value); + } + + [Fact] + public void TchTextReadsTheContentOfAMultilineText() + { + TchText text = TchText.Parse("TCH_MTEXT", decode(MTextFixture), ACadVersion.AC1021); + + //The export splits this into one entity per word per line; the object stores it whole. + //The line breaks are the reason this read as nothing for a while: a filter that allowed + //only printable characters rejected every multiline text while every single line one + //still came through. + Assert.Equal("Multiline Text\r\nLine 2\r\nLine 3\r\n...\r\nLine N", text.Value); + } + + [Fact] + public void TchTextReadsTheNameOfATitleBlock() + { + TchText text = TchText.Parse("TCH_DRAWINGNAME", decode(DrawingNameFixture), ACadVersion.AC1021); + + Assert.Equal("Drawing Title", text.Value); + } + + [Fact] + public void TchTextTakesNoContentFromAClassThatHasNone() + { + //A column's payload holds property names such as PUB_HATCH, which must not come back as + //text. + TchText text = TchText.Parse("TCH_COLUMN", decode(ColumnPayload), ACadVersion.AC1021); + + Assert.Null(text.Value); + } + + [Fact] + public void TchTextRejectsAShortPayload() + { + TchText text = TchText.Parse("TCH_TEXT", new byte[4], ACadVersion.AC1021); + + Assert.False(text.HasPosition); + Assert.Equal(CSMath.XYZ.Zero, text.InsertionPoint); + } + + [Fact] + public void TchTextThrowsOnANullPayload() + { + Assert.Throws(() => TchText.Parse("TCH_TEXT", null, ACadVersion.AC1021)); + } + + [Fact] + public void TchPolygonRecoversTheColumnSection() + { + TchPolygon polygon = TchPolygon.FindInPayload(decode(ColumnPayload), ACadVersion.AC1021, out long offset); + + Assert.NotNull(polygon); + Assert.True(polygon.IsComplete); + Assert.Equal(332, offset); + Assert.Equal(4, polygon.Vertices.Count); + + //The four corners of the column section, in order. + Assert.Equal(35910.0208, polygon.Vertices[0].Position.X, 4); + Assert.Equal(28751.9846, polygon.Vertices[0].Position.Y, 4); + Assert.Equal(36510.0208, polygon.Vertices[1].Position.X, 4); + Assert.Equal(28751.9846, polygon.Vertices[1].Position.Y, 4); + Assert.Equal(36510.0208, polygon.Vertices[2].Position.X, 4); + Assert.Equal(29351.9846, polygon.Vertices[2].Position.Y, 4); + Assert.Equal(35910.0208, polygon.Vertices[3].Position.X, 4); + Assert.Equal(29351.9846, polygon.Vertices[3].Position.Y, 4); + + //A straight edged outline, so every bulge is zero. + foreach (TchVertex vertex in polygon.Vertices) + { + Assert.Equal(0.0, vertex.Bulge); + } + } + + [Fact] + public void TchPolygonRecoversTheSectionSize() + { + TchPolygon polygon = TchPolygon.FindInPayload(decode(ColumnPayload), ACadVersion.AC1021, out _); + + double width = polygon.Vertices[1].Position.X - polygon.Vertices[0].Position.X; + double height = polygon.Vertices[2].Position.Y - polygon.Vertices[1].Position.Y; + + //The column is square, and its edge lands on the same grid as the walls in the flattened + //export of the same drawing. + Assert.Equal(600.0, width, 4); + Assert.Equal(600.0, height, 4); + } + + [Fact] + public void TchPolygonConvertsToAPolylineWithoutLosingTheShape() + { + TchPolygon polygon = TchPolygon.FindInPayload(decode(ColumnPayload), ACadVersion.AC1021, out _); + + ACadSharp.Entities.LwPolyline polyline = polygon.ToLwPolyline(); + + Assert.True(polyline.IsClosed); + Assert.Equal(polygon.Vertices.Count, polyline.Vertices.Count); + + for (int i = 0; i < polygon.Vertices.Count; i++) + { + Assert.Equal(polygon.Vertices[i].Position.X, polyline.Vertices[i].Location.X); + Assert.Equal(polygon.Vertices[i].Position.Y, polyline.Vertices[i].Location.Y); + Assert.Equal(polygon.Vertices[i].Bulge, polyline.Vertices[i].Bulge); + } + } + + [Fact] + public void TchPolygonCarriesTheColourAndLayerOfTheSourceObject() + { + TchPolygon polygon = TchPolygon.FindInPayload(decode(ColumnPayload), ACadVersion.AC1021, out _); + + //A third of the objects in the corpus carry an explicit colour rather than ByLayer, so + //the conversion has to carry it across. + ACadSharp.Entities.Line source = new ACadSharp.Entities.Line(); + source.Layer = new ACadSharp.Tables.Layer("COLUMN"); + source.Color = new ACadSharp.Color(30); + source.LineWeight = ACadSharp.LineWeightType.W50; + + ACadSharp.Entities.LwPolyline polyline = polygon.ToLwPolyline(source); + + Assert.Equal(source.Color, polyline.Color); + Assert.Equal(source.Layer.Name, polyline.Layer.Name); + Assert.Equal(source.LineWeight, polyline.LineWeight); + } + + [Fact] + public void TchPolygonLeavesPropertiesAtTheirDefaultsWithoutASource() + { + TchPolygon polygon = TchPolygon.FindInPayload(decode(ColumnPayload), ACadVersion.AC1021, out _); + + ACadSharp.Entities.LwPolyline polyline = polygon.ToLwPolyline(); + + Assert.True(polyline.Color.IsByLayer); + } + + [Fact] + public void TchPolygonConvertsAnArcEdgeAsABulge() + { + //TCH_CUT carries an outline with a bulge of 1 on its first vertex, a semicircular edge. + //The conversion has to keep it rather than flatten it. + TchPolygon polygon = TchPolygon.FindInPayload(decode(CutPayload), ACadVersion.AC1021, out _); + + ACadSharp.Entities.LwPolyline polyline = polygon.ToLwPolyline(); + + Assert.Equal(1.0, polyline.Vertices[0].Bulge); + Assert.Equal(0.0, polyline.Vertices[1].Bulge); + } + + [Fact] + public void TchGeometryFindsTheStartOfAWallCurve() + { + Assert.True(TchGeometry.TryFindPoint(decode(WallFixture), ACadVersion.AC1021, out CSMath.XYZ point, out long offset)); + + //The wall in the flattened export runs from 36510.021 along y = 29051.985, and the + //start of its centre line is what the object stores. + Assert.Equal(36510.0208, point.X, 4); + Assert.Equal(29051.9846, point.Y, 4); + Assert.Equal(0.0, point.Z); + Assert.Equal(428, offset); + } + + [Fact] + public void TchGeometryFindsAnOpeningPosition() + { + Assert.True(TchGeometry.TryFindPoint(decode(OpeningFixture), ACadVersion.AC1021, out CSMath.XYZ point, out _)); + + //The opening sits in a gap of the wall, and the export draws that gap between 44358.685 + //and 45158.685, so the opening is centred on the midpoint. + Assert.Equal(44758.6853, point.X, 4); + } + + [Fact] + public void TchGeometryFindsTheCentreLineOfAWall() + { + IReadOnlyList points = TchGeometry.FindPositions(decode(WallFixture), ACadVersion.AC1021); + + Assert.Equal(2, points.Count); + + //The export runs the wall from 36510.021 to 48824.945 along y = 29051.985, and those are + //the two positions. + Assert.Equal(36510.0208, points[0].X, 3); + Assert.Equal(29051.9846, points[0].Y, 3); + Assert.Equal(48824.9446, points[1].X, 3); + Assert.Equal(29051.9846, points[1].Y, 3); + } + + [Fact] + public void TchGeometryFindsThePositionOfAStair() + { + IReadOnlyList points = TchGeometry.FindPositions(decode(StairFixture), ACadVersion.AC1021); + + Assert.NotEmpty(points); + + //The export draws the flight starting from x = 67480.37. + Assert.Equal(67480.3701, points[0].X, 3); + } + + [Fact] + public void TchGeometryFindsThePositionOfADimension() + { + IReadOnlyList points = TchGeometry.FindPositions(decode(DimensionFixture), ACadVersion.AC1021); + + Assert.NotEmpty(points); + + //The export draws this one from (38843.062, 28951.985). + Assert.Equal(38843.0625, points[0].X, 3); + Assert.Equal(28951.9846, points[0].Y, 3); + } + + [Fact] + public void TchGeometryFindsThePositionOfATextObject() + { + IReadOnlyList points = TchGeometry.FindPositions(decode(TextPayload), ACadVersion.AC1021); + + Assert.Single(points); + Assert.Equal(39176.3507, points[0].X, 3); + Assert.Equal(29653.1919, points[0].Y, 3); + } + + [Fact] + public void TchGeometryFindsThePositionOfADrawingIndex() + { + IReadOnlyList points = TchGeometry.FindPositions(decode(DrawingIndexFixture), ACadVersion.AC1021); + + //The export puts a polyline at exactly this point, and the drawing number and scale next + //to it. This is the object that needed the tighter alignment tried as well: its position + //sits where the wider one says there is no room left before the class fields end. + Assert.Single(points); + Assert.Equal(82148.3232, points[0].X, 3); + Assert.Equal(27030.7664, points[0].Y, 3); + } + + [Fact] + public void TchGeometryThrowsOnANullPayloadWhenFindingPositions() + { + Assert.Throws(() => TchGeometry.FindPositions(null, ACadVersion.AC1021)); + } + + [Fact] + public void TchGeometryFindsThePointsOfAnIndexPointer() + { + IReadOnlyList points = TchGeometry.FindPoints(decode(IndexPointerFixture), ACadVersion.AC1021, out long offset); + + Assert.Equal(2, points.Count); + Assert.Equal(86, offset); + + //The export draws the pointer as a line from (45665.25, 29070.18) to + //(46469.90, 26579.10), and those are the two points. + Assert.Equal(45665.2489, points[0].X, 3); + Assert.Equal(29070.1762, points[0].Y, 3); + Assert.Equal(46469.9009, points[1].X, 3); + Assert.Equal(26579.0959, points[1].Y, 3); + } + + [Fact] + public void TchGeometryFindsThePointsOfASectionMark() + { + IReadOnlyList points = TchGeometry.FindPoints(decode(SectionFixture), ACadVersion.AC1021, out _); + + Assert.Equal(2, points.Count); + + //The export puts a polyline at (55806.38, 29903.54), so the Y agrees exactly. + Assert.Equal(29903.5382, points[0].Y, 3); + } + + [Fact] + public void TchGeometryReturnsNoPointsForATextObject() + { + Assert.Empty(TchGeometry.FindPoints(decode(TextPayload), ACadVersion.AC1021, out _)); + } + + [Fact] + public void TchGeometryThrowsOnANullPayloadWhenFindingPoints() + { + Assert.Throws(() => TchGeometry.FindPoints(null, ACadVersion.AC1021, out _)); + } + + [Fact] + public void TgDataEncryptUsesTheRecoveredAlphabet() + { + Assert.Equal(12, TgDataEncrypt.Alphabet.Count); + + //The digits are deliberately out of order: 6 sits below 0. + Assert.Equal('0', TgDataEncrypt.Alphabet[58]); + Assert.Equal('6', TgDataEncrypt.Alphabet[50]); + Assert.Equal('9', TgDataEncrypt.Alphabet[51]); + Assert.Equal('8', TgDataEncrypt.Alphabet[56]); + Assert.Equal('7', TgDataEncrypt.Alphabet[57]); + Assert.Equal('.', TgDataEncrypt.Alphabet[36]); + } + + [Fact] + public void TgDataEncryptDecodesTheColumnHeight() + { + IReadOnlyList values = TgDataEncrypt.ReadAllFrom(decode(ColumnPayload), ACadVersion.AC1021); + + Assert.NotEmpty(values); + + //The column section is 600 by 600, which its outline gives, and its height is 2600. + //Those come from different parts of the payload, so they confirm each other. + Assert.Contains(2600.0, values); + } + + [Fact] + public void TgDataEncryptDecodesTheWallDimensions() + { + IReadOnlyList values = TgDataEncrypt.ReadAllFrom(decode(WallFixture), ACadVersion.AC1021); + + Assert.NotEmpty(values); + + //All plausible drawing dimensions: nothing negative, nothing astronomical. + foreach (double value in values) + { + Assert.InRange(value, 0.0, 1.0e6); + } + + Assert.Contains(41700.0, values); + Assert.Contains(11900.0, values); + } + + [Fact] + public void TgDataEncryptRejectsAStringOutsideTheAlphabet() + { + //A class id is not ciphertext and must not be read as a number. + Assert.False(TgDataEncrypt.TryDecode("{F8F0F12F-9389-49FC-835D-8C86C6EEEEDB}", out _)); + } + + [Fact] + public void TgDataEncryptThrowsOnANullString() + { + Assert.Throws(() => TgDataEncrypt.Decode(null)); + } + + [Fact] + public void TchGeometryReadsAnElevationPosition() + { + Assert.True(TchGeometry.TryReadPosition("TCH_ELEVATION", decode(ElevationFixture), ACadVersion.AC1021, out CSMath.XY position)); + + //The export draws the symbol on the DIM_ELEV layer starting at exactly this point: + //Line (41657.97, 5785.30) -> (41357.97, 6085.30) + Assert.Equal(41657.97, position.X, 2); + Assert.Equal(5785.30, position.Y, 2); + } + + [Fact] + public void TchGeometryHasNoPositionForAnUnrecordedClass() + { + Assert.False(TchGeometry.TryReadPosition("TCH_NOT_A_CLASS", decode(ElevationFixture), ACadVersion.AC1021, out _)); + } + + [Fact] + public void TchGeometryRejectsAShortPayload() + { + Assert.False(TchGeometry.TryFindPoint(new byte[8], ACadVersion.AC1021, out _, out _)); + } + + [Fact] + public void TchGeometryThrowsOnANullPayload() + { + Assert.Throws(() => TchGeometry.TryFindPoint(null, ACadVersion.AC1021, out _, out _)); + } + + [Fact] + public void TchPolygonReturnsNullWhenThereIsNoOutline() + { + //A text object carries no outline. + TchPolygon polygon = TchPolygon.FindInPayload(decode(TextPayload), ACadVersion.AC1021, out long offset); + + Assert.Null(polygon); + Assert.Equal(-1, offset); + } + + [Fact] + public void TchPolygonThrowsOnANullPayload() + { + Assert.Throws(() => TchPolygon.FindInPayload(null, ACadVersion.AC1021, out _)); + } + + [Fact] + public void TchDataMapFindsTheBlockInsideANativePayload() + { + TchDataMap map = TchDataMap.ReadFromPayload(decode(WallPayload), ACadVersion.AC1021); + + Assert.True(map.IsComplete); + Assert.Contains(TchDataMap.BlockEndMarker, map.Entries); + Assert.Contains("T20s1", map.Entries); + } + + [Fact] + public void TchDataMapPairsDescriptorsWithTheirValues() + { + TchDataMap map = TchDataMap.ReadFromPayload(decode(WallPayload), ACadVersion.AC1021); + + Assert.False(map.IsEmpty); + Assert.Equal("A级", map.Properties["T20s1"]); + Assert.Equal("普通砖", map.Properties["T20s4"]); + Assert.Equal("保温层", map.Properties["T20s9"]); + } + + [Fact] + public void TchDataMapTreatsANameLookingValueAsAValue() + { + //PUB_HATCH is the value of T20s2, it only looks like a property name + TchDataMap map = TchDataMap.ReadFromPayload(decode(ColumnPayload), ACadVersion.AC1021); + + Assert.Equal("PUB_HATCH", map.Properties["T20s2"]); + Assert.Equal("钢筋混凝土", map.Properties["T20s1"]); + Assert.Equal("钢筋砼", map.Properties["T20s5"]); + } + + [Fact] + public void TchDataMapReturnsAnEmptyMapWhenThereIsNoBlock() + { + TchDataMap map = TchDataMap.ReadFromPayload(new byte[64], ACadVersion.AC1021); + + Assert.False(map.IsComplete); + Assert.True(map.IsEmpty); + } + } +} diff --git a/src/ACadSharp/Entities/UnknownEntity.cs b/src/ACadSharp/Entities/UnknownEntity.cs index d030367b2..d91a81c31 100644 --- a/src/ACadSharp/Entities/UnknownEntity.cs +++ b/src/ACadSharp/Entities/UnknownEntity.cs @@ -14,6 +14,20 @@ public class UnknownEntity : Entity /// public override ObjectType ObjectType => ObjectType.UNDEFINED; + /// + /// Gets the part of the object data that lays beyond the common entity data, as it is + /// stored in the file. + /// + /// + /// ACadSharp has no typed representation for this object, so the bytes written by the + /// class's own dwgOutFields are kept here verbatim. They are the only source of the + /// class specific fields, and they are what an application that knows the class reads back. + /// + /// Only populated when the reader is configured to keep unknown entities. + /// + /// + public byte[] RawData { get; internal set; } + /// public override string ObjectName { diff --git a/src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.cs b/src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.cs index 5c13baa9e..3d0c26dc9 100644 --- a/src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.cs +++ b/src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.cs @@ -7337,9 +7337,34 @@ private CadTemplate readUnknownEntity(DxfClass dxfClass) this.readCommonEntityData(template); + //The class specific fields have no reader, keep the bytes so that nothing is lost + entity.RawData = this.readRemainingObjectData(); + return template; } + /// + /// Reads the object data that has not been consumed yet, up to the end of the object. + /// + /// + /// The section has no length of its own, so it is delimited by the end of the object. That + /// end includes the handle section, which is left in place: consumers of these bytes look + /// for what they know, and the padding is harmless. + /// + private byte[] readRemainingObjectData() + { + long start = this._objectReader.PositionInBits(); + long end = this._objectInitialPos + ((long)this._size * 8L); + + long bits = end - start; + if (bits <= 0) + { + return null; + } + + return this._objectReader.ReadBytes((int)((bits + 7L) / 8L)); + } + private CadTemplate readUnknownNonGraphicalObject(DxfClass dxfClass) { UnknownNonGraphicalObject obj = new UnknownNonGraphicalObject(dxfClass); diff --git a/src/ACadSharp/IO/DWG/Tangent/TchDataMap.cs b/src/ACadSharp/IO/DWG/Tangent/TchDataMap.cs new file mode 100644 index 000000000..c60e98324 --- /dev/null +++ b/src/ACadSharp/IO/DWG/Tangent/TchDataMap.cs @@ -0,0 +1,400 @@ +using System; +using System.Collections.Generic; +using System.Text; + +namespace ACadSharp.IO.DWG.Tangent +{ + /// + /// Decoded Tianzheng (TArch / Tangent) property map stored inside a custom object payload. + /// + /// + /// + /// Tianzheng custom objects (TCH_* / TDb*) carry a self-describing property block. + /// It is written through AcDbDwgFiler, so it uses the ordinary DWG string encoding: + /// a BitShort character count followed by UTF-16LE characters. + /// + /// + /// The block is sparse: only properties that differ from their default are present, and + /// different classes carry different properties. Property names are upper snake case ASCII + /// (PIPE_SYSTEM_NAME, PIPE_THICK, ...); values may be numeric text or localized text. + /// + /// + /// The sequence ends with an (usually empty) TDBXDATAMAP_BLOCK_BEGIN_ / + /// TDBXDATAMAP_BLOCK_END_ marker pair. These markers terminate the map; they do not wrap it. + /// + /// + public sealed class TchDataMap + { + /// Marker written after the property sequence. + public const string BlockBeginMarker = "TDBXDATAMAP_BLOCK_BEGIN_"; + + /// Closing marker of the terminating marker pair. + public const string BlockEndMarker = "TDBXDATAMAP_BLOCK_END_"; + + private const int DefaultMaxEntries = 4096; + + private const int MaxStringLength = 200; + + private readonly List _entries = new List(); + + private readonly Dictionary _properties = new Dictionary(StringComparer.Ordinal); + + private TchDataMap() { } + + /// Gets the decoded strings in stream order. + public IReadOnlyList Entries => _entries; + + /// Gets the property name to value map built from . + /// + /// Tianzheng writes a property name immediately before its value. Names without a following + /// value (for example when a flag is set) are still listed in but are + /// not present here. + /// + public IReadOnlyDictionary Properties => _properties; + + /// Gets a value indicating whether the closing marker was reached. + public bool IsComplete { get; private set; } + + /// Gets a value indicating whether the object carried no properties. + /// + /// The terminating marker pair is not considered a property, so an object whose payload only + /// contains the markers is reported as empty. + /// + public bool IsEmpty => _properties.Count == 0; + + /// + /// Reads a Tianzheng property map from the current position of . + /// + /// Reader positioned at the start of the map. + /// Safety limit for the number of decoded strings. + /// The decoded map; never null. + /// + /// The reader position is restored if a string cannot be decoded, so the caller can continue + /// reading the enclosing object. + /// + internal static TchDataMap Read(IDwgStreamReader reader, int maxEntries = DefaultMaxEntries) + { + if (reader == null) + { + throw new ArgumentNullException(nameof(reader)); + } + + TchDataMap map = new TchDataMap(); + + for (int i = 0; i < maxEntries; i++) + { + if (reader.IsEmpty) + { + break; + } + + long start = reader.PositionInBits(); + string value; + try + { + value = reader.ReadVariableText(); + } + catch (Exception) + { + reader.SetPositionInBits(start); + break; + } + + if (string.IsNullOrEmpty(value) || value.Length > MaxStringLength || !isPrintable(value)) + { + reader.SetPositionInBits(start); + break; + } + + map._entries.Add(value); + + if (value == BlockEndMarker) + { + map.IsComplete = true; + break; + } + } + + map.buildProperties(); + return map; + } + + /// + /// Reads a Tianzheng property map from a detached payload, such as the retained data of a + /// proxy entity. + /// + /// Raw payload bytes. + /// Drawing version used for the string encoding. + public static TchDataMap Read(byte[] payload, ACadVersion version) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + using (System.IO.MemoryStream stream = new System.IO.MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + return Read(reader); + } + } + + /// + /// Reads a Tianzheng property map from the whole class specific payload of an object, such + /// as . + /// + /// + /// Unlike the map is not at the start of the payload + /// here: it follows the class's own fields and is bit aligned to whatever they consumed. The + /// block is located by trying every bit offset and keeping the longest readable string + /// sequence that contains the closing marker. + /// + /// Raw payload bytes. + /// Drawing version used for the string encoding. + /// The decoded map; never null. An empty map when no block is found. + public static TchDataMap ReadFromPayload(byte[] payload, ACadVersion version) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + if (payload.Length == 0) + { + return new TchDataMap(); + } + + using (System.IO.MemoryStream stream = new System.IO.MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + + return ReadFromPayload(reader, (long)payload.Length * 8L); + } + } + + private static TchDataMap ReadFromPayload(IDwgStreamReader reader, long limit) + { + TchDataMap best = null; + + for (long offset = 0; offset < limit; offset++) + { + TchDataMap candidate = tryReadSequence(reader, offset); + if (candidate == null) + { + continue; + } + + //The longest run wins: the properties precede the markers, so the sequence that + //starts earliest and still reaches the closing marker is the whole block. + if (best == null || candidate._entries.Count > best._entries.Count) + { + best = candidate; + } + } + + return best ?? new TchDataMap(); + } + + private static TchDataMap tryReadSequence(IDwgStreamReader reader, long offset) + { + TchDataMap map = new TchDataMap(); + reader.SetPositionInBits(offset); + + for (int i = 0; i < DefaultMaxEntries; i++) + { + if (reader.IsEmpty) + { + break; + } + + long start = reader.PositionInBits(); + string value; + try + { + value = reader.ReadVariableText(); + } + catch (Exception) + { + reader.SetPositionInBits(start); + break; + } + + if (string.IsNullOrEmpty(value) || value.Length > MaxStringLength || !isPrintable(value)) + { + break; + } + + map._entries.Add(value); + + if (value == BlockEndMarker) + { + map.IsComplete = true; + break; + } + } + + //Only a sequence that reached the closing marker describes a real block + if (!map.IsComplete || map._entries.Count < 2) + { + return null; + } + + map.buildProperties(); + return map; + } + + private static bool isPrintable(string value) + { + foreach (char c in value) + { + if (char.IsControl(c)) + { + return false; + } + } + + return true; + } + + private void buildProperties() + { + string pending = null; + foreach (string entry in _entries) + { + if (entry == BlockBeginMarker || entry == BlockEndMarker) + { + continue; + } + + if (isPropertyName(entry)) + { + //A descriptor followed by a plain name looking entry is a pair: the second one is + //a value that happens to look like a name, such as the hatch pattern PUB_HATCH. + if (pending != null && isTypedFieldDescriptor(pending) && !isTypedFieldDescriptor(entry)) + { + _properties[pending] = entry; + pending = null; + continue; + } + + // A name with no value of its own: it is either a flag or its value is written + // through the native (non string) path. + if (pending != null) + { + _properties[pending] = null; + } + + pending = entry; + if (!_properties.ContainsKey(entry)) + { + _properties[entry] = null; + } + } + else if (pending != null) + { + _properties[pending] = entry; + pending = null; + } + } + } + + /// + /// Decides whether an entry looks like a Tianzheng property name. + /// + /// + /// Names are upper case ASCII. Numeric looking entries such as DN25 are values, not + /// names, so an entry containing digits is only accepted as a name when it also contains an + /// underscore. + /// + /// + /// Decides whether an entry is a typed field descriptor rather than a value. + /// + /// + /// Native objects describe their fields as T20 followed by an optional version prefix + /// V<n>_, a type letter (b boolean, d double, s string, + /// u unsigned) and either an index or a readable name: + /// T20s1, T20b2, T20d12, T20V9_dRealDist2DimLine. + /// + private static bool isTypedFieldDescriptor(string value) + { + if (value.Length < 5 || !value.StartsWith("T20", StringComparison.Ordinal)) + { + return false; + } + + int i = 3; + + if (i < value.Length && value[i] == 'V') + { + i++; + while (i < value.Length && value[i] >= '0' && value[i] <= '9') + { + i++; + } + + if (i >= value.Length || value[i] != '_') + { + return false; + } + + i++; + } + + if (i >= value.Length) + { + return false; + } + + char type = value[i]; + if (type != 'b' && type != 'd' && type != 's' && type != 'u') + { + return false; + } + + return i + 1 < value.Length; + } + + private static bool isPropertyName(string value) + { + if (isTypedFieldDescriptor(value)) + { + return true; + } + + if (value.Length < 3) + { + return false; + } + + bool hasLetter = false; + bool hasDigit = false; + bool hasUnderscore = false; + + foreach (char c in value) + { + if (c >= 'a' && c <= 'z') + { + return false; + } + else if (c >= 'A' && c <= 'Z') + { + hasLetter = true; + } + else if (c >= '0' && c <= '9') + { + hasDigit = true; + } + else if (c == '_') + { + hasUnderscore = true; + } + else + { + return false; + } + } + + return hasLetter && (!hasDigit || hasUnderscore); + } + } +} diff --git a/src/ACadSharp/IO/DWG/Tangent/TchEntityLayout.cs b/src/ACadSharp/IO/DWG/Tangent/TchEntityLayout.cs new file mode 100644 index 000000000..050a76379 --- /dev/null +++ b/src/ACadSharp/IO/DWG/Tangent/TchEntityLayout.cs @@ -0,0 +1,60 @@ +using System.Collections.Generic; + +namespace ACadSharp.IO.DWG.Tangent +{ + /// + /// Positions at which individual classes store geometry that is not part of an outline. + /// + /// + /// + /// Most classes can be handled without a table: their outline is a polygon and + /// finds it, or their curve is a 3-D point and + /// finds that. A few classes store a bare 2-D point and + /// nothing else, and for those neither search has anything to key on, so the offset is recorded + /// here instead. + /// + /// + /// **These offsets are valid for the version the sample drawings use.** They sit after the base + /// class fields, whose length is version dependent, so a drawing written by a different version + /// will need them re-measured. The same caveat applies to , which relies on + /// the same kind of offset. + /// + /// + /// Each entry was found by comparing instances of one class: fields that hold geometry differ + /// between objects, while defaults, property block contents and padding repeat. Taking the + /// offsets where the values vary, and only those, leaves the geometry. Each is then confirmed + /// against the coordinates in Tianzheng's own flattened export of the same drawing. + /// + /// + internal static class TchEntityLayout + { + private static readonly Dictionary _positions = new() + { + //TCH_ELEVATION, three instances, confirmed against the DIM_ELEV layer of the export: + //the export draws the symbol from exactly these points. Note that X sits at the higher + //offset; the same is true of TchText, so the pair is written Y first. + { "TCH_ELEVATION", (142, 78) }, + }; + + /// + /// Gets the bit offsets of a 2-D position stored by the given class. + /// + /// DXF name of the class, for example TCH_ELEVATION. + /// Receives the bit offset of the X coordinate. + /// Receives the bit offset of the Y coordinate. + /// True when the class has a recorded position. + public static bool TryGetPositionOffset(string dxfName, out int xOffset, out int yOffset) + { + if (dxfName != null && _positions.TryGetValue(dxfName, out (int X, int Y) offsets)) + { + xOffset = offsets.X; + yOffset = offsets.Y; + return true; + } + + xOffset = -1; + yOffset = -1; + return false; + } + } +} diff --git a/src/ACadSharp/IO/DWG/Tangent/TchGeometry.cs b/src/ACadSharp/IO/DWG/Tangent/TchGeometry.cs new file mode 100644 index 000000000..ea962deaf --- /dev/null +++ b/src/ACadSharp/IO/DWG/Tangent/TchGeometry.cs @@ -0,0 +1,481 @@ +using CSMath; +using System; +using System.Collections.Generic; +using System.IO; + +namespace ACadSharp.IO.DWG.Tangent +{ + /// + /// Reads the geometric primitives Tianzheng stores on an object but that are not an outline. + /// + /// + /// + /// A class that has no filled section, a wall for instance, still stores the curve it is built + /// on. That curve is a pair of 3-D points, and a point is written as three BitDouble + /// values rather than as three raw doubles, so it is variable length: the 2-bit selector in + /// front of each coordinate is 0 when eight bytes follow, 1 for 1.0 and 2 for 0.0. A point of a + /// horizontal wall therefore occupies 134 bits, not the 192 a fixed layout would take. + /// + /// + /// That variable length is why a search for a 24-byte point finds nothing, and it is the reason + /// this helper exists rather than a fixed offset table. + /// + /// + public static class TchGeometry + { + /// Bit size of a point whose X and Y are both stored in full and whose Z is zero. + public const int PointBits = 134; + + /// + /// Smallest magnitude accepted for a horizontal coordinate. + /// + /// + /// The filter has to reject the values a misaligned read produces, and those cluster near + /// zero: reading a tag or a short as a double yields values of a few units. Architectural + /// drawings are dimensioned in millimetres, so requiring three digits keeps the real + /// coordinates and drops that noise. A drawing in metres would need this lowered. + /// + public const double MinimumCoordinate = 100.0; + + /// + /// Reads the 2-D position that a class stores at a recorded offset. + /// + /// + /// For the classes in the position is a bare pair of + /// coordinates with nothing around it to recognise, so it is read at its offset rather than + /// searched for. Use for the classes that store a curve. + /// + /// DXF name of the class, for example TCH_ELEVATION. + /// The class specific data of the object. + /// Drawing version used by the stream reader. + /// Receives the position that was read. + /// True when the class has a recorded position and the payload is long enough. + public static bool TryReadPosition(string dxfName, byte[] payload, ACadVersion version, out XY position) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + position = XY.Zero; + + if (!TchEntityLayout.TryGetPositionOffset(dxfName, out int xOffset, out int yOffset)) + { + return false; + } + + if ((long)payload.Length * 8L < yOffset + 64) + { + return false; + } + + using (MemoryStream stream = new MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + + reader.SetPositionInBits(xOffset); + double x = reader.ReadDouble(); + + reader.SetPositionInBits(yOffset); + double y = reader.ReadDouble(); + + position = new XY(x, y); + return true; + } + } + + /// + /// Searches a class specific payload for the first well formed 3-D point. + /// + /// + /// The point does not sit at a fixed offset, so every bit offset is tried and a candidate is + /// accepted only when all three coordinates decode to plausible values. On a wall this + /// returns the start of the curve the wall is built on. + /// + /// The class specific data of the object. + /// Drawing version used by the stream reader. + /// Receives the point that was found. + /// Receives the bit offset the point was found at. + /// True when a point was found. + public static bool TryFindPoint(byte[] payload, ACadVersion version, out XYZ point, out long bitOffset) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + point = XYZ.Zero; + bitOffset = -1; + + if (payload.Length < 32) + { + return false; + } + + using (MemoryStream stream = new MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + long limit = (long)payload.Length * 8L; + + for (long offset = 0; offset < limit - PointBits; offset++) + { + if (!tryReadPoint(reader, offset, limit, out XYZ candidate)) + { + continue; + } + + point = candidate; + bitOffset = offset; + return true; + } + } + + return false; + } + + /// + /// Searches a class specific payload for the positions an object carries. + /// + /// + /// + /// This is the general form of the shape and + /// were special cases of, and it is present in every class in + /// the corpus. A position is three raw doubles: Y, then X sixty-eight bits later, then Z + /// twenty-four bits after that. The two gaps differ because the fields in front of them do, + /// and they hold for every class measured. + /// + /// + /// A wall carries two of these, its centre line; a text object one; a window one; a stair + /// one. Nothing marks them, so the search takes every offset that produces a plausible + /// position and returns them in order. Z is required to be small, which is what keeps a + /// misaligned read from passing: an X or Y read at the wrong bit is almost always + /// astronomical, but a Z read at the wrong bit can look ordinary unless it is bounded. + /// + /// + /// Each result is confirmed against the flattened export elsewhere in this library's tests; + /// for the wall the two positions are the ends of the centre line the export draws, and for + /// a stair the X lands on the first line of the flight. + /// + /// + /// The class specific data of the object. + /// Drawing version used by the stream reader. + /// The positions in the order they appear; empty when there are none. + public static IReadOnlyList FindPositions(byte[] payload, ACadVersion version) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + List positions = new List(); + + if (payload.Length < 32) + { + return positions; + } + + using (MemoryStream stream = new MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + long limit = (long)payload.Length * 8L; + + long boundary = findFieldBoundary(reader, limit); + + //The tighter alignment needs less room, so the loop has to allow for it: a drawing + //index puts its position exactly where the wider check would say there is no room. + for (long offset = 0; offset + XOffsetTight + CoordinateBits <= boundary; offset++) + { + if (tryReadPosition(reader, offset, XOffset, out XYZ position) + || tryReadPosition(reader, offset, XOffsetTight, out position)) + { + positions.Add(position); + } + } + } + + return positions; + } + + /// + /// Bit gap between Y and X within a stored position. + /// + /// + /// Most classes put 68 bits between the two. Two in the corpus use 64, which is the same + /// shape written from a class whose fields in front end two bits earlier. The search tries + /// both rather than assuming one. + /// + public const int XOffset = 68; + + /// The same gap for the classes that align it two bits earlier. + public const int XOffsetTight = 64; + + /// Bit gap between X and Z within a stored position. + public const int ZOffset = 24; + + /// + /// Locates the point in a payload past which the class fields end. + /// + /// + /// + /// The fields the base class writes, which is where the position lives, all come before the + /// class id and the property block. Past that boundary the payload holds text, and text read + /// at a misaligned bit yields values that pass for coordinates — the same pair turns up in + /// every object because the same block sits in every object. + /// + /// + /// So the search stops at the first class id or block marker rather than filtering its + /// results afterwards. The boundary is generous: a position measured at bit 84 of a text + /// object and one at 450 of a drawing index both sit far below their boundary. + /// + /// + private static long findFieldBoundary(IDwgStreamReader reader, long limit) + { + long mark = reader.PositionInBits(); + + for (long offset = 0; offset < limit; offset++) + { + string value; + + try + { + reader.SetPositionInBits(offset); + value = reader.ReadVariableText(); + } + catch (Exception) + { + continue; + } + + if (value != null && isBoundary(value)) + { + reader.SetPositionInBits(mark); + return offset; + } + } + + reader.SetPositionInBits(mark); + return limit; + } + + private static bool isBoundary(string value) + { + if (value.Length == 38 && value[0] == '{' && value[value.Length - 1] == '}') + { + return true; + } + + return value.StartsWith("TDBXDATAMAP", System.StringComparison.Ordinal); + } + + private static bool tryReadPosition(IDwgStreamReader reader, long offset, int xOffset, out XYZ position) + { + position = XYZ.Zero; + + try + { + reader.SetPositionInBits(offset); + double y = reader.ReadDouble(); + + reader.SetPositionInBits(offset + xOffset); + double x = reader.ReadDouble(); + + reader.SetPositionInBits(offset + xOffset + ZOffset); + double z = reader.ReadDouble(); + + if (!isPlausible(x) || !isPlausible(y) || !isFinite(z) || Math.Abs(z) > 1.0e5) + { + return false; + } + + position = new XYZ(x, y, z); + return true; + } + catch (Exception) + { + return false; + } + } + + /// + /// Searches a class specific payload for a run of 2-D points. + /// + /// + /// + /// Several classes store the points they are drawn from as a bare run of coordinates with + /// nothing in front of them: an index pointer is a line between two points, a section mark + /// carries its three, and so on. Each point is written Y first then X, 64 bits apart, and + /// the next point begins immediately after, so the whole run is a sequence of plausible + /// coordinates at a 64 bit stride. + /// + /// + /// The longest such run wins. A run of one coordinate is not accepted, because a single + /// plausible double carries too little information to call it a point. + /// + /// + /// Verified on an index pointer in the sample drawing, whose two points come out as + /// (45665.2489, 29070.1762) and (46469.9009, 26579.0959), while the flattened export + /// draws the line from (45665.25, 29070.18) to (46469.90, 26579.10). + /// + /// + /// The class specific data of the object. + /// Drawing version used by the stream reader. + /// Receives the bit offset of the first Y coordinate. + /// The points in order; empty when the payload holds no such run. + public static IReadOnlyList FindPoints(byte[] payload, ACadVersion version, out long bitOffset) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + bitOffset = -1; + + List best = new List(); + + if (payload.Length < 32) + { + return best; + } + + using (MemoryStream stream = new MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + long limit = (long)payload.Length * 8L; + + for (long offset = 0; offset < limit - 2L * CoordinateBits; offset++) + { + List run = tryReadRun(reader, offset, limit); + if (run != null && run.Count > best.Count) + { + best = run; + bitOffset = offset; + } + } + } + + return best; + } + + private static List tryReadRun(IDwgStreamReader reader, long offset, long limit) + { + List points = new List(); + + try + { + reader.SetPositionInBits(offset); + + while (reader.PositionInBits() + 2L * CoordinateBits <= limit) + { + double y = reader.ReadDouble(); + double x = reader.ReadDouble(); + + if (!isPlausible(x) || !isPlausible(y)) + { + break; + } + + points.Add(new XY(x, y)); + } + } + catch (Exception) + { + return null; + } + + //Two coordinates are one point and tell us nothing; a run is worth having from two + //points, that is four coordinates, upwards. + return points.Count >= 2 ? points : null; + } + + /// Bit size of one coordinate of a stored point. + public const int CoordinateBits = 64; + + /// + /// Searches a class specific payload for a point whose X and Y match the given values. + /// + /// The class specific data of the object. + /// Drawing version used by the stream reader. + /// Expected X. + /// Expected Y. + /// Accepted difference, in drawing units. + /// Receives the bit offset the point was found at. + public static bool ContainsPoint(byte[] payload, ACadVersion version, double x, double y, double tolerance, out long bitOffset) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + return findMatching(payload, version, x, y, tolerance, out bitOffset); + } + + private static bool findMatching(byte[] payload, ACadVersion version, double x, double y, double tolerance, out long bitOffset) + { + bitOffset = -1; + + using (MemoryStream stream = new MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + long limit = (long)payload.Length * 8L; + + for (long offset = 0; offset < limit - PointBits; offset++) + { + if (!tryReadPoint(reader, offset, limit, out XYZ candidate)) + { + continue; + } + + if (Math.Abs(candidate.X - x) <= tolerance && Math.Abs(candidate.Y - y) <= tolerance) + { + bitOffset = offset; + return true; + } + } + } + + return false; + } + + private static bool tryReadPoint(IDwgStreamReader reader, long offset, long limit, out XYZ point) + { + point = XYZ.Zero; + + try + { + reader.SetPositionInBits(offset); + + if (reader.PositionInBits() + PointBits > limit) + { + return false; + } + + double x = reader.ReadBitDouble(); + double y = reader.ReadBitDouble(); + double z = reader.ReadBitDouble(); + + if (!isPlausible(x) || !isPlausible(y) || !isFinite(z) || Math.Abs(z) > 1.0e7) + { + return false; + } + + point = new XYZ(x, y, z); + return true; + } + catch (Exception) + { + //A misaligned offset produces values the reader rejects. + return false; + } + } + + private static bool isPlausible(double value) + { + return isFinite(value) && Math.Abs(value) > MinimumCoordinate && Math.Abs(value) < 1.0e7; + } + + private static bool isFinite(double value) + { + return !double.IsNaN(value) && !double.IsInfinity(value); + } + } +} diff --git a/src/ACadSharp/IO/DWG/Tangent/TchPolygon.cs b/src/ACadSharp/IO/DWG/Tangent/TchPolygon.cs new file mode 100644 index 000000000..7298b326f --- /dev/null +++ b/src/ACadSharp/IO/DWG/Tangent/TchPolygon.cs @@ -0,0 +1,281 @@ +using ACadSharp.Entities; +using CSMath; +using System; +using System.Collections.Generic; +using System.IO; + +namespace ACadSharp.IO.DWG.Tangent +{ + /// + /// A vertex of a . + /// + public readonly struct TchVertex + { + /// Gets the position of the vertex. + public XY Position { get; } + + /// + /// Gets the bulge of the edge that starts at this vertex, which is the tangent of a quarter + /// of the arc's included angle. Zero means a straight segment. + /// + public double Bulge { get; } + + internal TchVertex(double x, double y, double bulge) + { + this.Position = new XY(x, y); + this.Bulge = bulge; + } + + /// + public override string ToString() => $"({this.Position.X}, {this.Position.Y}) bulge {this.Bulge}"; + } + + /// + /// A polygon as Tianzheng stores the outline of an object. + /// + /// + /// + /// The layout matches TchExport2::ReadPoly2D (0x1C4C6880): a flags byte, a vertex + /// count, then that many vertices, and finally an object id. Every value is read through a plain + /// filer call, so the outline is stored in the clear and no part of the obfuscation layer + /// applies to it. + /// + /// + /// flags byte + /// count byte + /// repeat count: + /// X 64-bit little endian double + /// Y 64-bit little endian double + /// bulge BitDouble: 2-bit selector, 0 reads a further double, 1 is 1.0, 2 is 0.0 + /// + /// + /// The vertex stride is therefore 130 bits for a straight edge and 194 bits for an arc, which is + /// what makes the layout self-delimiting. + /// + /// + /// Verified on a TCH_COLUMN in the sample drawing: the recovered four vertices form the + /// 600 by 600 section of the column, and their coordinates land on the same grid as the walls in + /// Tianzheng's own flattened export of the same drawing. + /// + /// + public sealed class TchPolygon + { + /// Bit size of a vertex whose edge is straight. + public const int StraightVertexBits = 130; + + /// Bit size of a vertex whose edge is an arc. + public const int ArcVertexBits = 194; + + private readonly List _vertices = new List(); + + private TchPolygon(byte flags) + { + this.Flags = flags; + } + + /// Gets the flags byte that precedes the vertex list. + public byte Flags { get; } + + /// Gets the vertices in the order they are stored. + public IReadOnlyList Vertices => _vertices; + + /// Gets a value indicating whether the outline was read in full. + public bool IsComplete { get; private set; } + + /// + /// Reads a polygon at the current position of . + /// + internal static TchPolygon Read(IDwgStreamReader reader) + { + byte flags = reader.ReadByte(); + byte count = reader.ReadByte(); + + TchPolygon polygon = new TchPolygon(flags); + + for (int i = 0; i < count; i++) + { + double x = reader.ReadDouble(); + double y = reader.ReadDouble(); + double bulge = reader.ReadBitDouble(); + + polygon._vertices.Add(new TchVertex(x, y, bulge)); + } + + polygon.IsComplete = true; + return polygon; + } + + /// + /// Searches a class specific payload for the outline Tianzheng stored for an object. + /// + /// + /// + /// The outline does not sit at a fixed offset: it follows the fields the class wrote before + /// it, and for some classes it is only present when a flag is set. The search therefore tries + /// every bit offset and keeps a candidate only when every vertex decodes to a coordinate of a + /// plausible magnitude, which is a strong enough filter that the correct offset is normally + /// the only one that survives. + /// + /// + /// Returns null when the object carries no outline, which is the normal case for a class with + /// no filled section, such as a text object. + /// + /// + /// The class specific data of the object. + /// Drawing version used by the stream reader. + /// Receives the bit offset the outline was found at. + public static TchPolygon FindInPayload(byte[] payload, ACadVersion version, out long bitOffset) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + bitOffset = -1; + + if (payload.Length < 32) + { + return null; + } + + using (MemoryStream stream = new MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + long limit = (long)payload.Length * 8L; + + TchPolygon best = null; + long bestOffset = -1; + + for (long offset = 0; offset < limit - StraightVertexBits; offset++) + { + TchPolygon candidate = tryReadAt(reader, offset, limit); + if (candidate == null) + { + continue; + } + + //Keep the longest: a match that starts inside a real outline yields a shorter + //run of that outline's vertices, so the longest candidate is the whole one. + if (best == null || candidate._vertices.Count > best._vertices.Count) + { + best = candidate; + bestOffset = offset; + } + } + + bitOffset = bestOffset; + return best; + } + } + + private static TchPolygon tryReadAt(IDwgStreamReader reader, long offset, long limit) + { + try + { + return tryReadAtCore(reader, offset, limit); + } + catch (Exception) + { + //A misaligned offset produces values the reader rejects, which is the expected + //outcome for all but one of the offsets tried. + return null; + } + } + + private static TchPolygon tryReadAtCore(IDwgStreamReader reader, long offset, long limit) + { + reader.SetPositionInBits(offset); + + if (reader.PositionInBits() + 16 > limit) + { + return null; + } + + byte flags = reader.ReadByte(); + byte count = reader.ReadByte(); + + if (count < 2 || count > 64) + { + return null; + } + + TchPolygon polygon = new TchPolygon(flags); + + for (int i = 0; i < count; i++) + { + if (reader.PositionInBits() + StraightVertexBits > limit) + { + return null; + } + + double x = reader.ReadDouble(); + double y = reader.ReadDouble(); + double bulge = reader.ReadBitDouble(); + + if (!isPlausible(x) || !isPlausible(y)) + { + return null; + } + + polygon._vertices.Add(new TchVertex(x, y, bulge)); + } + + polygon.IsComplete = true; + return polygon; + } + + /// + /// Converts the outline into a lightweight polyline, which is how a consumer draws it. + /// + /// + /// + /// The two representations line up exactly: a vertex carries a position and a bulge in both, + /// and the bulge has the same meaning, so the shape survives the conversion without + /// approximation. Nothing is lost except the flags byte. + /// + /// + /// Colour, layer, linetype and the rest of the common properties live on the source object + /// rather than in its payload, and they are carried across through + /// . That matters in practice: of the objects in + /// the test corpus, 71 are ByLayer and 40 carry an explicit indexed colour, so a conversion + /// that left the colour at its default would draw a third of them wrong. + /// + /// + /// Object to take the common properties from, or null to leave them at + /// their defaults. + /// Whether to mark the polyline closed, which is the usual case for an + /// object outline but not for every class. + /// A polyline with one vertex per outline vertex. + public LwPolyline ToLwPolyline(IEntity source = null, bool closed = true) + { + LwPolyline polyline = new LwPolyline(); + polyline.IsClosed = closed; + + foreach (TchVertex vertex in _vertices) + { + polyline.Vertices.Add(new LwPolyline.Vertex(vertex.Position) + { + Bulge = vertex.Bulge + }); + } + + if (source != null) + { + polyline.MatchProperties(source); + } + + return polyline; + } + + private static bool isPlausible(double value) + { + //A drawing coordinate. The bound is loose on purpose: it only has to reject the bit + //patterns that a misaligned read produces, which are almost always astronomically large + //or denormal. + return !double.IsNaN(value) + && !double.IsInfinity(value) + && Math.Abs(value) > 1.0 + && Math.Abs(value) < 1.0e7; + } + } +} diff --git a/src/ACadSharp/IO/DWG/Tangent/TchText.cs b/src/ACadSharp/IO/DWG/Tangent/TchText.cs new file mode 100644 index 000000000..6daa49738 --- /dev/null +++ b/src/ACadSharp/IO/DWG/Tangent/TchText.cs @@ -0,0 +1,218 @@ +using ACadSharp.IO.DWG; +using CSMath; +using System; +using System.Collections.Generic; +using System.IO; + +namespace ACadSharp.IO.DWG.Tangent +{ + /// + /// Position of a Tianzheng text object (TCH_TEXT / TCH_MTEXT). + /// + /// + /// + /// The offsets are relative to the start of the class specific data, which is what + /// holds. They were verified on a drawing with + /// eleven TCH_TEXT objects: the extracted points match the insertion points of the + /// corresponding text entities in Tianzheng's own flattened export of the same drawing exactly, + /// with no offset at all. That export is produced by a different tool, so the check is + /// independent rather than a re-reading of the same assumption. + /// + /// + /// The layout is fixed for these classes because everything in front of it is constant: + /// + /// + /// bit 0 .. 7 base class version, 10 in the sample drawings + /// bit 8 .. 15 tag, 17 in the sample drawings + /// bit 16 .. 17 layout name, empty + /// bit 18 .. 83 base class fields covered by the obfuscation layer + /// bit 84 .. 147 Y coordinate, IEEE double, little endian + /// bit 148 .. 151 gap + /// bit 152 .. 215 X coordinate, IEEE double, little endian + /// bit 216 .. class id, property block and the remaining fields + /// + /// + /// Only the position is exposed. The text content is stored further along, interleaved with + /// values that use the same string encoding, and telling them apart needs the field walk to know + /// where the class's own fields end. A heuristic scan of the entries cannot do it reliably, so + /// no attempt is made here. + /// + /// + public sealed class TchText + { + /// Bit offset of the Y coordinate. + public const int YOffset = 84; + + /// Bit offset of the X coordinate. + public const int XOffset = 152; + + private const int RequiredBits = XOffset + 64; + + private TchText() + { + } + + /// Gets the insertion point of the text, in drawing coordinates. + public XYZ InsertionPoint { get; private set; } = XYZ.Zero; + + /// Gets a value indicating whether the payload carried a readable position. + public bool HasPosition { get; private set; } + + /// + /// Gets the text content, or null when the payload carried none that could be told apart from + /// the object's own fields. + /// + /// + /// + /// The entries in a payload interleave three kinds of string and only the last is content. + /// The class id and the block markers are recognisable by shape, and the scalars are + /// recognisable because they decode through the alphabet of §4.2.2 — that is what makes this + /// readable now, where an earlier attempt failed. What is left is text. + /// + /// + /// Misaligned reads of the other kinds survive that filter as short strings of unusual + /// characters, so the longest candidate is taken. On the sample drawing that gives the right + /// content for every text object: Wall & Window & Door, Square Column, + /// Elevation, Breakline, U Stair, Line Stair, + /// Multi-flight Staircase, Callout Title and axial grid with Dimension. + /// + /// + public string Value { get; private set; } + + /// + /// Reads the position of a TCH_TEXT or TCH_MTEXT object. + /// + /// DXF name of the class. Content is only taken from the classes this + /// reader is for; the payload of any other class holds property names that would otherwise + /// be mistaken for text. + /// The class specific data of the object. + /// Drawing version used by the string encoding. + /// The parsed position; never null. + public static TchText Parse(string dxfName, byte[] payload, ACadVersion version) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + TchText text = new TchText(); + + if ((long)payload.Length * 8L < RequiredBits) + { + return text; + } + + using (MemoryStream stream = new MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + + reader.SetPositionInBits(YOffset); + double y = reader.ReadDouble(); + + reader.SetPositionInBits(XOffset); + double x = reader.ReadDouble(); + + text.InsertionPoint = new XYZ(x, y, 0.0); + text.HasPosition = true; + } + + //Only the classes that write text take content from this; for anything else the longest + //readable string is a property name, which is not content. TCH_DRAWINGNAME carries the + //title block's name and its scale as two strings. + if (dxfName == "TCH_TEXT" || dxfName == "TCH_MTEXT" || dxfName == "TCH_DRAWINGNAME") + { + readContent(text, payload, version); + } + + return text; + } + private static void readContent(TchText text, byte[] payload, ACadVersion version) + { + using (MemoryStream stream = new MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + long limit = (long)payload.Length * 8L; + + while (reader.PositionInBits() < limit) + { + long start = reader.PositionInBits(); + string value; + + try + { + value = reader.ReadVariableText(); + } + catch (Exception) + { + reader.SetPositionInBits(start + 1); + continue; + } + + if (isContent(value) && (text.Value == null || value.Length > text.Value.Length)) + { + text.Value = value; + } + + reader.SetPositionInBits(start + 1); + } + } + } + + private static bool isContent(string value) + { + if (value == null || value.Length < 2 || value.Length > 400) + { + return false; + } + + //Markers, field descriptors and the class id are all recognisable by shape. + if (value.StartsWith("TDBXDATAMAP", StringComparison.Ordinal) || value.StartsWith("T20", StringComparison.Ordinal)) + { + return false; + } + + if (value[0] == '{' || value[value.Length - 1] == '}') + { + return false; + } + + //A scalar decodes through the alphabet, so it is not content. + if (TgDataEncrypt.TryDecode(value, out _)) + { + return false; + } + + int core = 0; + foreach (char c in value) + { + int o = c; + + //The blocks a drawing's text actually uses: ASCII, CJK ideographs, CJK punctuation, + //fullwidth forms, and a few typographic marks. + //A multiline text carries its line breaks, so the control characters a drawing + //legitimately contains are allowed. Leaving them out rejected every multiline text + //while every single line one still read, which is why the gap went unnoticed. + bool allowed = (o >= 0x20 && o <= 0x7E) + || o == '\t' || o == '\n' || o == '\r' || o == '\f' || o == '\v' + || (o >= 0x4E00 && o <= 0x9FFF) + || (o >= 0x3000 && o <= 0x303F) + || (o >= 0xFF00 && o <= 0xFFEF) + || o == 0x2011 || o == 0x2013 || o == 0x2014 + || o == 0x2018 || o == 0x2019 || o == 0x201C || o == 0x201D + || o == 0x2026 || o == 0x00B0 || o == 0x00D7 || o == 0x00B1; + + if (!allowed) + { + return false; + } + + if (char.IsLetterOrDigit(c)) + { + core++; + } + } + + return core >= 2; + } + } +} \ No newline at end of file diff --git a/src/ACadSharp/IO/DWG/Tangent/TgDataEncrypt.cs b/src/ACadSharp/IO/DWG/Tangent/TgDataEncrypt.cs new file mode 100644 index 000000000..2822e1d23 --- /dev/null +++ b/src/ACadSharp/IO/DWG/Tangent/TgDataEncrypt.cs @@ -0,0 +1,209 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Text; + +namespace ACadSharp.IO.DWG.Tangent +{ + /// + /// Decoder for the layer Tianzheng puts between its numeric fields and the file. + /// + /// + /// + /// A scalar field is not stored as a number. It is stored as a string whose characters are + /// drawn from a small alphabet, and each pair of characters carries one character of the + /// decimal text the value would have been printed as. TgDataEncrypt::TDReadDouble reads + /// the string, decodes it and hands the result to _wtof. + /// + /// + /// One pair decodes to (c1 - K) * 26 + (c2 - K), where K comes from a table + /// indexed by the tag and the context the object carries. For the drawings available here the + /// layout name is empty, so the context is 0, the tag is 17 and K is 0xDF. That + /// the pair values land in 0..25 for every string in the corpus is what confirms the + /// key rather than merely fitting it. + /// + /// + /// The pair value then maps to a character through , which is a table + /// rather than a shift: the digits are not in value order. The table was recovered by decoding + /// every payload in the corpus and reading the results as the dimensions they are — the + /// column in the sample decodes to a height of 2600 next to the 600 by 600 section its outline + /// gives, and those two come from different parts of the format, so they confirm each other. + /// + /// + public static class TgDataEncrypt + { + /// + /// The pair value that stands for each character of the decoded text. + /// + /// + /// Twelve entries, being the ten digits, the decimal point and the sign. The digits are + /// deliberately out of order: 6, 9 and 8 and 7 sit below 0. + /// + public static readonly IReadOnlyDictionary Alphabet = new Dictionary + { + { 39, '-' }, + { 36, '.' }, + { 50, '6' }, + { 51, '9' }, + { 56, '8' }, + { 57, '7' }, + { 58, '0' }, + { 59, '1' }, + { 60, '2' }, + { 61, '3' }, + { 62, '4' }, + { 63, '5' }, + }; + + /// + /// The key that applies to the objects in the sample drawings. + /// + public const int DefaultKey = 0xDF; + + /// + /// Reads the number stored in a string written through the obfuscation layer. + /// + /// Ciphertext as it was read from the file. + /// Key for the object, for the samples. + /// The decoded text. + /// A pair decodes to a value outside the alphabet. + public static string Decode(string cipher, int key = DefaultKey) + { + if (cipher == null) + { + throw new ArgumentNullException(nameof(cipher)); + } + + StringBuilder text = new StringBuilder(cipher.Length / 2); + + for (int i = 0; i + 1 < cipher.Length; i += 2) + { + int value = ((cipher[i] & 0xFF) - key) * 26 + ((cipher[i + 1] & 0xFF) - key); + + if (!Alphabet.TryGetValue(value, out char character)) + { + throw new FormatException($"Pair at {i} decodes to {value}, which is not in the alphabet"); + } + + text.Append(character); + } + + return text.ToString(); + } + + /// + /// Reads every obfuscated number in a class specific payload. + /// + /// + /// The scalar fields of a class sit in its payload with nothing to delimit them, so they + /// are found the way the other readers find their data: by looking for what they are. A + /// ciphertext string is recognisable because every character of it has a low byte of + /// 0xC0 or more, which no ordinary text and no class id does, and because it decodes + /// to a number. Strings that fail either test are skipped. + /// + /// The class specific data of the object. + /// Drawing version used by the stream reader. + /// Key for the object, for the samples. + /// The values in the order they appear; never null. + public static IReadOnlyList ReadAllFrom(byte[] payload, ACadVersion version, int key = DefaultKey) + { + if (payload == null) + { + throw new ArgumentNullException(nameof(payload)); + } + + List values = new List(); + + if (payload.Length < 16) + { + return values; + } + + using (MemoryStream stream = new MemoryStream(payload, writable: false)) + { + IDwgStreamReader reader = DwgStreamReaderBase.GetStreamHandler(version, stream, resetPositon: true); + long limit = (long)payload.Length * 8L; + + while (reader.PositionInBits() < limit) + { + long start = reader.PositionInBits(); + string cipher; + + try + { + cipher = reader.ReadVariableText(); + } + catch (Exception) + { + reader.SetPositionInBits(start + 1); + continue; + } + + if (!isCiphertext(cipher)) + { + //Step one bit and try again: the string may start at a different alignment. + reader.SetPositionInBits(start + 1); + continue; + } + + if (TryDecode(cipher, out double value, key)) + { + values.Add(value); + } + } + } + + return values; + } + + private static bool isCiphertext(string value) + { + if (value == null || value.Length < 2 || value.Length > 120 || value.Length % 2 != 0) + { + return false; + } + + foreach (char c in value) + { + //Every character of a ciphertext string sits in the high half of Latin-1, which is + //what makes it distinguishable from a class id, a property name and a marker. + if ((c & 0xFF) < 0xC0) + { + return false; + } + } + + return true; + } + + /// + /// Tries to read the number stored in a string written through the obfuscation layer. + /// + /// Ciphertext as it was read from the file. + /// Receives the decoded number. + /// Key for the object, for the samples. + /// True when the string decoded to a number. + public static bool TryDecode(string cipher, out double value, int key = DefaultKey) + { + value = 0.0; + + if (cipher == null || cipher.Length < 2 || cipher.Length % 2 != 0) + { + return false; + } + + string text; + try + { + text = Decode(cipher, key); + } + catch (FormatException) + { + return false; + } + + return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } + } +}