From 532363ec97d528e33a14c54f86a45244aee4381a Mon Sep 17 00:00:00 2001 From: Li Yanxu Date: Fri, 16 Oct 2026 16:25:01 +0800 Subject: [PATCH 1/2] feat: read native Tianzheng text objects in the reader Tianzheng (TArch) draws its architectural objects as ObjectARX custom objects: a wall, a stair and a dimension are each one object of a class ACadSharp has no reader for, so every one of them arrives as an UnknownEntity and the drawing loses its content. In China most architectural drawings are made this way. This reads the classes whose object is text -- TCH_TEXT, TCH_MTEXT and TCH_DRAWINGNAME -- and converts them to TextEntity. On the sample drawing in issue #1131 that is 13 objects, and all 13 come out with the value, the position and the height that the drawing's own flattened export draws them at. The other 31 objects are left whole and named, so nothing is dropped. The reading is in the reader. `DwgObjectReader.Tangent` recognises the classes, takes the object's data and fills a `TchText`; the entity holds fields and converts them, and never sees the bytes. That split is forced by the layout rather than chosen: these classes write their numbers as text into a string stream, and the stream is described from the end of the object rather than stepped to, so the reader has to see the object whole before it can hand over fields. Walking the filer straight through it does not reach them. The types live in `Entities/Tangent` and the readers in `IO/DWG/Tangent`, which is where the AEC and Mechanical support puts its own. One `case TchEntity:` covers every class in both writers, so the next class added needs no new line there, and the reflection-driven tests skip the base type for the same reason. --- src/ACadSharp.Tests/CadDocumentTests.cs | 7 +- src/ACadSharp.Tests/CadObjectTests.cs | 4 +- src/ACadSharp.Tests/Internal/DxfMapTests.cs | 4 +- src/ACadSharp/Entities/Tangent/TchEntity.cs | 102 +++++++ src/ACadSharp/Entities/Tangent/TchText.cs | 133 +++++++++ src/ACadSharp/Entities/UnknownEntity.cs | 14 + .../DwgObjectReader.Tangent.cs | 97 +++++++ .../DWG/DwgStreamReaders/DwgObjectReader.cs | 39 ++- .../DWG/DwgStreamWriters/DwgObjectWriter.cs | 2 + .../IO/DWG/Tangent/TchObjectReader.cs | 216 ++++++++++++++ .../IO/DWG/Tangent/TchStreamReader.cs | 217 ++++++++++++++ src/ACadSharp/IO/DWG/Tangent/TchTextReader.cs | 271 ++++++++++++++++++ src/ACadSharp/IO/DWG/Tangent/TgDataEncrypt.cs | 172 +++++++++++ .../DxfSectionWriterBase.Entities.cs | 2 + .../IO/Templates/CadTchEntityTemplate.cs | 12 + 15 files changed, 1286 insertions(+), 6 deletions(-) create mode 100644 src/ACadSharp/Entities/Tangent/TchEntity.cs create mode 100644 src/ACadSharp/Entities/Tangent/TchText.cs create mode 100644 src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.Tangent.cs create mode 100644 src/ACadSharp/IO/DWG/Tangent/TchObjectReader.cs create mode 100644 src/ACadSharp/IO/DWG/Tangent/TchStreamReader.cs create mode 100644 src/ACadSharp/IO/DWG/Tangent/TchTextReader.cs create mode 100644 src/ACadSharp/IO/DWG/Tangent/TgDataEncrypt.cs create mode 100644 src/ACadSharp/IO/Templates/CadTchEntityTemplate.cs diff --git a/src/ACadSharp.Tests/CadDocumentTests.cs b/src/ACadSharp.Tests/CadDocumentTests.cs index 54ff8a9b7..b1be6a681 100644 --- a/src/ACadSharp.Tests/CadDocumentTests.cs +++ b/src/ACadSharp.Tests/CadDocumentTests.cs @@ -1,5 +1,6 @@ using ACadSharp.Blocks; using ACadSharp.Entities; +using ACadSharp.Entities.Tangent; using ACadSharp.Objects; using ACadSharp.Tables; using ACadSharp.Tests.Common; @@ -28,7 +29,11 @@ static CadDocumentTests() if (item == typeof(Block) || item == typeof(PdfUnderlay) || item == typeof(BlockEnd) - || item == typeof(UnknownEntity)) + || item == typeof(UnknownEntity) + //A Tianzheng entity is built from the class record it was read with, so it has no + //parameterless constructor. Excluding the base type covers every one of them and + //every one added later. + || typeof(TchEntity).IsAssignableFrom(item)) continue; EntityTypes.Add(item); diff --git a/src/ACadSharp.Tests/CadObjectTests.cs b/src/ACadSharp.Tests/CadObjectTests.cs index 8d2e4061e..a03e0c047 100644 --- a/src/ACadSharp.Tests/CadObjectTests.cs +++ b/src/ACadSharp.Tests/CadObjectTests.cs @@ -2,6 +2,7 @@ using Xunit; using ACadSharp.Tests.Common; using ACadSharp.Entities; +using ACadSharp.Entities.Tangent; using ACadSharp.Objects; using ACadSharp.Tables; using ACadSharp.XData; @@ -20,7 +21,8 @@ static CadObjectTests() { if (item == typeof(UnknownEntity) || item == typeof(PdfUnderlay) - || item == typeof(UnknownNonGraphicalObject)) + || item == typeof(UnknownNonGraphicalObject) + || typeof(TchEntity).IsAssignableFrom(item)) { continue; } diff --git a/src/ACadSharp.Tests/Internal/DxfMapTests.cs b/src/ACadSharp.Tests/Internal/DxfMapTests.cs index def8cc288..7e514c6d1 100644 --- a/src/ACadSharp.Tests/Internal/DxfMapTests.cs +++ b/src/ACadSharp.Tests/Internal/DxfMapTests.cs @@ -1,5 +1,6 @@ using ACadSharp.Attributes; using ACadSharp.Entities; +using ACadSharp.Entities.Tangent; using ACadSharp.Tables; using ACadSharp.Tests.Common; using System; @@ -25,7 +26,8 @@ static DxfMapTests() || item.IsSubclassOf(typeof(TableEntry))) { if (item == typeof(UnknownEntity) - || item == typeof(PdfUnderlay)) + || item == typeof(PdfUnderlay) + || typeof(TchEntity).IsAssignableFrom(item)) { continue; } diff --git a/src/ACadSharp/Entities/Tangent/TchEntity.cs b/src/ACadSharp/Entities/Tangent/TchEntity.cs new file mode 100644 index 000000000..32fc3b0a9 --- /dev/null +++ b/src/ACadSharp/Entities/Tangent/TchEntity.cs @@ -0,0 +1,102 @@ +using ACadSharp.Classes; +using System.Collections.Generic; + +namespace ACadSharp.Entities.Tangent +{ + /// + /// Base class for every entity that comes from a Tianzheng drawing. + /// + /// + /// + /// Tianzheng is a Chinese architectural package built on ObjectARX. Its entities are ordinary + /// custom objects, so the common entity data — layer, colour, handle — is read the usual way; + /// what differs is everything the class writes for itself, which is obfuscated and uses a + /// layout that is not documented anywhere. That reading lives in the reader, not here: these + /// types carry what was read and convert it, and none of them holds the bytes it came from. + /// + /// + /// Because that reading process differs, the result is a distinct type rather than something + /// reshaped into an existing one. A Tianzheng text carries what a + /// carries, but it was written by TDbText::dwgInFields and not by the TEXT reader, + /// so it is a and + /// is how it becomes one when that is what a caller wants. + /// + /// + /// The base class exists so that the writers can leave all of these alone with a single check. + /// ACadSharp cannot write a Tianzheng class correctly — it has no dwgOutFields for one — + /// so they are skipped, and one case covers every class now and every class added later. + /// + /// + public abstract class TchEntity : Entity + { + /// + public override ObjectType ObjectType => ObjectType.UNDEFINED; + + /// + /// Gets the class this entity was read from. + /// + /// + /// The entity type says how the object was read; this says which Tianzheng class it is. + /// Two objects of different classes share a type when they are read the same way, which is + /// why the class is kept rather than encoded in the type. + /// + public DxfClass DxfClass { get; } + + /// + public override string ObjectName => this.DxfClass?.DxfName ?? "TCH_UNKNOWN"; + + /// + public override string SubclassMarker => this.DxfClass?.CppClassName ?? DxfSubclassMarker.Entity; + + /// + /// Initialises an entity from the class it was read as. + /// + /// The Tianzheng class the object was written as. + internal TchEntity(DxfClass dxfClass) + { + this.DxfClass = dxfClass; + } + + /// + /// Converts this object into the standard entities it stands for. + /// + /// + /// + /// Some Tianzheng classes store the shape they draw, and conversion is a direct mapping. + /// Others store parameters from which the shape is derived — a wall keeps a centre line and + /// a thickness and its two side lines are computed — and for those this needs the drawing + /// logic of the class. + /// + /// + /// Classes that have neither give an empty result rather than throwing, so a document can + /// be converted without checking every object first. + /// + /// + /// The standard entities, or empty when there is nothing to convert. + public abstract IEnumerable ToStandardEntities(); + + /// + /// Gives a converted entity the properties the object it came from carries. + /// + /// The object being converted. + /// The standard entity it becomes. + /// The same entity, so a conversion can build and pass it in one expression. + /// + /// A conversion that keeps the geometry and drops the layer and the colour loses what the + /// drawing says about how the result is drawn, which is most of what a drawing is for. These + /// are the common properties that survive: the rest of an entity's state is the class's own + /// and has no standard counterpart. + /// + internal static T CopyCommonProperties(TchEntity source, T target) + where T : Entity + { + target.Layer = source.Layer; + target.Color = source.Color; + target.LineWeight = source.LineWeight; + target.LineType = source.LineType; + target.LineTypeScale = source.LineTypeScale; + + return target; + } + } +} diff --git a/src/ACadSharp/Entities/Tangent/TchText.cs b/src/ACadSharp/Entities/Tangent/TchText.cs new file mode 100644 index 000000000..e7739c44a --- /dev/null +++ b/src/ACadSharp/Entities/Tangent/TchText.cs @@ -0,0 +1,133 @@ +using ACadSharp.Classes; +using CSMath; +using System; +using System.Collections.Generic; + +namespace ACadSharp.Entities.Tangent +{ + /// + /// A Tianzheng text object. + /// + /// + /// + /// Covers the classes whose object is text: TCH_TEXT, TCH_MTEXT and + /// TCH_DRAWINGNAME. They are one type because they are read the same way — the object's + /// string stream is walked for the string that is content rather than a field — and + /// still says which of them it was. + /// + /// + /// It is not a . The bytes were written by TDbText::dwgInFields, + /// not by the TEXT reader, and treating it as one would mean the writers produced a + /// TEXT that was never in the drawing. is how a caller asks for + /// one deliberately. + /// + /// + public class TchText : TchEntity + { + /// + /// Initialises a text from the class it was read as. + /// + internal TchText(DxfClass dxfClass) + : base(dxfClass) + { + } + + /// + /// Gets the insertion point of the text, in drawing coordinates. + /// + /// + /// Zero when is false, which happens when the object carries + /// nothing that reads as a position. + /// + public XYZ InsertionPoint { get; internal set; } = XYZ.Zero; + + /// + /// Gets a value indicating whether a position was found. + /// + public bool HasPosition { get; internal set; } + + /// + /// Gets the text itself. + /// + /// + /// A multiline text keeps its line breaks, so the value can contain \r\n. + /// + public string Value { get; internal set; } + + /// + /// Gets the height the text is drawn at, in drawing units. + /// + /// + /// Not-a-number when the object carries no readable height, in which case + /// leaves the standard text's own default in place. + /// + public double Height { get; internal set; } = double.NaN; + + /// + /// Builds the standard text this object stands for. + /// + /// + /// The result is a new ; this object is left alone. + /// + /// The text entity. + public TextEntity ToTextEntity() + { + TextEntity text = CopyCommonProperties(this, new TextEntity + { + InsertPoint = this.InsertionPoint, + Value = this.Value ?? string.Empty, + }); + + if (!double.IsNaN(this.Height)) + { + text.Height = this.Height; + } + + return text; + } + + /// + public override IEnumerable ToStandardEntities() + { + yield return this.ToTextEntity(); + } + + /// + public override void ApplyTransform(Transform transform) + { + this.InsertionPoint = transform.ApplyTransform(this.InsertionPoint); + } + + /// + /// + /// A text with no position has no extent, so the box is null rather than empty. + /// + public override BoundingBox GetBoundingBox() + { + if (!this.HasPosition) + { + return BoundingBox.Null; + } + + BoundingBox box = BoundingBox.Null; + + foreach (Entity entity in this.ToStandardEntities()) + { + box = box.Merge(entity.GetBoundingBox()); + } + + return box; + } + + /// + /// Says whether a Tianzheng class writes text, and so is read as a + /// rather than as an unlisted entity. + /// + /// DXF name of the class. + /// True when the class writes text. + internal static bool WritesText(string dxfName) + { + return dxfName == "TCH_TEXT" || dxfName == "TCH_MTEXT" || dxfName == "TCH_DRAWINGNAME"; + } + } +} 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.Tangent.cs b/src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.Tangent.cs new file mode 100644 index 000000000..4d72f163a --- /dev/null +++ b/src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.Tangent.cs @@ -0,0 +1,97 @@ +using ACadSharp.Classes; +using ACadSharp.Entities; +using ACadSharp.Entities.Tangent; +using ACadSharp.IO.DWG.Tangent; +using ACadSharp.IO.Templates; +using CSMath; +using System; + +namespace ACadSharp.IO.DWG +{ + /// + /// Reading the Tianzheng (TArch / Tangent) custom objects. + /// + /// + /// Kept apart from the rest of because these objects are not + /// AutoCAD classes: they have no entry in the file's class table that names a reader, so they + /// arrive as unlisted objects and are recognised by their class's DXF name instead. + /// + internal partial class DwgObjectReader + { + /// + /// Whether an unlisted class is one of Tianzheng's. + /// + /// + /// By the DXF name, which every one of them begins with TCH_, and by the application + /// name the class registers under, which does too. In the samples both hold and nothing else + /// in the file shares either. + /// + private static bool isTangent(DxfClass dxfClass) + { + return dxfClass.DxfName.StartsWith("TCH_", StringComparison.Ordinal) + || (dxfClass.ApplicationName?.StartsWith("TCH_", StringComparison.Ordinal) ?? false); + } + + /// + /// Reads a Tianzheng entity. + /// + /// + /// The type follows how the object is read, not which class it is: a class that writes text + /// becomes a , and anything else is kept whole as an + /// until a reader for its class exists. The class itself travels + /// on the entity either way. + /// + private CadTemplate readTangentEntity(DxfClass dxfClass) + { + if (TchText.WritesText(dxfClass.DxfName)) + { + return this.readTangentText(dxfClass); + } + + return this.readUnknownEntity(dxfClass); + } + + /// + /// Reads a Tianzheng class whose object is text. + /// + /// + /// The object's own data is kept while it is being read, because the fields in it are not at + /// offsets the filer can reach: the string stream that holds them is located from the end of + /// the object rather than walked to, and the position is found by trying both of the + /// alignments these classes use. Nothing of that reaches the entity, which is handed the + /// fields rather than the bytes. + /// + private CadTemplate readTangentText(DxfClass dxfClass) + { + TchText entity = new TchText(dxfClass); + CadTchEntityTemplate template = new CadTchEntityTemplate(entity); + + this.readCommonEntityData(template); + + //The class specific fields have no reader in the filer, so the bytes are taken and what + //can be read from them is read here rather than by the entity. + long payloadStart = this._objectReader.PositionInBits(); + + byte[] payload = this.readRemainingObjectData(); + + if (payload == null || payload.Length == 0) + { + this._builder.Notify( + $"Tianzheng object {dxfClass.DxfName} {entity.Handle} carries no data to read", + NotificationType.Warning); + + return template; + } + + //The handle section is where the version specific paths put the handles reader: the field + //this used to read is only filled in for R2010 and later, and an AC1021 drawing positions + //it from the object's own end-of-data long instead. + long handleOffset = this._handlesReader.PositionInBits() - payloadStart; + + TchTextReader.Read(entity, payload, handleOffset); + + return template; + } + + } +} diff --git a/src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.cs b/src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.cs index 5c13baa9e..f9994498b 100644 --- a/src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.cs +++ b/src/ACadSharp/IO/DWG/DwgStreamReaders/DwgObjectReader.cs @@ -2,7 +2,9 @@ using ACadSharp.Classes; using ACadSharp.Entities; using ACadSharp.Entities.AecEntities; +using ACadSharp.Entities.Tangent; using ACadSharp.Entities.Mechanical; +using ACadSharp.IO.DWG.Tangent; using ACadSharp.IO.Templates; using ACadSharp.Objects; using ACadSharp.Objects.AEC; @@ -6228,8 +6230,15 @@ private CadTemplate readUnlistedType(short classNumber) if (template == null && c.IsAnEntity) { - template = this.readUnknownEntity(c); - this._builder.Notify($"Unlisted object with DXF name {c.DxfName} has been read as an UnknownEntity", NotificationType.Warning); + if (isTangent(c)) + { + template = this.readTangentEntity(c); + } + else + { + template = this.readUnknownEntity(c); + this._builder.Notify($"Unlisted object with DXF name {c.DxfName} has been read as an UnknownEntity", NotificationType.Warning); + } } else if (template == null && !c.IsAnEntity) { @@ -7329,7 +7338,6 @@ private CadTemplate readText() return template; } - private CadTemplate readUnknownEntity(DxfClass dxfClass) { UnknownEntity entity = new UnknownEntity(dxfClass); @@ -7337,9 +7345,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/DwgStreamWriters/DwgObjectWriter.cs b/src/ACadSharp/IO/DWG/DwgStreamWriters/DwgObjectWriter.cs index d607032e5..a4774c536 100644 --- a/src/ACadSharp/IO/DWG/DwgStreamWriters/DwgObjectWriter.cs +++ b/src/ACadSharp/IO/DWG/DwgStreamWriters/DwgObjectWriter.cs @@ -2,6 +2,7 @@ using ACadSharp.Classes; using ACadSharp.Entities; using ACadSharp.Entities.AecEntities; +using ACadSharp.Entities.Tangent; using ACadSharp.Entities.Mechanical; using ACadSharp.Objects; using ACadSharp.Tables; @@ -144,6 +145,7 @@ private bool isEntitySupported(Entity entity) switch (entity) { case UnknownEntity: + case TchEntity: return false; case Shape: return this.WriteShapes; diff --git a/src/ACadSharp/IO/DWG/Tangent/TchObjectReader.cs b/src/ACadSharp/IO/DWG/Tangent/TchObjectReader.cs new file mode 100644 index 000000000..d28754803 --- /dev/null +++ b/src/ACadSharp/IO/DWG/Tangent/TchObjectReader.cs @@ -0,0 +1,216 @@ +using System; + +namespace ACadSharp.IO.DWG.Tangent +{ + /// + /// Reading the parts of a Tianzheng object that every class writes the same way. + /// + /// + /// A Tianzheng object is [what the class writes inline][a stream of strings][the handle + /// section], and the class writes its numbers into the middle part as text rather than + /// inline — which is why scanning the object's own bytes for a value never finds one. Locating + /// that stream and reading out of it is shared, because getting it wrong is silent and every + /// class would get it wrong in the same way. + /// + internal static class TchObjectReader + { + /// + /// Where the position a class stores inline begins, as a bit offset into the object's data. + /// + /// + /// A class writes its position before its string stream, and what precedes it is a version + /// byte and one tag per class in the chain, none of which varies. Counting rather than + /// searching is safe here for that reason. + /// + public const long PositionOffset = 66; + + /// + /// Where the position begins for a class whose version prefix is a different length. + /// + /// + /// The prefix is a version byte and a tag per class in the chain, so a class that reads fewer + /// of them starts earlier. Each value here is measured against the class's own drawing rather + /// than derived, and the comment says which measurement it came from. + /// + public const long ShortPositionOffset = 52; + + /// + /// How far below the handle section the end of the string stream is looked for. + /// + /// + /// Only a flag and the size sit between the two, so a handful of bits is enough when the + /// handle section's start is known — which it is while an object is being read. An object + /// that owns many handles has a handle section long enough to push the stream outside a + /// narrower window. Widening it cannot change an answer that was already found, because the + /// search runs downwards and stops at the first fit. + /// + private const long StreamSearchBits = 4096; + + /// + /// Finds where an object's string stream begins, given where its handle section does. + /// + /// The object's bytes. + /// The handle section's start, as a bit offset into the payload. + /// The bit offset of the first string, or -1 when the object has no string stream. + /// + /// The stream is described from its end rather than its start, because its length is known + /// before its contents are: the bit before the handle section says whether there is one, and + /// the sixteen bits below that hold its size in the modular form this format uses for + /// lengths, where the top bit means a second word follows. The start is then that size back + /// from where the size was read. No class needs to know its own offsets, which is the point. + /// + public static long FindStringStream(byte[] payload, long handleOffset) + { + if (payload == null || handleOffset <= 0) + { + return -1; + } + + long bits = (long)payload.Length * 8L; + + //The end of the string stream is at or just before the handle section, and where exactly + //is not something the handle reader knows: it marks the end of the object's data, and the + //stream ends a little before that. Searching for it is cheap and self-checking, because a + //candidate is only right if the strings it describes fill the stream exactly: walking + //them has to land on the size field and not before or after it. + for (long endbit = Math.Min(handleOffset, bits - 1); endbit > handleOffset - StreamSearchBits && endbit > 32; endbit--) + { + TchStreamReader probe = new TchStreamReader(payload, endbit); + + if (!probe.ReadBit()) + { + continue; + } + + long sizeAt = endbit - 16; + + if (sizeAt < 0) + { + continue; + } + + TchStreamReader sizes = new TchStreamReader(payload, sizeAt); + + int size = sizes.ReadUShort(); + + if ((size & 0x8000) != 0) + { + long hi = sizeAt - 16; + + if (hi < 0) + { + continue; + } + + TchStreamReader high = new TchStreamReader(payload, hi); + + size = (size & 0x7FFF) | (high.ReadUShort() << 15); + sizeAt = hi; + } + + long start = sizeAt - size; + + if (start < 0 || start >= sizeAt) + { + continue; + } + + if (fillsStream(payload, start, sizeAt)) + { + return start; + } + } + + return -1; + } + + /// + /// Whether the strings from a position fill the space up to another, exactly. + /// + /// + /// This is what makes the search for the stream's end a check rather than a guess. A wrong + /// candidate describes a run of strings that stops somewhere other than where it should, + /// and that happens for every position but the right one. + /// + private static bool fillsStream(byte[] payload, long start, long end) + { + TchStreamReader reader = new TchStreamReader(payload, start); + + while (reader.PositionInBits < end) + { + int length = reader.ReadBitShort(); + + if (length < 0 || length > 2048) + { + return false; + } + + long next = reader.PositionInBits + (length * 16L); + + if (next > end) + { + return false; + } + + reader = new TchStreamReader(payload, next); + } + + return reader.PositionInBits == end; + } + + /// + /// Reads the number at a position in the string stream. + /// + /// The stream, positioned at the number. + /// The value for the context the class reads its fields in. + /// + /// A field that does not read is left as not-a-number rather than thrown on, and that covers + /// running off the end of the stream as much as it covers a value that does not decode. These + /// classes are read without a specification, so a value that surprises the reader is + /// expected; a throw here costs the whole object, because the reader above drops an entity + /// whose class threw and says only that it could not be read. + /// + public static double ReadStreamedDouble(TchStreamReader reader, byte xor) + { + string cipher; + + try + { + cipher = reader.ReadCipherString(); + } + catch (InvalidOperationException) + { + return double.NaN; + } + + return TgDataEncrypt.TryDecode(cipher, out double value, xor) ? value : double.NaN; + } + + /// + /// Moves past one string in the stream. + /// + /// + /// Reading a string is what advances a reader, so this is only for stepping over one without + /// looking at it. + /// + public static TchStreamReader SkipString(TchStreamReader reader) + { + int length; + + try + { + length = reader.ReadBitShort(); + } + catch (InvalidOperationException) + { + return reader; + } + + long next = reader.PositionInBits + (length * 16L); + + return next >= reader.LengthInBits + ? new TchStreamReader(reader.Payload, reader.LengthInBits) + : new TchStreamReader(reader.Payload, next); + } + } +} diff --git a/src/ACadSharp/IO/DWG/Tangent/TchStreamReader.cs b/src/ACadSharp/IO/DWG/Tangent/TchStreamReader.cs new file mode 100644 index 000000000..2a0136868 --- /dev/null +++ b/src/ACadSharp/IO/DWG/Tangent/TchStreamReader.cs @@ -0,0 +1,217 @@ +using System; +using System.Text; + +namespace ACadSharp.IO.DWG.Tangent +{ + /// + /// Reads a Tianzheng object's string stream, where the class writes its numbers. + /// + /// + /// + /// An object's bytes are split into three parts: what the class writes inline, a stream of + /// strings, and the handle section. A Tianzheng class puts its doubles in the middle part, + /// written as text, so a reader that walks the bytes looking for a number finds none — and a + /// reader that walks them looking for text finds the numbers, misaligned, as whatever the bits + /// happen to say. + /// + /// + /// The order of the stream is the write order, so the fields of a class can be read by walking + /// it in the order dwgInFields reads them. That is what makes a class's field list, and + /// not a set of offsets, the thing to know. + /// + /// + /// This reader is deliberately minimal: the primitives the Tianzheng classes actually use, and + /// no attempt at the whole DWG object format, which the library already reads elsewhere. + /// + /// + internal sealed class TchStreamReader + { + private readonly byte[] _payload; + + private long _position; + + /// + /// Initialises a reader over a payload, positioned at a bit offset. + /// + /// The object's bytes, as they are stored. + /// Where to start reading. + public TchStreamReader(byte[] payload, long startBit) + { + _payload = payload ?? throw new ArgumentNullException(nameof(payload)); + _position = startBit; + } + + /// + /// Gets the bytes being read. + /// + public byte[] Payload => _payload; + + /// + /// Gets the current position, in bits. + /// + public long PositionInBits => _position; + + /// + /// Gets the number of bits in the payload. + /// + public long LengthInBits => (long)_payload.Length * 8L; + + /// + /// Reads a single bit. + /// + public bool ReadBit() + { + return this.readBits(1) != 0; + } + + /// + /// Reads a short held in the variable form the format uses. + /// + /// + /// Two bits choose the width, which is how a value of zero costs two bits and a value of + /// 256 costs two as well. + /// + public int ReadBitShort() + { + int code = (int)this.readBits(2); + + switch (code) + { + case 0: + return (int)this.readBits(16); + + case 1: + return (int)this.readBits(8); + + case 2: + return 0; + + default: + return 256; + } + } + + /// + /// Reads a short written as two bytes, low first. + /// + /// + /// Two bytes and not sixteen bits: the format writes this one little endian, the way it + /// writes any other short, so reading it as one run of bits reverses the halves. The size of + /// a string stream is read this way, and getting it backwards moves the stream's start by + /// tens of thousands of bits without ever failing outright. + /// + public int ReadUShort() + { + int low = (int)this.readBits(8); + + return low | ((int)this.readBits(8) << 8); + } + + /// + /// Reads a long written as four bytes, low first. + /// + public long ReadRawLong() + { + long value = 0; + + for (int i = 0; i < 4; i++) + { + value |= (long)this.readBits(8) << (8 * i); + } + + return value; + } + + /// + /// Reads a double held in the variable form the format uses. + /// + public double ReadBitDouble() + { + int code = (int)this.readBits(2); + + switch (code) + { + case 0: + return this.ReadRawDouble(); + + case 1: + return 1.0; + + default: + return 0.0; + } + } + + /// + /// Reads a double written as sixty four bits. + /// + public double ReadRawDouble() + { + byte[] bytes = new byte[8]; + + for (int i = 0; i < 8; i++) + { + bytes[i] = (byte)this.readBits(8); + } + + return BitConverter.ToDouble(bytes, 0); + } + + /// + /// Reads the ciphertext of a number written through the obfuscation layer. + /// + /// + /// The length is a short and the characters are two bytes each, as any other string in the + /// stream. What makes it ciphertext is the character values, not the framing. + /// + public string ReadCipherString() + { + int length = this.ReadBitShort(); + + if (length <= 0) + { + return string.Empty; + } + + StringBuilder text = new StringBuilder(length); + + for (int i = 0; i < length; i++) + { + //Little endian, like every other short in the format. Reading the pair the other way + //round gives characters that are still characters, so nothing fails: the numbers simply + //come out as something else. + int low = (int)this.readBits(8); + + text.Append((char)(low | (int)this.readBits(8) << 8)); + } + + return text.ToString(); + } + + private long readBits(int count) + { + if (_position + count > this.LengthInBits) + { + throw new InvalidOperationException( + $"Reading {count} bits at {_position} runs past the end of a payload of {this.LengthInBits} bits"); + } + + long value = 0; + + for (int i = 0; i < count; i++) + { + long bit = _position + i; + int octet = _payload[bit >> 3]; + + //The format packs bits high first within each byte. + int taken = (octet >> (7 - (int)(bit & 7))) & 1; + + value = (value << 1) | (uint)taken; + } + + _position += count; + + return value; + } + } +} diff --git a/src/ACadSharp/IO/DWG/Tangent/TchTextReader.cs b/src/ACadSharp/IO/DWG/Tangent/TchTextReader.cs new file mode 100644 index 000000000..75a9e6829 --- /dev/null +++ b/src/ACadSharp/IO/DWG/Tangent/TchTextReader.cs @@ -0,0 +1,271 @@ +using ACadSharp.Entities.Tangent; +using CSMath; +using System; + +namespace ACadSharp.IO.DWG.Tangent +{ + /// + /// Reading a Tianzheng class whose object is text. + /// + /// + /// + /// The object's own data is gone over here rather than walked by the filer, because the fields + /// in it are not at offsets the filer can reach: the string stream that holds them is located + /// from the end of the object rather than stepped to, and the position is found by trying both + /// of the alignments these classes use. Nothing of that reaches the entity, which is handed the + /// fields rather than the bytes. + /// + /// + /// This covers the classes whose object is text — TCH_TEXT, TCH_MTEXT and + /// TCH_DRAWINGNAME. They are read the same way, which is why they are one reader. + /// + /// + public static class TchTextReader + { + /// + /// Fills a text from the object's own data. + /// + /// The entity to fill. + /// The object's bytes. + /// Where the handle section begins, as a bit offset. + public static void Read(TchText text, byte[] payload, long handleOffset) + { + if (text == null || payload == null || payload.Length == 0) + { + return; + } + + long streamOffset = TchObjectReader.FindStringStream(payload, handleOffset); + + readPosition(text, payload); + readContent(text, payload, streamOffset); + + if (streamOffset >= 0) + { + text.Height = readHeight(payload, streamOffset); + } + } + + /// + /// Where each coordinate of the position a text class stores begins, as a bit offset. + /// + /// + /// Two offsets rather than one, because the coordinates are not written as a pair: each is a + /// plain double with no length prefix, sixty eight bits apart. Reading them as a consecutive + /// pair finds a number for each and puts every text in the wrong place, which is what an + /// earlier reading of this class did. + /// + private const long TextYOffset = 84; + + /// + private const long TextXOffset = 152; + + /// + /// Reads the point a text class stores inline. + /// + private static void readPosition(TchText text, byte[] payload) + { + if (payload == null || (long)payload.Length * 8L < TextXOffset + 64L) + { + return; + } + + try + { + double y = new TchStreamReader(payload, TextYOffset).ReadRawDouble(); + double x = new TchStreamReader(payload, TextXOffset).ReadRawDouble(); + + if (double.IsNaN(x) || double.IsNaN(y) + || double.IsInfinity(x) || double.IsInfinity(y) + || Math.Abs(x) > 1e8 || Math.Abs(y) > 1e8) + { + return; + } + + text.InsertionPoint = new XYZ(x, y, 0.0); + text.HasPosition = true; + } + catch (InvalidOperationException) + { + //A payload that cannot be walked that far simply has no position. + } + } + + /// + /// Finds the string in an object that is content rather than a field. + /// + /// + /// Three kinds of string are interleaved: the class identifier and the property block's + /// markers, which are recognisable by shape; the numbers, which are recognisable because they + /// decode through ; and the text, which is what is left. + /// Misaligned reads of the other kinds survive that as short strings of unusual characters, + /// so the longest candidate is taken. + /// + private static void readContent(TchText text, byte[] payload, long streamOffset) + { + long start = streamOffset >= 0 ? streamOffset : 0L; + long limit = (long)payload.Length * 8L; + + string best = null; + long bestEnd = -1; + + for (long offset = start; offset < limit - 8; offset++) + { + string value; + + try + { + TchStreamReader reader = new TchStreamReader(payload, offset); + value = reader.ReadCipherString(); + + if (isContent(value) && (best == null || value.Length > best.Length)) + { + best = value; + bestEnd = reader.PositionInBits; + } + } + catch (InvalidOperationException) + { + continue; + } + } + + text.Value = best; + } + + /// + /// Reads the height the text is drawn at. + /// + /// The object's bytes. + /// Where the string stream begins. + /// The height in drawing units, or not-a-number when it does not read. + /// + /// The value written immediately after the text is the height in the drawing's own scale, so + /// it is the scale that turns it into a length — the sample's texts all carry 3.5 and the + /// export draws every one of them at 350, the drawing being at 1:100. The scale is the second + /// string of the stream, after the class identifier, and is the same in every object of a + /// drawing. + /// + private static double readHeight(byte[] payload, long streamOffset) + { + double scale = TchObjectReader.ReadStreamedDouble( + TchObjectReader.SkipString(new TchStreamReader(payload, streamOffset)), + TgDataEncrypt.ObjectXor); + + if (double.IsNaN(scale) || scale <= 0.0) + { + return double.NaN; + } + + //The text's own string is the one the content search took, and what follows it is the + //height. Finding that string again rather than carrying its end out of the search keeps + //the two readings independent of each other. + long end = findTextEnd(payload, streamOffset); + + if (end < 0 || end >= (long)payload.Length * 8L) + { + return double.NaN; + } + + double height = TchObjectReader.ReadStreamedDouble( + new TchStreamReader(payload, end), TgDataEncrypt.ObjectXor); + + return double.IsNaN(height) || height <= 0.0 ? double.NaN : height * scale; + } + + /// + /// Where the longest content string in an object ends. + /// + private static long findTextEnd(byte[] payload, long streamOffset) + { + long start = streamOffset >= 0 ? streamOffset : 0L; + long limit = (long)payload.Length * 8L; + + string best = null; + long bestEnd = -1; + + for (long offset = start; offset < limit - 8; offset++) + { + try + { + TchStreamReader reader = new TchStreamReader(payload, offset); + string value = reader.ReadCipherString(); + + if (isContent(value) && (best == null || value.Length > best.Length)) + { + best = value; + bestEnd = reader.PositionInBits; + } + } + catch (InvalidOperationException) + { + continue; + } + } + + return bestEnd; + } + + /// + /// Whether a string read out of an object is the text rather than a field. + /// + private static bool isContent(string value) + { + if (value == null || value.Length < 2 || value.Length > 400) + { + return false; + } + + //Markers, field descriptors and the class identifier 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 number written through the obfuscation layer sits among the strings as ciphertext, and + //the two are told apart by shape: every character of a ciphertext pair has a low byte of + //0xC0 or more, which no text does. + if (TgDataEncrypt.IsCiphertext(value)) + { + return false; + } + + int core = 0; + + foreach (char c in value) + { + int o = c; + + //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; + } + } +} diff --git a/src/ACadSharp/IO/DWG/Tangent/TgDataEncrypt.cs b/src/ACadSharp/IO/DWG/Tangent/TgDataEncrypt.cs new file mode 100644 index 000000000..b1500a8f0 --- /dev/null +++ b/src/ACadSharp/IO/DWG/Tangent/TgDataEncrypt.cs @@ -0,0 +1,172 @@ +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 constant the character pairs are biased by. + /// + public const int DefaultKey = 0xDF; + + /// + /// The value the decoded characters are exclusive-ored with, for a nested reader. + /// + /// + /// The reader derives it from the context it is handed: the high byte of context + 0x9D2. + /// A class passing its own context lands at 0x0A, and a nested helper passing zero at 0x09, + /// which is what makes the same bytes read as different characters. + /// + public const byte NestedXor = 0x09; + + /// + public const byte ObjectXor = 0x0A; + + /// + /// The exclusive-or value that applies to a reader given this context. + /// + public static byte XorFor(int context) + { + return (byte)(((context + 0x9D2) >> 8) & 0xFF); + } + + /// + /// Decodes a string written through the obfuscation layer. + /// + /// The characters as the filer stored them. + /// The value for the context the field was read in. + /// The pair bias. + /// + /// Three steps, and missing either of the last two is silent rather than loud. + /// + /// Character pairs are read as one value and then exclusive-ored, so the same pair decodes + /// to different characters in different contexts. A fixed pair-to-character table cannot + /// express that, which is how an earlier version of this class came to hold a table that + /// was self-consistent and wrong. + /// + /// + /// The reader then deletes the characters at indices 0 and 2 of what it decoded — the + /// second deletion is of index 1 after the first, which is the same as index 2 of + /// the original — so the result is the first character plus everything from the fourth on. + /// A number written by this layer carries two characters that are not part of it. + /// + /// + /// The two deletions apply whatever the length, and that matters most for the shortest + /// strings: a one-digit value is written as six characters and decodes to three, of which + /// the middle one is the value. Passing the three through undecoded reads 254 where + /// the object wrote 5, which is a number and not a failure, so nothing complains. + /// + /// + public static string Decode(string cipher, byte xor, 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); + + text.Append((char)(value ^ xor)); + } + + if (text.Length == 0) + { + return string.Empty; + } + + //Deleting index 0 shifts the character that was at index 2 down to index 1, so the + //second deletion is of the original index 2. What survives is the character that was at + //index 1 and everything from index 3 on, and for a text of fewer than four characters + //that is the single character at index 1. + return text.Length < 4 + ? text[1].ToString() + : text[1] + text.ToString(3, text.Length - 3); + } + + /// + /// Whether a string is the ciphertext of a number rather than text. + /// + /// + /// Every character of an obfuscated pair carries a low byte of 0xC0 or more, which is a + /// property of how the writer biases them. Text never does, so the shape separates the two + /// without needing to know which field was being read. + /// + public static bool IsCiphertext(string value) + { + if (string.IsNullOrEmpty(value) || value.Length < 2 || value.Length % 2 != 0) + { + return false; + } + + foreach (char c in value) + { + if ((c & 0xFF) < 0xC0) + { + return false; + } + } + + return true; + } + + /// + /// Reads the number a field holds, for a nested reader. + /// + public static bool TryDecode(string cipher, out double value, byte xor, int key = DefaultKey) + { + value = 0; + + if (string.IsNullOrEmpty(cipher) || cipher.Length < 2 || cipher.Length % 2 != 0) + { + return false; + } + + string text; + + try + { + text = Decode(cipher, xor, key); + } + catch (FormatException) + { + return false; + } + + return double.TryParse(text, NumberStyles.Float, CultureInfo.InvariantCulture, out value); + } + + } +} diff --git a/src/ACadSharp/IO/DXF/DxfStreamWriter/DxfSectionWriterBase.Entities.cs b/src/ACadSharp/IO/DXF/DxfStreamWriter/DxfSectionWriterBase.Entities.cs index 4155b5998..b0dbf2921 100644 --- a/src/ACadSharp/IO/DXF/DxfStreamWriter/DxfSectionWriterBase.Entities.cs +++ b/src/ACadSharp/IO/DXF/DxfStreamWriter/DxfSectionWriterBase.Entities.cs @@ -1,5 +1,6 @@ using ACadSharp.Entities; using ACadSharp.Entities.AecEntities; +using ACadSharp.Entities.Tangent; using ACadSharp.Entities.Mechanical; using ACadSharp.Objects; using CSMath; @@ -149,6 +150,7 @@ private bool isEntitySupported(Entity entity) { case Seqend://Manually assign at the end of the collections case UnknownEntity: + case TchEntity: case MechanicalEntity: case Wall: return false; diff --git a/src/ACadSharp/IO/Templates/CadTchEntityTemplate.cs b/src/ACadSharp/IO/Templates/CadTchEntityTemplate.cs new file mode 100644 index 000000000..1e5477091 --- /dev/null +++ b/src/ACadSharp/IO/Templates/CadTchEntityTemplate.cs @@ -0,0 +1,12 @@ +using ACadSharp.Entities.Tangent; + +namespace ACadSharp.IO.Templates +{ + internal class CadTchEntityTemplate : CadEntityTemplate + { + public CadTchEntityTemplate(TchEntity entity) + : base(entity) + { + } + } +} From 20c04cc82b8d27a8748368f63a044e0af1b3de3c Mon Sep 17 00:00:00 2001 From: Li Yanxu Date: Fri, 16 Oct 2026 16:25:04 +0800 Subject: [PATCH 2/2] test: measure the Tianzheng text reader against the flattened export Each fixture is one object's class specific data, taken from the sample drawing byte for byte, so the tests need no drawing to run and no folder to be configured. What they pin is what the export draws: the position of `Wall & Window & Door` to four places, its height at 350 where the object records 3.5 and the drawing is at 1:100, the content of a single line text, of a multiline one with its line breaks, and of a title block's name. A multiline text is the case that read as nothing for a while, because the filter that told text from a field rejected the line breaks while every single line text still came through. --- .../IO/Tangent/TchTextTests.cs | 237 ++++++++++++++++++ 1 file changed, 237 insertions(+) create mode 100644 src/ACadSharp.Tests/IO/Tangent/TchTextTests.cs diff --git a/src/ACadSharp.Tests/IO/Tangent/TchTextTests.cs b/src/ACadSharp.Tests/IO/Tangent/TchTextTests.cs new file mode 100644 index 000000000..a2222d116 --- /dev/null +++ b/src/ACadSharp.Tests/IO/Tangent/TchTextTests.cs @@ -0,0 +1,237 @@ +using ACadSharp.Classes; +using ACadSharp.Entities; +using ACadSharp.Entities.Tangent; +using ACadSharp.IO.DWG.Tangent; +using CSMath; +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text; +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 the object as it was read. + /// + public class TchTextTests + { + private const string TextPayload = + "ChGQVQWQOQOBBEwC9uR0z13ECPgOLDkLIeNASZ7AEYAOABGADAARgAxADIARgAtADkAMwA4ADkALQA0ADkARgBDAC0AOAAzA" + + "DUARAAtADgAQwA4ADYAQwA2AEUARQBFAEUARABCAH0ASzhAOgA4QDmAOEA5wDhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAO" + + "UA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOkA4QDlAOAA+ADgAOkA4QDlAOE" + + "A5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEG4QDmAOEA5QDhAOQA" + + "RhUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBBVQAMgAwAGQAMQBBVQAMgAwAGQAMgB" + + "BVQAMgAwAGQAMwBBVQAMgAwAGQANABFlQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEUATgBEAF8ASZ7AEI" + + "AMAA3ADEAMwBFADYAQgAtAEEARgBGADYALQA0ADgANwAxAC0AQQAyADcAMwAtADcAOAA5ADYAOAAzADQARAA2ADYANwA2AH0" + + "AQbhAOUA4QDlAOEA5gBFFcAYQBsAGwAIAAmACAAVwBpAG4AZABvAHcAIAAmACAARABvAG8AcgBKOEA6ADhAOQA4QDmAOAA6Q" + + "DhAOoA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOYA4QD" + + "lAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QD" + + "lAEG4QDpAOEA6ADhAOYAQbhAOMA4QDlAOEA4wCKFqkBRSiIoCB/f"; + + private const string SquareColumnFixture = + "ChGQVQWQOQOBBEbBi4iLTH3ECBSlmpfmkOFASZ7AEYAOABGADAARgAxADIARgAtADkAMwA4ADkALQA0ADkARgBDAC0AOAAzA" + + "DUARAAtADgAQwA4ADYAQwA2AEUARQBFAEUARABCAH0ASzhAOUA4QDmAOEA5ADhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAO" + + "UA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOkA4QDlAOAA+ADgAOkA4QDlAOE" + + "A5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEG4AD4AOEA5QDgAPcA" + + "RhUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAEwATwBDAEsAXwBCAEUARwBJAE4AXwBBVQAMgAwAGQAMQBBVQAMgAwAGQAMgB" + + "BVQAMgAwAGQAMwBBVQAMgAwAGQANABFlQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEUATgBEAF8ASZ7AEI" + + "AMAA3ADEAMwBFADYAQgAtAEEARgBGADYALQA0ADgANwAxAC0AQQAyADcAMwAtADcAOAA5ADYAOAAzADQARAA2ADYANwA2AH0" + + "AQbgAPcA4QDlAOEA6gBDVMAcQB1AGEAcgBlACAAQwBvAGwAdQBtAG4ASjhAOkA4QDkAOAA9wDgAOkA4QDqAOEA5QDhAOUA4Q" + + "DlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4AD3AOEA5QDhAOoA4ADpAOEA5QD" + + "hAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBBuEA4wDhAOgA4QDk" + + "AEG4QDlAOEA5QDhAOcAGhapAUUoiKAgf7w=="; + + private const string MTextFixture = + "ChGQVQWQOQOBBEhF+0xNYe1kCEJmZ7s+5OVAAhFJnsARABGADcAMAA4AEIAMAAxAC0ARQBGADkAMAAtADQAOQA5ADIALQBCA" + + "DgAQwA1AC0AMgBCADgANgBBADcARgA5AEQANQAxADYAfQBLOEA5gDhAOYA4QDqAOEA5QDhAOUA4ADpAOEA5QDhAOUA4QDlAO" + + "EA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA4wDhAOUA4QDqAOAA6QDhAOU" + + "A4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQbgAPgA4QDlAOEA" + + "4wBGFQARABCAFgARABBAFQAQQBNAEEAUABfAEIATABPAEMASwBfAEIARQBHAEkATgBfAEFVAAyADAAZAAxAEFVAAyADAAZAA" + + "yAEFVAAyADAAZAAzAEFVAAyADAAZAA0AEWVABEAEIAWABEAEEAVABBAE0AQQBQAF8AQgBMAE8AQwBLAF8ARQBOAEQAXwBJns" + + "AQgAwADcAMQAzAEUANgBCAC0AQQBGAEYANgAtADQAOAA3ADEALQBBADIANwAzAC0ANwA4ADkANgA4ADMANABEADYANgA3ADY" + + "AfQBBuEA5ADhAOUA4QDpAErTQB1AGwAdABpAGwAaQBuAGUAIABUAGUAeAB0AA0ACgBMAGkAbgBlACAAMgANAAoATABpAG4AZ" + + "QAgADMADQAKAC4ALgAuAA0ACgBMAGkAbgBlACAATgBKOEA4wDhAOQA4QDqAOAA6QDhAOoA4QDlAOEA5QDhAOUA4QDlAOEA5Q" + + "DhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjgAPgA4QDlAOEA6ADgAOkA4QDlAOEA5QDhAOUA4QD" + + "lAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEI4QDpAOEA5gDhAOQA4QDlAEG4QDpA" + + "OEA5QDhAOgASzhAOQA4QDjAOEA6gDhAOUA4QDlAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAO" + + "UA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOoA4QDmAOEA5wDgAOkA4QDpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOE" + + "A5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDmAG4dqQFFKIigIHF"; + + private const string DrawingNameFixture = + "ChGQQRqD65+LEYs81AgCkSsDO6PkQEmewBGADgARgAwAEYAMQAyAEYALQA5ADMAOAA5AC0ANAA5AEYAQwAtADgAMwA1AEQAL" + + "QA4AEMAOAA2AEMANgBFAEUARQBFAEQAQgB9AEs4AD4AOEA5gDhAOcA4QDlAOEA5QDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5Q" + + "DhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDqAOEA5QDhAOMA4ADpAOEA5QDhAOUA4QD" + + "lAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBBuAA9wDhAOUA4QDqAEYVABEA" + + "EIAWABEAEEAVABBAE0AQQBQAF8AQgBMAE8AQwBLAF8AQgBFAEcASQBOAF8ARZUAEQAQgBYAEQAQQBUAEEATQBBAFAAXwBCAE" + + "wATwBDAEsAXwBFAE4ARABfAEmewBCADAANwAxADMARQA2AEIALQBBAEYARgA2AC0ANAA4ADcAMQAtAEEAMgA3ADMALQA3ADg" + + "AOQA2ADgAMwA0AEQANgA2ADcANgB9AEG4AD4AOEA5QDhAOQAQ1EAHIAYQB3AGkAbgBnACAAVABpAHQAbABlAEFMQA6ADEAMA" + + "AwAEo4QDoAOEA5QDhAOgA4ADpAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QD" + + "hAOUA4QDlAOEA5QBKOEA5wDhAOgA4QDoAOAA6QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDl" + + "AOEA5QDhAOUA4QDlAOEA5QDhAOUASjhAOcA4QDqAOEA4wDgAOkA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhA" + + "OUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAEo4QDlAOEA5QDgAPgA4ADpAOEA6ADhAOUA4QDlAOEA5QDhAOUA4QDlAO" + + "EA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QBKOEA5gDhAOUA4QDqAOAA6QDhAOcA4QDlAOEA5QDhAOU" + + "A4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUA4QDlAOEA5QDhAOUAQbhAOcA4QDlAOEA4wBBuAA+ADhAOYA4" + + "QDmAEG4QDqAOEA6ADhAOcAFB2pAUQoiKiI/L"; + + private static byte[] decode(string base64) + { + return Convert.FromBase64String(base64); + } + + private static DxfClass tchClass(string dxfName) + { + return new DxfClass + { + DxfName = dxfName, + CppClassName = "TDb" + dxfName.Substring(4), + ApplicationName = "TCH_KERNAL", + }; + } + + private static TchText read(string dxfName, string fixture) + { + byte[] payload = decode(fixture); + TchText text = new TchText(tchClass(dxfName)); + TchTextReader.Read(text, payload, payload.Length * 8L); + return text; + } + + [Fact] + public void TchTextReadsThePositionTheExportDrawsItAt() + { + TchText text = read("TCH_TEXT", TextPayload); + + Assert.True(text.HasPosition); + Assert.Equal(39176.3507, text.InsertionPoint.X, 4); + Assert.Equal(29653.1919, text.InsertionPoint.Y, 4); + } + + [Fact] + public void TchTextReadsTheHeightItIsDrawnAt() + { + //The value after the text is the height in the drawing's scale, and the scale is the + //second string of the stream. This object carries 3.5 and the drawing is at 1:100, which + //is the 350 the export draws it at. + TchText text = read("TCH_TEXT", TextPayload); + + Assert.Equal(350.0, text.Height, 6); + Assert.Equal(350.0, text.ToTextEntity().Height, 6); + } + + [Fact] + public void TchTextReadsTheContent() + { + TchText text = read("TCH_TEXT", TextPayload); + + //The export splits this into per-word text entities, but the object stores it whole. + Assert.Equal("Wall & Window & Door", text.Value); + } + + [Fact] + public void TchTextReadsContentThatLooksLikeNothingElse() + { + Assert.Equal("Square Column", read("TCH_TEXT", SquareColumnFixture).Value); + } + + [Fact] + public void TchTextReadsTheContentOfAMultilineText() + { + //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. + TchText text = read("TCH_MTEXT", MTextFixture); + + Assert.Equal("Multiline Text\r\nLine 2\r\nLine 3\r\n...\r\nLine N", text.Value); + } + + [Fact] + public void TchTextReadsTheNameOfATitleBlock() + { + Assert.Equal("Drawing Title", read("TCH_DRAWINGNAME", DrawingNameFixture).Value); + } + + [Fact] + public void TchTextIsNotATextEntityButConvertsToThem() + { + TchText text = read("TCH_TEXT", TextPayload); + + Assert.IsNotType(text); + + TextEntity converted = text.ToStandardEntities().OfType().Single(); + + Assert.Equal(text.Value, converted.Value); + Assert.Equal(text.InsertionPoint.X, converted.InsertPoint.X, 9); + Assert.Equal(text.InsertionPoint.Y, converted.InsertPoint.Y, 9); + } + + [Fact] + public void TchTextCarriesTheClassItWasReadFrom() + { + Assert.Equal("TCH_MTEXT", read("TCH_MTEXT", MTextFixture).DxfClass.DxfName); + } + + [Fact] + public void TchTextMovesWithTheTransform() + { + TchText text = read("TCH_TEXT", TextPayload); + + XYZ before = text.InsertionPoint; + + text.ApplyTransform(Transform.CreateTranslation(new XYZ(10.0, 20.0, 0.0))); + + Assert.Equal(before.X + 10.0, text.InsertionPoint.X, 9); + Assert.Equal(before.Y + 20.0, text.InsertionPoint.Y, 9); + } + + [Fact] + public void TchTextRejectsAPayloadThatIsTooShort() + { + TchText text = new TchText(tchClass("TCH_TEXT")); + + TchTextReader.Read(text, new byte[8], 64L); + + Assert.False(text.HasPosition); + Assert.Null(text.Value); + } + + [Fact] + public void TchTextTakesNothingFromANullPayload() + { + TchText text = new TchText(tchClass("TCH_TEXT")); + + TchTextReader.Read(text, null, 0L); + + Assert.False(text.HasPosition); + } + + [Fact] + public void TchObjectReaderFindsTheStringStream() + { + //The stream is described from its end, and the search only accepts a candidate whose + //strings fill the space up to the size field exactly. On this object that lands on bit + //216, which is where the first string begins. + byte[] payload = decode(TextPayload); + + Assert.Equal(216L, TchObjectReader.FindStringStream(payload, payload.Length * 8L)); + } + + [Fact] + public void TchObjectReaderReadsTheScaleEveryObjectCarries() + { + //The second string of the stream, after the class identifier, is the drawing's scale, + //and it is what turns the multiples a class records into lengths. + byte[] payload = decode(TextPayload); + long stream = TchObjectReader.FindStringStream(payload, payload.Length * 8L); + + double scale = TchObjectReader.ReadStreamedDouble( + TchObjectReader.SkipString(new TchStreamReader(payload, stream)), TgDataEncrypt.ObjectXor); + + Assert.Equal(100.0, scale, 9); + } + } +}